5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-05-21 11:40:47 +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
# !./archivo
!./monit.conf

View file

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

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 PANDOC_VERSION=2.17.1.1
ARG RUBY_VERSION=2.7
ARG RUBY_PATCH=6
ARG ALPINE_VERSION=3.13.10
ARG BASE_IMAGE=registry.nulo.in/sutty/rails
FROM ${BASE_IMAGE}:${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}
ARG PANDOC_VERSION=2.18
ENV RAILS_ENV production
# Instalar las dependencias, separamos la librería de base de datos para
@ -10,10 +14,13 @@ ENV RAILS_ENV production
# principal
RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \
rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \
yarn daemonize ruby-webrick
yarn daemonize ruby-webrick postgresql-client dateutils file
RUN gem install --no-document --no-user-install foreman
RUN wget https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz -O - | tar --strip-components 1 -xvzf - pandoc-${PANDOC_VERSION}/bin/pandoc && mv /bin/pandoc /usr/bin/pandoc
RUN apk add npm && npm install -g pnpm@~7 && apk del npm
COPY ./monit.conf /etc/monit.d/sutty.conf
VOLUME "/srv"

View file

@ -38,6 +38,8 @@ gem 'commonmarker'
gem 'devise'
gem 'devise-i18n'
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 'exception_notification'
gem 'fast_blank'
@ -89,7 +91,7 @@ gem 'stackprof'
gem 'prometheus_exporter'
# debug
gem 'fast_jsonparser'
gem 'fast_jsonparser', '~> 0.5.0'
gem 'down'
gem 'sourcemap'
gem 'rack-cors'

View file

@ -115,6 +115,7 @@ GEM
xpath (>= 2.0, < 4.0)
chartkick (4.1.2)
childprocess (4.1.0)
climate_control (1.2.0)
coderay (1.1.3)
colorator (1.1.0)
commonmarker (0.21.2-x86_64-linux-musl)
@ -153,12 +154,45 @@ GEM
devise_invitable (2.0.5)
actionmailer (>= 5.0)
devise (>= 4.6)
distributed-press-api-client (0.2.2)
addressable (~> 2.3, >= 2.3.0)
climate_control
dry-schema
httparty (~> 0.18)
json (~> 2.1, >= 2.1.0)
jwt (~> 2.6.0)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.4)
addressable (~> 2.8)
dry-configurable (1.0.1)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-core (1.0.0)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (1.0.0)
dry-initializer (3.1.1)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-schema (1.13.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.0, < 2)
dry-initializer (~> 3.0)
dry-logic (>= 1.5, < 2)
dry-types (>= 1.7, < 2)
zeitwerk (~> 2.6)
dry-types (1.7.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-inflector (~> 1.0, < 2)
dry-logic (>= 1.4, < 2)
zeitwerk (~> 2.6)
ed25519 (1.2.4-x86_64-linux-musl)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
@ -216,8 +250,8 @@ GEM
thor
hiredis (0.6.3-x86_64-linux-musl)
http_parser.rb (0.8.0-x86_64-linux-musl)
httparty (0.18.1)
mime-types (~> 3.0)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
@ -261,7 +295,7 @@ GEM
jekyll (~> 4)
jekyll-ignore-layouts (0.1.2)
jekyll (~> 4)
jekyll-images (0.3.0)
jekyll-images (0.3.2)
jekyll (~> 4)
ruby-filemagic (~> 0.7)
ruby-vips (~> 2)
@ -292,6 +326,7 @@ GEM
jekyll-write-and-commit-changes (0.2.1)
jekyll (~> 4)
rugged (~> 1)
jwt (2.6.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@ -352,6 +387,9 @@ GEM
nokogiri (1.12.5-x86_64-linux-musl)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
njalla-api-client (0.1.0)
dry-schema
httparty (~> 0.18)
orm_adapter (0.5.0)
parallel (1.21.0)
parser (3.0.2.0)
@ -578,6 +616,7 @@ DEPENDENCIES
devise
devise-i18n
devise_invitable
distributed-press-api-client (~> 0.2.2)
dotenv-rails
down
ed25519
@ -612,6 +651,7 @@ DEPENDENCIES
mini_magick
mobility
net-ssh
njalla-api-client
nokogiri
pg
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: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
cleanup: bundle exec rake cleanup:everything
stats: bundle exec rake stats:process_all

View file

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

View file

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

View file

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

View file

@ -46,17 +46,19 @@ class ApplicationController < ActionController::Base
# defecto.
#
# Esto se refiere al idioma de la interfaz, no de los artículos.
def current_locale(include_params: true, site: nil)
return params[:locale] if include_params && params[:locale].present?
#
# @return [String,Symbol]
def current_locale
session[:locale] = params[:change_locale_to] if params[:change_locale_to].present?
current_usuarie&.lang || I18n.locale
session[:locale] || current_usuarie&.lang || I18n.locale
end
# El idioma es el preferido por le usuarie, pero no necesariamente se
# corresponde con el idioma de los artículos, porque puede querer
# traducirlos.
def set_locale(&action)
I18n.with_locale(current_locale(include_params: false), &action)
I18n.with_locale(current_locale, &action)
end
# Muestra una página 404
@ -88,4 +90,12 @@ class ApplicationController < ActionController::Base
def prepare_exception_notifier
request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie }
end
# Olvidar el idioma elegido antes de iniciar la sesión y reenviar a
# los sitios en el idioma de le usuarie.
def after_sign_in_path_for(resource)
session[:locale] = nil
sites_path
end
end

View file

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

View file

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

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
class DeployJob < ApplicationJob
class DeployException < StandardError; end
class DeployTimedOutException < DeployException; end
discard_on ActiveRecord::RecordNotFound
# rubocop:disable Metrics/MethodLength
def perform(site, notify = true, time = Time.now)
def perform(site, notify: true, time: Time.now, output: false)
@output = output
ActiveRecord::Base.connection_pool.with_connection do
@site = Site.find(site)
@ -16,32 +21,39 @@ class DeployJob < ApplicationJob
# hora original para poder ir haciendo timeouts.
if @site.building?
if 10.minutes.ago >= time
@site.update status: 'waiting'
raise DeployException,
notify = false
raise DeployTimedOutException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
end
DeployJob.perform_in(60, site, notify, time)
DeployJob.perform_in(60, site, notify: notify, time: time, output: output)
return
end
@site.update status: 'building'
# 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
unless @deployed[:deploy_local]
@site.update status: 'waiting'
notify_usuaries if notify
unless @deployed[:deploy_local][:status]
# Hacer fallar la tarea
raise DeployException, deploy_local.build_stats.last.log
raise DeployException, "#{@site.name}: Falló la compilación"
end
deploy_others
# Volver a la espera
@site.update status: 'waiting'
rescue DeployTimedOutException => e
notify_exception e
rescue DeployException => e
notify_exception e, deploy_local
ensure
@site&.update status: 'waiting'
notify_usuaries if notify
end
@ -50,17 +62,44 @@ class DeployJob < ApplicationJob
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
@deploy_local ||= @site.deploys.find_by(type: 'DeployLocal')
end
def deploy_locally
deploy_local.deploy
deploy_local.deploy(output: @output)
end
def deploy_others
@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

View file

@ -3,6 +3,8 @@
# Notifica excepciones a una instancia de Gitlab, como incidencias
# nuevas o como comentarios a las incidencias pre-existentes.
class GitlabNotifierJob < ApplicationJob
class GitlabNotifierError < StandardError; end
include ExceptionNotifier::BacktraceCleaner
# Variables que vamos a acceder luego
@ -18,22 +20,28 @@ class GitlabNotifierJob < ApplicationJob
@issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía
@cached = false
@issue = {}
# Traemos los datos desde la caché si existen, sino generamos un
# issue nuevo e inicializamos la caché
@issue_data = Rails.cache.fetch(cache_key) do
issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
@issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
@cached = true
{
count: 1,
issue: issue['iid'],
issue: @issue['iid'],
user_agents: [user_agent].compact,
params: [request&.filtered_parameters].compact,
urls: [url].compact
}
end
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
return if cached
@ -104,6 +112,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def description
@description ||= ''.dup.tap do |d|
d << log_section
d << request_section
d << javascript_section
d << javascript_footer
@ -151,6 +160,19 @@ class GitlabNotifierJob < ApplicationJob
@client ||= GitlabApiClient.new
end
# @return [String]
def log_section
return '' unless options[:log]
<<~LOG
# Log
```
#{options[:log]}
```
LOG
end
# Muestra información de la petición
#
# @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
# 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
# firma. Esto permite que luego podamos guardar el archivo donde
# corresponde.
@ -67,7 +79,9 @@ module ActiveStorage
# @param :key [String]
# @return [String]
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
# Crea una ruta para la llave con un nombre conocido.

View file

@ -8,21 +8,66 @@
# TODO: Agregar firma GPG y header Autocrypt
# TODO: Cifrar con GPG si le usuarie nos dio su llave
class DeployMailer < ApplicationMailer
include ActionView::Helpers::NumberHelper
include ActionView::Helpers::DateHelper
# rubocop:disable Metrics/AbcSize
def deployed(which_ones)
@usuarie = Usuarie.find(params[:usuarie])
@site = @usuarie.sites.find(params[:site])
@deploys = which_ones
@deploy_local = @site.deploys.find_by(type: 'DeployLocal')
def deployed(deploys = {})
usuarie = Usuarie.find(params[:usuarie])
site = usuarie.sites.find(params[:site])
hostname = site.hostname
deploys ||= {}
# Informamos a cada quien en su idioma y damos una dirección de
# respuesta porque a veces les usuaries nos escriben
I18n.with_locale(@usuarie.lang) do
mail(to: @usuarie.email,
reply_to: "sutty@#{Site.domain}",
subject: I18n.t('deploy_mailer.deployed.subject',
site: @site.name))
I18n.with_locale(usuarie.lang) do
subject = t('.subject', site: site.name)
@hi = t('.hi')
@explanation = t('.explanation', fqdn: hostname)
@help = t('.help')
@headers = %w[type status url seconds size].map do |header|
t(".th.#{header}")
end
@table = deploys.each_pair.map do |deploy, value|
{
title: t(".#{deploy}.title"),
status: t(".#{deploy}.#{value[:status] ? 'success' : 'error'}"),
urls: value[:urls],
seconds: {
human: distance_of_time_in_words(value[:seconds].seconds),
machine: "PT#{value[:seconds]}S"
},
size: number_to_human_size(value[:size], precision: 2)
}
end
@terminal_table = Terminal::Table.new do |t|
t << @headers
t.add_separator
@table.each do |row|
row[:urls].each do |url|
t << (row.map do |k, v|
case k
when :seconds then v[:human]
when :urls then url
else v
end
end)
end
end
end
mail(to: usuarie.email, reply_to: "sutty@#{Site.domain}", subject: subject)
end
end
# rubocop:enable Metrics/AbcSize
private
def t(key, **args)
I18n.t("deploy_mailer.deployed#{key}", **args)
end
end

View file

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

View file

@ -5,7 +5,7 @@ class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON
# Generar un link simbólico del sitio principal al alternativo
def deploy
def deploy(**)
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
@ -18,6 +18,10 @@ class DeployAlternativeDomain < Deploy
end
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

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
class DeployHiddenService < DeployWww
def deploy
def deploy(**)
return true if fqdn.blank?
super
@ -13,6 +13,6 @@ class DeployHiddenService < DeployWww
end
def url
'http://' + fqdn
"http://#{fqdn}"
end
end

View file

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

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

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

View file

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

View file

@ -12,7 +12,7 @@ class DeployZip < Deploy
# y generar un zip accesible públicamente.
#
# rubocop:disable Metrics/MethodLength
def deploy
def deploy(**)
FileUtils.rm_f path
time_start
@ -49,6 +49,10 @@ class DeployZip < Deploy
"#{site.hostname}.zip"
end
def url
"#{site.url}#{file}"
end
def path
File.join(destination, file)
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
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
end
def elements_with_style
@elements_with_style ||= %w[div mark].freeze
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}.path_required") if path_missing?
errors << I18n.t("metadata.#{type}.no_file_for_description") if no_file_for_description?
errors << I18n.t("metadata.#{type}.attachment_missing") if path? && !static_file
errors.compact!
@ -41,14 +40,14 @@ class MetadataFile < MetadataTemplate
end
# Asociar la imagen subida al sitio y obtener la ruta
#
# XXX: Si evitamos guardar cambios con changed? no tenemos forma de
# saber que un archivo subido manualmente se convirtió en
# un Attachment y cada vez que lo editemos vamos a subir una imagen
# repetida.
# @return [Boolean]
def save
value['description'] = sanitize value['description']
value['path'] = static_file ? relative_destination_path_with_filename.to_s : nil
if value['path'].blank?
self[:value] = default_value
else
value['description'] = sanitize value['description']
value['path'] = relative_destination_path_with_filename.to_s if static_file
end
true
end
@ -62,9 +61,6 @@ class MetadataFile < MetadataTemplate
# * El archivo es una ruta que apunta a un archivo asociado al sitio
# * El archivo es una ruta a un archivo dentro del repositorio
#
# XXX: La última opción provoca archivos duplicados, pero es lo mejor
# que tenemos hasta que resolvamos https://0xacab.org/sutty/sutty/-/issues/213
#
# @todo encontrar una forma de obtener el attachment sin tener que
# recurrir al último subido.
#
@ -75,13 +71,7 @@ class MetadataFile < MetadataTemplate
when ActionDispatch::Http::UploadedFile
site.static_files.last if site.static_files.attach(value['path'])
when String
if (blob_id = ActiveStorage::Blob.where(key: key_from_path).pluck(:id).first)
site.static_files.find_by(blob_id: blob_id)
elsif path? && pathname.exist? && site.static_files.attach(io: pathname.open, filename: pathname.basename)
site.static_files.last.tap do |s|
s.blob.update(key: key_from_path)
end
end
site.static_files.find_by(blob_id: blob_id) || migrate_static_file!
end
end
@ -98,7 +88,7 @@ class MetadataFile < MetadataTemplate
#
# @return [String]
def key_from_path
pathname.dirname.basename.to_s
@key_from_path ||= pathname.dirname.basename.to_s
end
def path?
@ -127,13 +117,22 @@ class MetadataFile < MetadataTemplate
# devolvemos la ruta original, que puede ser el archivo que no existe
# o vacía si se está subiendo uno.
rescue Errno::ENOENT => e
ExceptionNotifier.notify_exception(e)
ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
value['path']
Pathname.new(File.join(site.path, value['path']))
end
# Obtener la ruta relativa al sitio.
#
# Si algo falla, devolver la ruta original para no romper el archivo.
#
# @return [String, nil]
def relative_destination_path_with_filename
destination_path_with_filename.relative_path_from(Pathname.new(site.path).realpath)
rescue ArgumentError => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
value['path']
end
def static_file_path
@ -145,8 +144,31 @@ class MetadataFile < MetadataTemplate
end
end
# No hay archivo pero se lo describió
def no_file_for_description?
!path? && description?
# Obtiene el id del blob asociado
#
# @return [Integer,nil]
def blob_id
@blob_id ||= ActiveStorage::Blob.where(key: key_from_path, service_name: site.name).pluck(:id).first
end
# Genera el blob para un archivo que ya se encuentra en el
# repositorio y lo agrega a la base de datos.
#
# @return [ActiveStorage::Attachment]
def migrate_static_file!
raise ArgumentError, 'El archivo no existe' unless path? && pathname.exist?
Site.transaction do
blob =
ActiveStorage::Blob.create_after_unfurling!(key: key_from_path,
io: pathname.open,
filename: pathname.basename,
service_name: site.name)
ActiveStorage::Attachment.create!(name: 'static_files', record: site, blob: blob)
end
rescue ArgumentError => e
ExceptionNotifier.notify_exception(e, data: { site: site.name, path: value['path'] })
nil
end
end

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.
class MetadataPermalink < MetadataString
# El valor por defecto una vez creado es la URL que le asigne Jekyll,
# de forma que nunca cambia aunque se cambie el título.
def default_value
document.url.sub(%r{\A/}, '') unless post.new?
end
# Los permalinks nunca pueden ser privados
def private?
false

View file

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

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
# guardado
def save
return true unless changed?
if !changed?
self[:value] = document_value if private?
return true
end
self[:value] = sanitize value
self[:value] = encrypt(value) if private?

View file

@ -29,7 +29,7 @@ class Post
# TODO: Reemplazar cuando leamos el contenido del Document
# a demanda?
def find_layout(path)
IO.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
File.foreach(path).lazy.grep(/^layout: /).take(1).first&.split(' ')&.last&.tr('\'', '')&.tr('"', '')&.to_sym
end
end
@ -90,16 +90,21 @@ class Post
'page' => document.to_liquid
}
# No tener errores de Liquid
site.jekyll.config['liquid']['strict_filters'] = false
site.jekyll.config['liquid']['strict_variables'] = false
# Renderizar lo estrictamente necesario y convertir a HTML para
# poder reemplazar valores.
html = Nokogiri::HTML document.renderer.render_document
# Las imágenes se cargan directamente desde el repositorio, porque
# Los archivos se cargan directamente desde el repositorio, porque
# no son públicas hasta que se publica el artículo.
html.css('img').each do |img|
next if %r{\Ahttps?://} =~ img.attributes['src']
html.css('img,audio,video,iframe').each do |element|
src = element.attributes['src']
img.attributes['src'].value = Rails.application.routes.url_helpers.site_static_file_url(site,
file: img.attributes['src'].value)
next unless src&.value&.start_with? 'public/'
src.value = Rails.application.routes.url_helpers.site_static_file_url(site, file: src.value)
end
# Notificar a les usuaries que están viendo una previsualización
@ -108,12 +113,16 @@ class Post
# Cacofonía
html.to_html.html_safe
rescue Liquid::Error => e
ExceptionNotifier.notify(e, data: { site: site.name, post: post.id })
''
end
end
# Devuelve una llave para poder guardar el post en una cache
def cache_key
'posts/' + uuid.value
"posts/#{uuid.value}"
end
def cache_version
@ -123,7 +132,7 @@ class Post
# Agregar el timestamp para saber si cambió, siguiendo el módulo
# ActiveRecord::Integration
def cache_key_with_version
cache_key + '-' + cache_version
"#{cache_key}-#{cache_version}"
end
# TODO: Convertir a UUID?

View file

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

View file

@ -17,7 +17,7 @@ class Site < ApplicationRecord
# TODO: Hacer que los diferentes tipos de deploy se auto registren
# @see app/services/site_service.rb
DEPLOYS = %i[local private www zip hidden_service].freeze
DEPLOYS = %i[local private www zip hidden_service distributed_press].freeze
validates :name, uniqueness: true, hostname: {
allow_root_label: true
@ -179,10 +179,20 @@ class Site < ApplicationRecord
# Siempre tiene que tener algo porque las traducciones están
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
# sus sitios.
#
# @return [Array]
def locales
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
end
# Modificar los locales disponibles
#
# @param :new_locales [Array]
# @return [Array]
def locales=(new_locales)
@locales = new_locales.map(&:to_sym).uniq
end
# Similar a site.i18n en jekyll-locales
#
# @return [Hash]
@ -250,6 +260,8 @@ class Site < ApplicationRecord
layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang)
rescue TypeError => e
ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path })
end
@posts[lang]
@ -425,7 +437,7 @@ class Site < ApplicationRecord
# El directorio donde se almacenan los sitios
def self.site_path
@site_path ||= ENV.fetch('SITE_PATH', Rails.root.join('_sites'))
@site_path ||= File.realpath(ENV.fetch('SITE_PATH', Rails.root.join('_sites')))
end
def self.default
@ -484,6 +496,7 @@ class Site < ApplicationRecord
config.title = title
config.url = url(slash: false)
config.hostname = hostname
config.locales = locales.map(&:to_s)
end
# Valida si el sitio tiene al menos una forma de alojamiento asociada

View file

@ -33,10 +33,10 @@ class Site
def write
return if persisted?
@saved = Site::Writer.new(site: site, file: path,
content: content.to_yaml).save
# Actualizar el hash para no escribir dos veces
@hash = content.hash
@saved = Site::Writer.new(site: site, file: path, content: content.to_yaml).save.tap do |result|
# Actualizar el hash para no escribir dos veces
@hash = content.hash
end
end
alias save write

View file

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

View file

@ -147,6 +147,23 @@ class Site
rugged.index.remove(relativize(file))
end
# Garbage collection
#
# @return [Boolean]
def gc
env = { 'PATH' => '/usr/bin', 'LANG' => ENV['LANG'], 'HOME' => path }
cmd = 'git gc'
r = nil
Dir.chdir(path) do
Open3.popen2e(env, cmd, unsetenv_others: true) do |_, _, t|
r = t.value
end
end
r&.success?
end
private
# Si Sutty tiene una llave privada de tipo ED25519, devuelve las

View file

@ -9,6 +9,8 @@ class Usuarie < ApplicationRecord
validates_uniqueness_of :email
validates_with EmailAddress::ActiveRecordValidator, field: :email
before_create :lang_from_locale!
has_many :roles
has_many :sites, through: :roles
has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit'
@ -38,4 +40,10 @@ class Usuarie < ApplicationRecord
increment_failed_attempts
lock_access! if attempts_exceeded? && !access_locked?
end
private
def lang_from_locale!
self.lang = I18n.locale.to_s
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
# TODO: Implementar rollback en la configuración
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
# configuración en el repositorio git
def create
@ -11,7 +16,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_role temporal: false, rol: 'usuarie'
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.config.write &&
commit_config(action: :create)
@ -19,6 +31,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
add_licencias
deploy
site
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),
method: :post) do |f|
- unless current_usuarie
= render 'layouts/flash'
.form-group
= f.label :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),
tags: %w[p a strong em]
= sanitize_markdown @explanation, tags: %w[p a strong em]
%table
%thead
%tr
%th= t('.th.type')
%th= t('.th.status')
- @headers.each do |header|
%th= header
%tbody
- @deploys.each do |deploy, value|
%tr
%td= t(".#{deploy}.title")
%td= value ? t(".#{deploy}.success") : t(".#{deploy}.error")
- @table.each do |row|
- row[:urls].each do |url|
%tr
%td= row[:title]
%td= row[:status]
%td= link_to_if url.present?, url, url
%td
%time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
%td= row[:size]
= sanitize_markdown t('.help'), tags: %w[p a strong em]
= sanitize_markdown @help, tags: %w[p a strong em]

View file

@ -1,12 +1,7 @@
= '# ' + t('.hi')
= "# #{@hi}"
\
= t('.explanation', fqdn: @deploy_local.site.hostname)
= @explanation
\
= Terminal::Table.new do |table|
- table << [t('.th.type'), t('.th.status')]
- table.add_separator
- @deploys.each do |deploy, value|
- table << [t(".#{deploy}.title"),
value ? t(".#{deploy}.success") : t(".#{deploy}.error")]
= @terminal_table
\
= t('.help')
= @help

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
%p= t('.greeting', recipient: @email)
%p= t('.instruction')
%p= link_to t('.action'), confirmation_url(@resource, confirmation_token: @token)
%p= link_to t('.action'), confirmation_url(@resource, confirmation_token: @token, change_locale_to: @resource.lang)

View file

@ -2,4 +2,4 @@
\
= 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= 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
%p= t('devise.mailer.invitation_instructions.accept_until',

View file

@ -9,7 +9,7 @@
\
= 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
= t('devise.mailer.invitation_instructions.accept_until',

View file

@ -1,5 +1,5 @@
%p= t('.greeting', recipient: @resource.email)
%p= t('.instruction')
%p= link_to t('.action'), edit_password_url(@resource, reset_password_token: @token)
%p= link_to t('.action'), edit_password_url(@resource, reset_password_token: @token, change_locale_to: @resource.lang)
%p= t('.instruction_2')
%p= t('.instruction_3')

View file

@ -2,7 +2,7 @@
\
= t('.instruction')
\
= edit_password_url(@resource, reset_password_token: @token)
= edit_password_url(@resource, reset_password_token: @token, change_locale_to: @resource.lang)
\
= t('.instruction_2')
\

View file

@ -1,4 +1,4 @@
%p= t('.greeting', recipient: @resource.email)
%p= t('.message')
%p= t('.instruction')
%p= link_to t('.action'), unlock_url(@resource, unlock_token: @token)
%p= link_to t('.action'), unlock_url(@resource, unlock_token: @token, change_locale_to: @resource.lang)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,4 @@
- if resource.errors.any?
#error_explanation
%h2
= I18n.t("errors.messages.not_saved", |
count: resource.errors.count, |
resource: resource.class.model_name.human.downcase) |
%ul
- resource.errors.full_messages.each do |message|
%li= message
= render 'bootstrap/alert' do
- resource.errors.full_messages.each do |message|
%p= message

View file

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

View file

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

View file

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

View file

@ -22,3 +22,7 @@
%li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
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|
- 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'}
- if help.respond_to? :each
%ul

View file

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

View file

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

View file

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

View file

@ -1,4 +1,8 @@
.row{ data: { controller: 'geo' } }
.col-12.mb-3
%p.mb-0= post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
post: post, attribute: attribute, metadata: metadata
.col
.form-group
= label_tag "#{base}_#{attribute}_lat",

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

View file

@ -1,4 +1,4 @@
- dir = t("locales.#{@locale}.dir")
- dir = @site.data.dig(params[:locale], 'dir')
.row.justify-content-center
.col-md-8
%article.content.table-responsive-md
@ -6,13 +6,6 @@
edit_site_post_path(@site, @post.id),
class: 'btn btn-block'
- unless @post.layout.ignored?
= link_to t('posts.preview.btn'),
site_post_preview_path(@site, @post.id),
class: 'btn btn-block',
target: '_blank',
rel: 'noopener'
%table.table.table-condensed
%thead
%tr

View file

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

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:
sign_up: Sign up
help: We only ask for an e-mail address and a password. The password is safely stored, no one else besides you knows it! You'll also receive an e-mail to confirm your account.
signed_up: Welcome! You have signed up successfully.
signed_up: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
signed_up_but_inactive: You have signed up successfully. However, we could not sign you in because your account is not yet activated.
signed_up_but_locked: You have signed up successfully. However, we could not sign you in because your account is locked.
signed_up_but_unconfirmed: A message with a confirmation link has been sent to your email address. Please follow the link to activate your account.
@ -138,7 +138,7 @@ en:
errors:
messages:
already_confirmed: was already confirmed, please try signing in
confirmation_period_expired: needs to be confirmed within %{period}, please request a new one
confirmation_period_expired: "wasn't confirmed within %{period}. Please request a new confirmation link by using the \"Resend confirmation instructions\" button below and find it in your inbox."
expired: has expired, please request a new one
not_found: not found
not_locked: was not locked

View file

@ -104,7 +104,7 @@ es:
new:
sign_up: Registrarme
help: Para registrarte solo pedimos una dirección de correo y una contraseña. La contraseña se almacena de forma segura, ¡nadie más que vos la sabe! Recibirás un correo de confirmación de cuenta.
signed_up: Bienvenide. Tu cuenta fue creada.
signed_up: "Hemos enviado un mensaje con un enlace de confirmación a tu correo electrónico. Por favor, abrí el enlace para terminar de activar tu cuenta."
signed_up_but_inactive: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque tu cuenta aún no está activada.
signed_up_but_locked: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque que tu cuenta está bloqueada.
signed_up_but_unconfirmed: Para recibir actualizaciones, se ha enviado un mensaje con un enlace de confirmación a tu correo electrónico. Abre el enlace para activar tu cuenta.
@ -138,7 +138,7 @@ es:
errors:
messages:
already_confirmed: ya ha sido confirmada, por favor intenta iniciar sesión
confirmation_period_expired: necesita confirmarse dentro de %{period}, por favor solicita una nueva
confirmation_period_expired: "quedó sin confirmar luego de %{period}. Por favor, usa el botón \"Reenviar instrucciones de confirmación\" y busca el nuevo link en tu casilla."
expired: ha expirado, por favor solicita una nueva
not_found: no se ha encontrado
not_locked: no estaba bloqueada

View file

@ -16,6 +16,15 @@ en:
ur:
name: Urdu
dir: rtl
zh:
name: Chinese
dir: ltr
de:
name: German
dir: ltr
fr:
name: French
dir: ltr
login:
email: E-mail address
password: Password
@ -81,6 +90,9 @@ en:
th:
type: Type
status: Status
seconds: Duration
size: Space used
url: Address
deploy_local:
title: Build the site
success: Success!
@ -105,6 +117,18 @@ en:
title: Alternative domain name
success: Success!
error: Error
deploy_distributed_press:
title: Distributed Web
success: Success!
error: Error
deploy_reindex:
title: Reindex
success: Success!
error: Error
deploy_localized_domain:
title: Domain name by language
success: Success!
error: Error
deploy_rsync:
title: Synchronize to backup server
success: Success!
@ -255,9 +279,26 @@ en:
Only accessible through [Tor
Browser](https://www.torproject.org/download/)
deploy_distributed_press:
title: 'Publish to the distributed Web'
help: |
Make your site available through peer-to-peer protocols,
Inter-Planetary File System (IPFS), Hypercore, and via
BitTorrent, so your site is more resilient and can be available
offline, including in community mesh networks.
**Important:** Only use this option if you would like your data
to be permanently available. If you decide to undo this
selection, a cleared version of the site will be shared in its
place. However, it is possible that nodes on the distributed
storage network may continue retaining copies of the data
indefinitely.
[Learn more](https://ffdweb.org/building-distributed-press-a-publishing-tool-for-the-decentralized-web/)
stats:
index:
title: Statistics
filter: "Filter"
help: |
These statistics show information about how your site is generated and
how many resources it uses.
@ -433,6 +474,8 @@ en:
attribute_ro:
file:
download: Download file
password:
safety: Passwords are stored safely
show:
front_matter: Post metadata
submit:
@ -494,7 +537,7 @@ en:
preview:
btn: 'Preliminary version'
alert: 'Not every article type has a preliminary version'
message: 'This is a preliminary version, use the Publish changes button back on the panel to publish the article onto your site.'
message: 'This is a preview of your post with some contextual elements from your site.'
open: 'Tip: You can add new options by typing them and pressing Enter'
private: '&#128274; The values of this field will remain private'
select:

View file

@ -16,6 +16,15 @@ es:
ur:
name: Urdu
dir: rtl
zh:
name: Chino
dir: ltr
de:
name: Alemán
dir: ltr
fr:
name: Francés
dir: ltr
login:
email: Correo electrónico
password: Contraseña
@ -81,6 +90,9 @@ es:
th:
type: Tipo
status: Estado
seconds: Duración
size: Espacio ocupado
url: Dirección
deploy_local:
title: Generar el sitio
success: ¡Éxito!
@ -105,6 +117,18 @@ es:
title: Dominio alternativo
success: ¡Éxito!
error: Hubo un error
deploy_distributed_press:
title: Web distribuida
success: ¡Éxito!
error: Hubo un error
deploy_reindex:
title: Reindexación
success: ¡Éxito!
error: Hubo un error
deploy_localized_domain:
title: Dominio según idioma
success: ¡Éxito!
error: Hubo un error
deploy_rsync:
title: Sincronizar al servidor alternativo
success: ¡Éxito!
@ -260,9 +284,26 @@ es:
Sólo será accesible a través del [Navegador
Tor](https://www.torproject.org/es/download/).
deploy_distributed_press:
title: 'Publicar a la Web distribuida'
help: |
Utiliza protocolos de pares, Inter-Planetary File System (IPFS),
Hypercore y torrents, para que tu sitio sea más resiliente y
esté disponible _offline_, inclusive en redes _mesh_
comunitarias.
**Importante:** Sólo usa esta opción si te parece correcto que
tu contenido esté disponible permanentemente. Cuando elijas
des-hacer esta acción, una versión "vacía" del sitio será
compartida en su lugar. Sin embargo, es posible que algunos
nodos en la red de almacenamiento distribuida puedan retener
copias de tu contenido indefinidamente.
[Saber más (en inglés)](https://ffdweb.org/building-distributed-press-a-publishing-tool-for-the-decentralized-web/)
stats:
index:
title: Estadísticas
filter: "Filtrar"
help: |
Las estadísticas visibilizan información sobre cómo se genera y
cuántos recursos utiliza tu sitio.
@ -441,6 +482,8 @@ es:
attribute_ro:
file:
download: Descargar archivo
password:
safety: Las contraseñas se almacenan de forma segura
show:
front_matter: Metadatos del artículo
submit:
@ -502,7 +545,7 @@ es:
preview:
btn: 'Versión preliminar'
alert: 'No todos los tipos de artículos poseen vista preliminar :)'
message: 'Esta es una versión preliminar, para que el artículo aparezca en tu sitio utiliza el botón Publicar cambios en el panel'
message: 'Esta es la vista previa de tu artículo, con algunos elementos contextuales del sitio'
open: 'Nota: Puedes agregar más opciones a medida que las escribes y presionas Entrar'
private: '&#128274; Los valores de este campo serán privados'
select:

View file

@ -55,7 +55,7 @@ Rails.application.routes.draw do
# Gestionar artículos según idioma
nested do
scope '/(:locale)', constraint: /[a-z]{2}/ do
scope '/(:locale)', constraint: /[a-z]{2}(-[A-Z]{2})?/ do
post :'posts/reorder', to: 'posts#reorder'
resources :posts do
get 'p/:page', action: :index, on: :collection

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