5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-05-20 23:50:48 +00:00

Merge branch 'rails' of 0xacab.org:sutty/sutty into issue-12970
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
f 2023-05-13 11:45:42 -03:00
commit 01bd8b00ba
107 changed files with 2847 additions and 343 deletions

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=

33
.gitlab-ci.yml Normal file
View file

@ -0,0 +1,33 @@
image: "gitea.nulo.in/sutty/panel:3.14.10-2.7.8-panel.sutty.nl"
variables:
RAILS_ENV: "production"
LC_ALL: "C.UTF-8"
cache:
paths:
- "vendor/ruby"
assets:
stage: "build"
rules:
- if: "$CI_COMMIT_BRANCH == \"panel.sutty.nl\""
- if: "$CI_COMMIT_BRANCH"
changes:
compare_to: "refs/heads/rails"
paths:
- "package.json"
- "app/javascript/**/*"
- "app/assets/**/*"
before_script:
- "git config --global user.email \"${GIT_USER_EMAIL:-$GITLAB_USER_EMAIL}\""
- "git config --global user.name \"${GIT_USER_NAME:-$GITLAB_USER_NAME}\""
- "git remote set-url --push origin \"https://${GITLAB_USERNAME}:${GITLAB_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git\""
- "apk add python2 dotenv brotli"
- "mv config/credentials.yml.enc.ci config/credentials.yml.enc"
- "cp .env.example .env"
- "dotenv bundle install --path=vendor"
script:
- "dotenv RAILS_ENV=production bundle exec rails webpacker:clobber"
- "dotenv RAILS_ENV=production bundle exec rails assets:precompile"
- "dotenv RAILS_ENV=production bundle exec rails assets:clean"
after_script:
- "git add public && git commit -m \"ci: assets [skip ci]\""
- "git push -o ci.skip"

72
.woodpecker.yml Normal file
View file

@ -0,0 +1,72 @@
pipeline:
publish:
image: "docker.io/woodpeckerci/plugin-docker-buildx"
settings:
registry: "gitea.nulo.in"
username: "sutty"
repo: "gitea.nulo.in/sutty/panel"
tags:
- "${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}-${CI_COMMIT_BRANCH}"
- "latest"
build_args:
- "RUBY_VERSION=${RUBY_VERSION}"
- "RUBY_PATCH=${RUBY_PATCH}"
- "ALPINE_VERSION=${ALPINE_VERSION}"
- "BASE_IMAGE=gitea.nulo.in/sutty/rails"
purge: false
secrets:
- "DOCKER_PASSWORD"
when:
branch:
- "rails"
- "panel.sutty.nl"
event: "push"
path:
include:
- "Dockerfile"
- ".dockerignore"
- ".woodpecker.yml"
assets:
image: "gitea.nulo.in/sutty/panel:${ALPINE_VERSION}-${RUBY_VERSION}.${RUBY_PATCH}"
commands:
- "apk add python2 dotenv openssh-client brotli"
- "install -d -m 700 ~/.ssh/"
- "echo \"$${KNOWN_HOSTS}\" | base64 -d >> ~/.ssh/known_hosts"
- "chmod 600 ~/.ssh/known_hosts"
- "eval $(ssh-agent -s)"
- "echo \"$${SSH_KEY}\" | base64 -d | ssh-add -"
- "ssh $${ORIGIN%:*}"
- "git config user.name Woodpecker"
- "git config user.email ci@sutty.coop.ar"
- "git remote add upstream $${ORIGIN}"
- "git checkout -B ${CI_COMMIT_BRANCH}"
- "mv config/credentials.yml.enc.ci config/credentials.yml.enc"
- "yarn"
- "cp .env.example .env"
- "dotenv bundle install --path=vendor"
- "dotenv RAILS_ENV=production bundle exec rails webpacker:clobber"
- "dotenv RAILS_ENV=production bundle exec rails assets:precompile"
- "dotenv RAILS_ENV=production bundle exec rails assets:clean"
- "find public -type f -print0 | xargs -r0 brotli -k9f"
- "git add public && git commit -m \"ci: assets [skip ci]\""
- "git pull upstream ${CI_COMMIT_BRANCH}"
- "git push upstream ${CI_COMMIT_BRANCH}"
secrets:
- "SSH_KEY"
- "KNOWN_HOSTS"
- "ORIGIN"
when:
branch:
- "rails"
- "panel.sutty.nl"
path:
include:
- "app/assets/**/*"
- "app/javascript/**/*"
- "package.json"
- "yarn.lock"
matrix:
include:
- ALPINE_VERSION: "3.14.10"
RUBY_VERSION: "2.7"
RUBY_PATCH: "8"

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,13 +14,16 @@ ENV RAILS_ENV production
# principal
RUN apk add --no-cache libxslt libxml2 postgresql-libs libssh2 \
rsync git jpegoptim vips tectonic oxipng git-lfs openssh-client \
yarn daemonize ruby-webrick
yarn daemonize ruby-webrick postgresql-client dateutils file
RUN gem install --no-document --no-user-install foreman
RUN wget https://github.com/jgm/pandoc/releases/download/${PANDOC_VERSION}/pandoc-${PANDOC_VERSION}-linux-amd64.tar.gz -O - | tar --strip-components 1 -xvzf - pandoc-${PANDOC_VERSION}/bin/pandoc && mv /bin/pandoc /usr/bin/pandoc
RUN apk add npm && npm install -g pnpm@~7 && apk del npm
COPY ./monit.conf /etc/monit.d/sutty.conf
RUN apk add npm && npm install -g pnpm && apk del npm
VOLUME "/srv"
EXPOSE 3000

View file

@ -23,6 +23,7 @@ if ENV['RAILS_GROUPS']&.split(',')&.include? 'assets'
end
gem 'nokogiri'
gem 'rgl'
# Turbolinks makes navigating your web application faster. Read more:
# https://github.com/turbolinks/turbolinks
@ -38,6 +39,8 @@ gem 'commonmarker'
gem 'devise'
gem 'devise-i18n'
gem 'devise_invitable'
gem 'distributed-press-api-client', '~> 0.2.3'
gem 'njalla-api-client', '~> 0.2.0'
gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n'
gem 'exception_notification'
gem 'fast_blank'

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)
@ -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,7 +387,11 @@ GEM
nokogiri (1.12.5-x86_64-linux-musl)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
njalla-api-client (0.2.0)
dry-schema
httparty (~> 0.18)
orm_adapter (0.5.0)
pairing_heap (3.0.0)
parallel (1.21.0)
parser (3.0.2.0)
ast (~> 2.4.1)
@ -443,6 +482,10 @@ GEM
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
rgl (0.6.2)
pairing_heap (>= 0.3.0)
rexml (~> 3.2, >= 3.2.4)
stream (~> 0.5.3)
rouge (3.26.1)
rubocop (1.23.0)
parallel (~> 1.10)
@ -510,6 +553,7 @@ GEM
sprockets (>= 3.0.0)
sqlite3 (1.4.2-x86_64-linux-musl)
stackprof (0.2.17-x86_64-linux-musl)
stream (0.5.5)
sucker_punch (3.0.1)
concurrent-ruby (~> 1.0)
sutty-archives (2.5.4)
@ -578,6 +622,7 @@ DEPENDENCIES
devise
devise-i18n
devise_invitable
distributed-press-api-client (~> 0.2.3)
dotenv-rails
down
ed25519
@ -612,6 +657,7 @@ DEPENDENCIES
mini_magick
mobility
net-ssh
njalla-api-client
nokogiri
pg
pg_search
@ -626,6 +672,7 @@ DEPENDENCIES
rails_warden
redis
redis-rails
rgl
rollups!
rubocop-rails
rubyzip

View file

@ -1,2 +1,11 @@
migrate: bundle exec rake db:prepare db:seed
sutty: bundle exec puma config.ru
blazer_5m: bundle exec rake blazer:run_checks SCHEDULE="5 minutes"
blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
blazer: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
cleanup: bundle exec rake cleanup:everything
stats: bundle exec rake stats:process_all
distributed_press_renew_tokens: bundle exec rake distributed_press:tokens:renew

View file

@ -29,6 +29,11 @@ $sizes: (
"70ch": 70ch,
);
.btn {
background-color: var(--foreground);
color: var(--background);
}
@import "bootstrap";
@import "editor";
@ -158,6 +163,12 @@ ol.breadcrumb {
transition: all 3s;
}
fieldset {
legend {
font-size: 1rem;
}
}
.mapable,
.taggable {
.input-map,
@ -198,8 +209,6 @@ svg {
}
.btn {
background-color: var(--foreground);
color: var(--background);
border: none;
border-radius: 0;
margin-right: 0.3rem;
@ -377,6 +386,9 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
}
}
.word-break-all { word-break: break-all !important; }
.hyphens { hyphens: auto; }
/*
* Modificadores de Bootstrap que no tienen versión responsive.
*/
@ -399,6 +411,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
.text-#{$grid-breakpoint}-right { text-align: right !important; }
.text-#{$grid-breakpoint}-center { text-align: center !important; }
.word-break-#{$grid-breakpoint}-all { word-break: break-all !important; }
// posición
@each $position in $positions {
.position-#{$grid-breakpoint}-#{$position} { position: $position !important; }

View file

@ -18,7 +18,7 @@ module Api
# Si todo salió bien, enviar los correos y redirigir al sitio.
# El sitio nos dice a dónde tenemos que ir.
ContactJob.perform_async site.id,
ContactJob.perform_later site.id,
params[:form],
contact_params.to_h.symbolize_keys,
params[:redirect]

View file

@ -12,31 +12,6 @@ module Api
render json: sites_names + alternative_names + api_names + www_names
end
# Sitios con hidden service de Tor
#
# @return [Array] lista de nombres de sitios sin onion aun
def hidden_services
render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name)
end
# Tor va a enviar el onion junto con el nombre del sitio y tenemos
# que guardarlo en su deploy_hidden_service.
#
# @params [String] name
# @params [String] onion
def add_onion
site = Site.find_by(name: params[:name])
if site
usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
service = SiteService.new site: site, usuarie: usuarie,
params: params
service.add_onion
end
head :ok
end
private
def canonicalize(name)

View file

@ -9,6 +9,7 @@ class ApplicationController < ActionController::Base
before_action :prepare_exception_notifier
before_action :configure_permitted_parameters, if: :devise_controller?
before_action :notify_unconfirmed_email, unless: :devise_controller?
around_action :set_locale
rescue_from Pundit::NilPolicyError, with: :page_not_found
@ -27,6 +28,15 @@ class ApplicationController < ActionController::Base
private
def notify_unconfirmed_email
return unless current_usuarie
return if current_usuarie.confirmed?
I18n.with_locale(current_usuarie.lang) do
flash[:notice] ||= I18n.t('devise.registrations.signed_up')
end
end
def uuid?(string)
/[a-f0-9]{8}-([a-f0-9]{4}-){3}[a-f0-9]{12}/ =~ string
end
@ -81,9 +91,14 @@ class ApplicationController < ActionController::Base
breadcrumb 'stats.index', root_path, match: :exact
end
def site
@site ||= find_site
end
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: Usuarie::CONSENT_FIELDS)
devise_parameter_sanitizer.permit(:account_update, keys: %i[lang])
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
# La lista de estados de compilación, por ahora solo mostramos el último
# estado.
class BuildStatsController < ApplicationController
include ActionView::Helpers::NumberHelper
include ActionView::Helpers::DateHelper
before_action :authenticate_usuarie!
breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path
breadcrumb 'sites.index', :sites_path, match: :exact
breadcrumb -> { site.title }, -> { site_posts_path(site, locale: locale) }, match: :exact
def index
authorize SiteBuildStat.new(site)
breadcrumb I18n.t('build_stats.index.title'), ''
@headers = %w[type url seconds size].map do |header|
t("deploy_mailer.deployed.th.#{header}")
end
@table = site.deployment_list.map do |deploy|
type = deploy.class.name.underscore
urls = deploy.respond_to?(:urls) ? deploy.urls : [deploy.url].compact
urls = [nil] if urls.empty?
build_stat = deploy.build_stats.where(status: true).last
seconds = build_stat&.seconds || 0
{
title: t("deploy_mailer.deployed.#{type}.title"),
urls: urls,
seconds: {
human: distance_of_time_in_words(seconds),
machine: "PT#{seconds}S"
},
size: number_to_human_size(build_stat&.bytes || 0, precision: 2)
}
end
end
end

View file

@ -159,10 +159,6 @@ class PostsController < ApplicationController
end.transform_keys(&:to_sym)
end
def site
@site ||= find_site
end
def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end

View file

@ -10,7 +10,7 @@ class SitesController < ApplicationController
# Ver un listado de sitios
def index
authorize Site
@sites = current_usuarie.sites.order(:title)
@sites = current_usuarie.sites.order(updated_at: :desc)
fresh_when @sites
end
@ -28,8 +28,6 @@ class SitesController < ApplicationController
@site = Site.new
authorize @site
@site.deploys.build type: 'DeployLocal'
end
def create

View file

@ -47,7 +47,7 @@ class UsuariesController < ApplicationController
@usuarie = Usuarie.find(params[:usuarie_id])
if @site.usuaries.count > 1
@usuarie.rol_for_site(@site).update_attribute :rol, 'invitade'
@usuarie.rol_for_site(@site).update_attribute :rol, Rol::INVITADE
else
flash[:warning] = I18n.t('usuaries.index.demote.denied')
end
@ -61,7 +61,7 @@ class UsuariesController < ApplicationController
authorize SiteUsuarie.new(@site, current_usuarie)
@usuarie = Usuarie.find(params[:usuarie_id])
@usuarie.rol_for_site(@site).update_attribute :rol, 'usuarie'
@usuarie.rol_for_site(@site).update_attribute :rol, Rol::USUARIE
redirect_to site_usuaries_path
end
@ -72,6 +72,8 @@ class UsuariesController < ApplicationController
site_usuarie = SiteUsuarie.new(@site, current_usuarie)
authorize site_usuarie
params[:invite_as] = invite_as
@policy = policy(site_usuarie)
end
@ -81,27 +83,33 @@ class UsuariesController < ApplicationController
authorize SiteUsuarie.new(@site, current_usuarie)
# Enviar la invitación si es necesario y agregar al sitio
invitaciones.each do |invitacion|
# Si la cuenta no existe, envía una invitación por correo, sino,
# no se envía nada
#
# TODO: Enviar invitación igual! Podemos no usar el Mailer de
# DeviseInvitations y usar uno propio que contenga texto y se
# envíe de todas formas.
usuarie = Usuarie.invite! email: invitacion.address,
skip_invitation: true
invitaciones.each do |address|
next if Usuarie.where(id: @site.roles.pluck(:usuarie_id)).find_by_email(address)
# No invitar al sitio si ya estaba en la lista!
#
# XXX: En este caso no estamos enviando ninguna invitación
next if usuarie.sites.exists? @site.id
Usuarie.transaction do
usuarie = Usuarie.find_by_email(address)
usuarie ||= Usuarie.invite!({ email: address, skip_invitation: true }).tap do |u|
u.send :generate_invitation_token!
end
@site.roles << Rol.create(usuarie: usuarie, site: @site,
temporal: true, rol: invited_as)
role = @site.roles.create(usuarie: usuarie, temporal: true, rol: invited_as)
# Invitamos después de crear el rol para que el correo de
# invitación pueda recibir el sitio.
usuarie.deliver_invitation
# XXX: La invitación tiene que ser enviada luego de crear el rol
if role.persisted?
# Si es una cuenta manual que no está confirmada aun,
# aprovechar para reconfirmarla.
if !usuarie.confirmed? && !usuarie.created_by_invite?
usuarie.confirmation_token = nil
usuarie.send :generate_confirmation_token!
end
usuarie.deliver_invitation
else
raise ArgumentError, role.errors.full_messages
end
rescue ArgumentError => e
ExceptionNotifier.notify_exception(e, data: { site: @site.name, address: address })
end
end
redirect_to site_usuaries_path(@site)
@ -142,6 +150,8 @@ class UsuariesController < ApplicationController
private
# Traer todas las invitaciones que al menos tengan usuarie y dominio
#
# @return [Array]
def invitaciones
# XXX: Podríamos usar EmailAddress pero hace chequeos más lentos
params[:invitaciones]&.tr("\r", '')&.split("\n")&.map do |m|
@ -150,17 +160,19 @@ class UsuariesController < ApplicationController
nil
end.compact.select do |m|
m.local && m.domain
end
end.map(&:address)
end
# El tipo de invitación que tenemos que enviar, si alguien mandó
# cualquier cosa, usamos el privilegio menor.
#
# @return [String]
def invited_as
if Rol::ROLES.include?(params[:invited_as])
params[:invited_as]
else
'invitade'
end
Rol.role?(params[:invited_as]) ? params[:invited_as] : Rol::INVITADE
end
def invite_as
Rol.role?(params[:invite_as]&.singularize) ? params[:invite_as] : Rol::INVITADE.pluralize
end
def site

View file

@ -4,9 +4,21 @@
class DeployJob < ApplicationJob
class DeployException < StandardError; end
class DeployTimedOutException < DeployException; end
class DeployAlreadyRunningException < DeployException; end
discard_on ActiveRecord::RecordNotFound
# Lanzar lo antes posible
self.priority = 10
def handle_error(error)
case error
when DeployAlreadyRunningException then retry_in 1.minute
when DeployTimedOutException then expire
else super
end
end
# rubocop:disable Metrics/MethodLength
def perform(site, notify: true, time: Time.now, output: false)
@output = output
@ -20,89 +32,98 @@ class DeployJob < ApplicationJob
# Como el trabajo actual se aplaza al siguiente, arrastrar la
# hora original para poder ir haciendo timeouts.
if @site.building?
notify = false
if 10.minutes.ago >= time
notify = false
raise DeployTimedOutException,
"#{@site.name} la tarea estuvo más de 10 minutos esperando, volviendo al estado original"
else
raise DeployAlreadyRunningException
end
end
@deployed = {}
@site.update status: 'building'
@site.deployment_list.each do |d|
begin
raise DeployException, 'Una dependencia falló' if failed_dependencies? d
status = d.deploy(output: @output)
seconds = d.build_stats.last.try(:seconds) || 0
size = d.size
urls = d.respond_to?(:urls) ? d.urls : [d.url].compact
rescue StandardError => e
status = false
seconds ||= 0
size ||= 0
# XXX: Hace que se vea la tabla
urls ||= [nil]
notify_exception e, d
end
DeployJob.perform_in(60, site, notify: notify, time: time, output: output)
return
end
@site.update status: 'building'
# Asegurarse que DeployLocal sea el primero!
@deployed = {
deploy_local: {
status: deploy_locally,
seconds: deploy_local.build_stats.last.seconds,
size: deploy_local.size,
urls: [deploy_local.url]
@deployed[d.type.underscore.to_sym] = {
status: status,
seconds: seconds,
size: size,
urls: urls
}
}
# No es opcional
unless @deployed[:deploy_local][:status]
# Hacer fallar la tarea
raise DeployException, "#{@site.name}: Falló la compilación"
end
deploy_others
rescue DeployTimedOutException => e
notify_exception e
rescue DeployException => e
notify_exception e, deploy_local
ensure
@site&.update status: 'waiting'
return unless @output
notify_usuaries if notify
puts (Terminal::Table.new do |t|
t << (%w[type] + @deployed.values.first.keys)
t.add_separator
@deployed.each do |type, row|
t << ([type.to_s] + row.values)
end
end)
ensure
if @site.present?
@site.update status: 'waiting'
notify_usuaries if notify
puts "\a" if @output
end
end
end
# rubocop:enable Metrics/MethodLength
private
# Detecta si un método de publicación tiene dependencias fallidas
#
# @param :deploy [Deploy]
# @return [Boolean]
def failed_dependencies?(deploy)
failed_dependencies(deploy).present?
end
# Obtiene las dependencias fallidas de un deploy
#
# @param :deploy [Deploy]
# @return [Array]
def failed_dependencies(deploy)
deploy.class::DEPENDENCIES & (@deployed.reject do |_, v|
v[:status]
end.keys)
end
# @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
log: deploy&.build_stats&.last&.log,
failed_dependencies: (failed_dependencies(deploy) if deploy)
}
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(output: @output)
end
def deploy_others
@site.deploys.where.not(type: 'DeployLocal').find_each do |d|
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
def notify_usuaries
@site.roles.where(rol: 'usuarie', temporal: false).pluck(:usuarie_id).each do |usuarie|
DeployMailer.with(usuarie: usuarie, site: @site.id)

View file

@ -16,7 +16,7 @@ class GitlabNotifierJob < ApplicationJob
# @param [Hash] opciones de ExceptionNotifier
def perform(exception, **options)
@exception = exception
@options = options
@options = fix_options options
@issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía
@cached = false
@ -37,7 +37,7 @@ class GitlabNotifierJob < ApplicationJob
}
end
unless @issue['iid']
if @issue['iid'].blank? && issue_data[:issue].blank?
Rails.cache.delete(cache_key)
raise GitlabNotifierError, @issue.dig('message', 'title')&.join(', ')
end
@ -61,9 +61,9 @@ class GitlabNotifierJob < ApplicationJob
Rails.cache.write(cache_key, issue_data)
# Si este trabajo genera una excepción va a entrar en un loop, así que
# la notificamos por correo
rescue Exception => e
email_notification.call(e)
email_notification.call(exception, options)
rescue StandardError => e
email_notification.call(e, data: @issue)
email_notification.call(exception, data: @options)
end
private
@ -84,10 +84,15 @@ class GitlabNotifierJob < ApplicationJob
exception.class.name,
Digest::SHA1.hexdigest(exception.message),
Digest::SHA1.hexdigest(backtrace&.first.to_s),
Digest::SHA1.hexdigest(options.dig(:data, :params, 'errors').to_s)
Digest::SHA1.hexdigest(errors.to_s)
].join('/')
end
# @return [Array]
def errors
options.dig(:data, :params, 'errors') || []
end
# Define si es una excepción de javascript o local
#
# @see BacktraceJob
@ -126,6 +131,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def body
@body ||= ''.dup.tap do |b|
b << log_section
b << request_section
b << javascript_footer
b << data_section
@ -162,14 +168,16 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def log_section
return '' unless options[:log]
return '' unless options.dig(:data, :log)
<<~LOG
# Log
```
#{options[:log]}
```
# Build log
```
#{options[:data].delete(:log)}
```
LOG
end
@ -257,8 +265,8 @@ class GitlabNotifierJob < ApplicationJob
## Data
```
#{pp options[:data]}
```yaml
#{options[:data].to_yaml}
```
DATA
@ -279,4 +287,16 @@ class GitlabNotifierJob < ApplicationJob
def url
@url ||= request&.url || options.dig(:data, :params, 'context', 'url')
end
# Define llaves necesarias
#
# @param :options [Hash]
# @return [Hash]
def fix_options(options)
options = { data: options } unless options.is_a? Hash
options[:data] ||= {}
options[:data][:params] ||= {}
options
end
end

View file

@ -10,7 +10,7 @@
# bundle exec rails c
# m = Maintenance.create message_en: 'reason', message_es: 'razón',
# estimated_from: Time.now, estimated_to: Time.now + 1.hour
# MaintenanceJob.perform_async(maintenance_id: m.id)
# MaintenanceJob.perform_later(maintenance_id: m.id)
#
# Lo mismo para salir de mantenimiento, agregando el atributo
# are_we_back: true al crear el Maintenance.

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.save
rescue DistributedPress::V1::Error => e
data = { instance: publisher.instance, expires_at: publisher.client.token.expires_at }
ExceptionNotifier.notify_exception(e, data: data)
end
end
end

View file

@ -4,11 +4,6 @@ module ActiveStorage
class Service
# Sube los archivos a cada repositorio y los agrega al LFS de su
# repositorio git.
#
# @todo: Implementar LFS. No nos gusta mucho la idea porque duplica
# el espacio en disco, pero es la única forma que tenemos (hasta que
# implementemos IPFS) para poder transferir los archivos junto con el
# sitio.
class JekyllService < Service::DiskService
# Genera un servicio para un sitio determinado
#
@ -27,7 +22,10 @@ module ActiveStorage
# @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)
unless exist?(key)
IO.copy_stream(io, make_path_for(key))
LfsObjectService.new(site: site, blob: blob_for(key)).process
end
ensure_integrity_of(key, checksum) if checksum
end
end
@ -79,7 +77,7 @@ module ActiveStorage
# @param :key [String]
# @return [String]
def filename_for(key)
ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first.tap do |filename|
blob_for(key).filename.to_s.tap do |filename|
raise ArgumentError, "Filename for key #{key} is blank" if filename.blank?
end
end
@ -91,6 +89,15 @@ module ActiveStorage
def path_for(key)
File.join root, folder_for(key), filename_for(key)
end
# @return [Site]
def site
@site ||= Site.find_by_name(name)
end
def blob_for(key)
ActiveStorage::Blob.find_by(key: key, service_name: name)
end
end
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Devise
module FailureAppDecorator
extend ActiveSupport::Concern
included do
include AbstractController::Callbacks
around_action :set_locale
private
def set_locale(&action)
I18n.with_locale(session[:locale] || I18n.locale, &action)
end
end
end
end
Devise::FailureApp.include Devise::FailureAppDecorator

View file

@ -11,7 +11,12 @@ module ExceptionNotifier
# @param [Exception]
# @param [Hash]
def call(exception, **options)
GitlabNotifierJob.perform_async(exception, **options)
case exception
when BacktraceJob::BacktraceException
GitlabNotifierJob.perform_later(exception, **options)
else
GitlabNotifierJob.perform_now(exception, **options)
end
end
end
end

View file

@ -0,0 +1,13 @@
# frozen_string_literal: true
require 'httparty'
class HiddenServiceClient
include HTTParty
base_uri ENV.fetch('HIDDEN_SERVICE', 'http://tor:3000')
def create(name)
self.class.get("/#{name}").body
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
# Códigos de conducta
class CodeOfConduct < ApplicationRecord
extend Mobility
translates :title, type: :string, locale_accessors: true
translates :description, type: :text, locale_accessors: true
translates :content, type: :text, locale_accessors: true
validates :title, presence: true, uniqueness: true
validates :description, presence: true
validates :content, presence: true
end

View file

@ -0,0 +1,26 @@
# frozen_string_literal: true
class Usuarie
# Gestiona los campos de consentimiento
module Consent
extend ActiveSupport::Concern
included do
CONSENT_FIELDS = %i[privacy_policy_accepted terms_of_service_accepted code_of_conduct_accepted available_for_feedback_accepted]
CONSENT_FIELDS.each do |field|
attribute field, :boolean
end
before_save :update_consent_fields!
private
def update_consent_fields!
CONSENT_FIELDS.each do |field|
send(:"#{field}_at=", Time.now) if send(field).present?
end
end
end
end
end

View file

@ -1,6 +1,7 @@
# frozen_string_literal: true
require 'open3'
# Este modelo implementa los distintos tipos de alojamiento que provee
# Sutty.
#
@ -11,6 +12,9 @@ class Deploy < ApplicationRecord
belongs_to :site
has_many :build_stats, dependent: :destroy
DEPENDENCIES = []
SOFT_DEPENDENCIES = []
def deploy(**)
raise NotImplementedError
end
@ -89,6 +93,20 @@ class Deploy < ApplicationRecord
r&.success?
end
# Variables de entorno
#
# @return [Hash]
def local_env
@local_env ||= {}
end
# Trae todas las dependencias
#
# @return [Array]
def self.all_dependencies
self::DEPENDENCIES | self::SOFT_DEPENDENCIES
end
private
# @param [String]
@ -96,4 +114,12 @@ class Deploy < ApplicationRecord
def readable_cmd(cmd)
cmd.split(' -', 2).first.tr(' ', '_')
end
def deploy_local
@deploy_local ||= site.deploys.find_by(type: 'DeployLocal')
end
def non_local_deploys
@non_local_deploys ||= site.deploys.where.not(type: 'DeployLocal')
end
end

View file

@ -4,6 +4,8 @@
class DeployAlternativeDomain < Deploy
store :values, accessors: %i[hostname], coder: JSON
DEPENDENCIES = %i[deploy_local]
# Generar un link simbólico del sitio principal al alternativo
def deploy(**)
File.symlink?(destination) ||
@ -18,7 +20,11 @@ class DeployAlternativeDomain < Deploy
end
def destination
@destination ||= File.join(Rails.root, '_deploy', hostname.gsub(/\.\z/, ''))
@destination ||= File.join(Rails.root, '_deploy', fqdn)
end
def fqdn
hostname.gsub(/\.\z/, '')
end
def url

View file

@ -0,0 +1,210 @@
# frozen_string_literal: true
require 'distributed_press/v1/client/site'
require 'njalla/v1'
# Soportar Distributed Press APIv1
#
# Usa tokens de publicación efímeros para todas las acciones.
#
# Al ser creado, genera el sitio en la instancia de Distributed Press
# configurada y almacena el ID.
#
# Al ser publicado, envía los archivos en un tarball y actualiza la
# información.
class DeployDistributedPress < Deploy
store :values, accessors: %i[hostname remote_site_id remote_info], coder: JSON
before_create :create_remote_site!, :create_njalla_records!
before_destroy :delete_remote_site!, :delete_njalla_records!
DEPENDENCIES = %i[deploy_local]
# Actualiza la información y luego envía los cambios
#
# @param :output [Bool]
# @return [Bool]
def deploy
status = false
log = []
time_start
create_remote_site! if remote_site_id.blank?
create_njalla_records!
save
if remote_site_id.blank?
raise DeployJob::DeployException, 'El sitio no se creó en Distributed Press'
end
if create_njalla_records? && remote_info[:njalla].blank?
raise DeployJob::DeployException, 'No se pudieron crear los registros necesarios en Njalla'
end
site_client.tap do |c|
stdout = Thread.new(publisher.logger_out) do |io|
until io.eof?
line = io.gets
puts line if output
log << line
end
end
status = c.publish(publishing_site, deploy_local.destination)
if status
self.remote_info[:distributed_press] = c.show(publishing_site).to_h
save
end
publisher.logger.close
stdout.join
end
time_stop
create_stat! status, log.join
status
end
def limit; end
def size
deploy_local.size
end
def destination; end
# Devuelve las URLs de todos los protocolos
def urls
gateway_urls
end
private
# @return [Array]
def gateway_urls
remote_info.dig(:distributed_press, :links)&.values&.map do |protocol|
[ protocol[:link], protocol[:gateway] ]
end&.flatten&.compact&.select do |link|
link.include? '://'
end || []
end
# El cliente de la API
#
# TODO: cuando soportemos más, tiene que haber una relación entre
# DeployDistributedPress y DistributedPressPublisher.
#
# @return [DistributedPressPublisher]
def publisher
@publisher ||= DistributedPressPublisher.last
end
# El cliente para actualizar el sitio
#
# @return [DistributedPress::V1::Client::Site]
def site_client
DistributedPress::V1::Client::Site.new(publisher.client)
end
# Genera el esquema de datos para poder publicar el sitio
#
# @return [DistributedPress::V1::Schemas::PublishingSite]
def publishing_site
DistributedPress::V1::Schemas::PublishingSite.new.call(id: remote_site_id)
end
# Genera el esquema de datos para crear el sitio
#
# @return [DistributedPressPublisher::V1::Schemas::NewSite]
def create_site
DistributedPress::V1::Schemas::NewSite.new.call(domain: hostname, protocols: { http: true, ipfs: true, hyper: true })
end
# Crea el sitio en la instancia con el hostname especificado
#
# @return [nil]
def create_remote_site!
created_site = site_client.create(create_site)
self.remote_site_id = created_site[:id]
self.remote_info ||= {}
self.remote_info[:distributed_press] = created_site.to_h
nil
rescue DistributedPress::V1::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name })
nil
end
# Crea los registros en Njalla
#
# XXX: Esto depende de nuestro DNS actual, cuando lo migremos hay
# que eliminarlo.
#
# @return [nil]
def create_njalla_records!
return unless create_njalla_records?
self.remote_info ||= {}
self.remote_info[:njalla] ||= {}
self.remote_info[:njalla][:a] ||= njalla.add_record(name: site.name, type: 'CNAME', content: "#{Site.domain}.").to_h
self.remote_info[:njalla][:cname] ||= njalla.add_record(name: "www.#{site.name}", type: 'CNAME', content: "#{Site.domain}.").to_h
self.remote_info[:njalla][:ns] ||= njalla.add_record(name: "_dnslink.#{site.name}", type: 'NS', content: "#{publisher.hostname}.").to_h
nil
rescue HTTParty::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name })
self.remote_info.delete :njalla
ensure
nil
end
# Registra lo que sucedió
#
# @param status [Bool]
# @param log [String]
# @return [nil]
def create_stat!(status, log)
build_stats.create action: publisher.to_s,log: log, seconds: time_spent_in_seconds, bytes: size, status: status
nil
end
def delete_remote_site!
site_client.delete(publishing_site)
nil
rescue DistributedPress::V1::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name })
nil
end
def delete_njalla_records!
return unless create_njalla_records?
%w[a ns cname].each do |type|
next if (id = remote_info.dig('njalla', type, 'id')).blank?
njalla.remove_record(id: id.to_i)
end
end
# Actualizar registros en Njalla
#
# @return [Njalla::V1::Domain]
def njalla
@njalla ||=
begin
client = Njalla::V1::Client.new(token: Rails.application.credentials.njalla)
Njalla::V1::Domain.new(domain: Site.domain, client: client)
end
end
# Detecta si tenemos que crear registros en Njalla
def create_njalla_records?
!site.name.end_with?('.')
end
end

View file

@ -0,0 +1,30 @@
# frozen_string_literal: true
class DeployFullRsync < DeployRsync
SOFT_DEPENDENCIES = %i[
deploy_alternative_domain
deploy_localized_domain
deploy_hidden_service
deploy_www
]
# Sincroniza las ubicaciones alternativas también, ignorando las que
# todavía no se generaron. Solo falla si ningún sitio fue
# sincronizado o si alguna sincronización falló.
#
# @param :output [Boolean]
# @return [Boolean]
def rsync(output: false)
result =
self.class.all_dependencies.map(&:to_s).map(&:classify).map do |dependency|
site.deploys.where(type: dependency).find_each.map do |deploy|
next unless File.exist? deploy.destination
run %(rsync -aviH --delete-after --timeout=5 #{Shellwords.escape deploy.destination} #{Shellwords.escape destination}), output: output
rescue StandardError
end
end.flatten.compact
result.present? && result.all?
end
end

View file

@ -2,17 +2,36 @@
# Genera una versión onion
class DeployHiddenService < DeployWww
def deploy(**)
return true if fqdn.blank?
store :values, accessors: %i[onion], coder: JSON
super
end
before_create :create_hidden_service!
ONION_RE = /\A[a-z0-9]{56}\.onion\z/.freeze
def fqdn
values[:onion]
create_hidden_service! if onion.blank?
onion.tap do |onion|
raise ArgumentError, 'Aun no se generó la dirección .onion' if onion.blank?
end
end
def url
"http://#{fqdn}"
end
private
def create_hidden_service!
onion_address = HiddenServiceClient.new.create(site.name)
if ONION_RE =~ onion_address
self.onion = onion_address
usuarie = GitAuthor.new email: "tor@#{Site.domain}", name: 'Tor'
params = { onion: onion_address, deploy: self }
SiteService.new(site: site, usuarie: usuarie, params: params).add_onion
end
end
end

View file

@ -15,6 +15,7 @@ class DeployLocal < Deploy
def deploy(output: false)
return false unless mkdir
return false unless yarn(output: output)
return false unless pnpm(output: output)
return false unless bundle(output: output)
jekyll_build(output: output)
@ -67,27 +68,35 @@ class DeployLocal < Deploy
end
# Un entorno que solo tiene lo que necesitamos
#
# @return [Hash]
def env
# XXX: This doesn't support Windows paths :B
paths = [File.dirname(`which bundle`), '/usr/bin', '/bin']
paths = [File.dirname(`which bundle`), '/usr/local/bin', '/usr/bin', '/bin']
{
'HOME' => home_dir,
'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url,
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
'JEKYLL_ENV' => Rails.env,
'LANG' => ENV['LANG'],
'YARN_CACHE_FOLDER' => yarn_cache_dir
}
# Las variables de entorno extra no pueden superponerse al local.
extra_env.merge({
'HOME' => home_dir,
'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url,
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
'JEKYLL_ENV' => Rails.env,
'LANG' => ENV['LANG'],
'YARN_CACHE_FOLDER' => yarn_cache_dir,
'GEMS_SOURCE' => ENV['GEMS_SOURCE']
})
end
def yarn_cache_dir
Rails.root.join('_yarn_cache').to_s
end
def pnpm_cache_dir
Rails.root.join('_pnpm_cache').to_s
end
def yarn_lock
File.join(site.path, 'yarn.lock')
end
@ -96,6 +105,14 @@ class DeployLocal < Deploy
File.exist? yarn_lock
end
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
@ -107,6 +124,13 @@ class DeployLocal < Deploy
run 'yarn install --production', output: output
end
def pnpm(output: false)
return true unless pnpm_lock?
run %(pnpm config set store-dir "#{pnpm_cache_dir}"), output: output
run 'pnpm install --production', output: output
end
def bundle(output: false)
run %(bundle install --no-cache --path="#{gems_dir}" --clean --without test development), output: output
end
@ -125,4 +149,18 @@ class DeployLocal < Deploy
def remove_destination!
FileUtils.rm_rf destination
end
# Consigue todas las variables de entorno configuradas por otros
# deploys.
#
# @deprecated Solo tenía sentido para Distributed Press v0
# @return [Hash]
def extra_env
@extra_env ||=
non_local_deploys.reduce({}) do |extra_env, deploy|
extra_env.tap do |e|
e.merge! deploy.local_env
end
end
end
end

View file

@ -6,6 +6,8 @@
# XXX: La plantilla tiene que soportar esto con el plugin
# jekyll-private-data
class DeployPrivate < DeployLocal
DEPENDENCIES = %i[deploy_local]
# No es necesario volver a instalar dependencias
def deploy(output: false)
jekyll_build(output: output)

View file

@ -3,7 +3,9 @@
# Sincroniza sitios a servidores remotos usando Rsync. El servidor
# remoto tiene que tener rsync instalado.
class DeployRsync < Deploy
store :values, accessors: %i[destination host_keys], coder: JSON
store :values, accessors: %i[hostname destination host_keys], coder: JSON
DEPENDENCIES = %i[deploy_local deploy_zip]
def deploy(output: false)
ssh? && rsync(output: output)
@ -23,6 +25,11 @@ class DeployRsync < Deploy
end
end
# @return [String]
def url
"https://#{hostname}/"
end
private
# Verificar la conexión SSH implementando Trust On First Use
@ -31,6 +38,7 @@ class DeployRsync < Deploy
#
# @return [Boolean]
def ssh?
return true if destination.start_with? 'rsync://'
user, host = user_host
ssh_available = false
@ -83,8 +91,8 @@ class DeployRsync < Deploy
# Sincroniza hacia el directorio remoto
#
# @return [Boolean]
def rsync(output: output)
run %(rsync -aviH --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
def rsync(output: false)
run %(rsync -aviH --delete-after --timeout=5 #{Shellwords.escape source}/ #{Shellwords.escape destination}/), output: output
end
# El origen es el destino de la compilación

View file

@ -4,9 +4,13 @@
class DeployWww < Deploy
store :values, accessors: %i[], coder: JSON
DEPENDENCIES = %i[deploy_local]
before_destroy :remove_destination!
def deploy(**)
def deploy(output: false)
puts "Creando symlink #{site.hostname} => #{destination}" if output
File.symlink?(destination) ||
File.symlink(site.hostname, destination).zero?
end
@ -28,7 +32,7 @@ class DeployWww < Deploy
end
def url
"https://www.#{site.hostname}/"
"https://#{fqdn}/"
end
private

View file

@ -8,28 +8,49 @@ require 'zip'
class DeployZip < Deploy
store :values, accessors: %i[], coder: JSON
DEPENDENCIES = %i[deploy_local]
# Una vez que el sitio está generado, tomar todos los archivos y
# y generar un zip accesible públicamente.
#
# rubocop:disable Metrics/MethodLength
def deploy(**)
def deploy(output: false)
FileUtils.rm_f path
time_start
Dir.chdir(destination) do
Zip::File.open(path, Zip::File::CREATE) do |z|
Dir.glob('./**/**').each do |f|
File.directory?(f) ? z.mkdir(f) : z.add(f, f)
Zip::File.open(path, Zip::File::CREATE) do |zip|
Dir.glob(File.join(destination, '**', '**')).each do |file|
entry = Pathname.new(file).relative_path_from(destination).to_s
if File.directory? file
log "Creando directorio #{entry}", output
zip.mkdir(entry)
else
log "Comprimiendo #{entry}", output
zip.add(entry, file)
end
end
end
time_stop
build_stats.create action: 'zip',
seconds: time_spent_in_seconds,
bytes: size
File.exist?(path).tap do |status|
build_stats.create action: 'zip',
seconds: time_spent_in_seconds,
bytes: size,
log: @log.join("\n"),
status: status
end
rescue Zip::Error => e
ExceptionNotifier.notify_exception(e, data: { site: site.name })
File.exist? path
build_stats.create action: 'zip',
seconds: 0,
bytes: 0,
log: @log.join("\n"),
status: false
false
end
# rubocop:enable Metrics/MethodLength
@ -41,8 +62,11 @@ class DeployZip < Deploy
File.size path
end
# @return [String]
def destination
File.join(Rails.root, '_deploy', site.hostname)
Rails.root.join('_deploy', site.hostname).realpath.to_s
rescue Errno::ENOENT
Rails.root.join('_deploy', site.hostname).to_s
end
def file
@ -56,4 +80,15 @@ class DeployZip < Deploy
def path
File.join(destination, file)
end
private
# @param :line [String]
# @param :output [Boolean]
def log(line, output)
@log ||= []
@log << line
puts line if output
end
end

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

@ -9,6 +9,13 @@ Layout = Struct.new(:site, :name, :meta, :metadata, keyword_init: true) do
name.to_s
end
# Obtiene todos los layouts (schemas) dependientes de este.
#
# @return [Array]
def schemas
@schemas ||= site.layouts.to_h.slice(*site.schema_organization[name]).values
end
def attributes
@attributes ||= metadata.keys.map(&:to_sym)
end

View file

@ -7,6 +7,7 @@ class Licencia < ApplicationRecord
translates :name, type: :string, locale_accessors: true
translates :url, type: :string, locale_accessors: true
translates :description, type: :text, locale_accessors: true
translates :short_description, type: :string, locale_accessors: true
translates :deed, type: :text, locale_accessors: true
has_many :sites
@ -14,5 +15,10 @@ class Licencia < ApplicationRecord
validates :name, presence: true, uniqueness: true
validates :url, presence: true
validates :description, presence: true
validates :short_description, presence: true
validates :deed, presence: true
def custom?
icons == 'custom'
end
end

View file

@ -11,7 +11,7 @@ class LogEntry < ApplicationRecord
def resend
return if sent
ContactJob.perform_async site_id, params[:form], params
ContactJob.perform_later site_id, params[:form], params
end
def params

View file

@ -1,22 +1,49 @@
# frozen_string_literal: true
# Los valores de este metadato son artículos en otros idiomas
class MetadataLocales < MetadataTemplate
def default_value
super || []
end
class MetadataLocales < MetadataHasAndBelongsToMany
# Todos los valores posibles para cada idioma disponible
#
# TODO: Optimizar?
# TODO: Mantener sincronizados
#
# @return { lang: { title: uuid } }
def values
@values ||= site.locales.map do |locale|
[locale, site.posts(lang: locale).map do |post|
[post.title.value, post.uuid.value]
[locale, posts.where(lang: locale).map do |post|
[title(post), post.uuid.value]
end.to_h]
end.to_h
end
# Siempre hay una relación inversa
#
# @return [True]
def inverse?
true
end
# El campo inverso se llama igual en el otro post
#
# @return [Symbol]
def inverse
:locales
end
private
# Obtiene todos los locales distintos a este post
#
# @return [Array]
def other_locales
site.locales.reject do |locale|
locale == post.lang.value.to_sym
end
end
# Obtiene todos los posts de los otros locales con el mismo layout
#
# @return [PostRelation]
def posts
other_locales.map do |locale|
site.posts(lang: locale).where(layout: post.layout.value)
end.reduce(&:concat) || PostRelation.new(site: site, lang: 'any')
end
end

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
# Políticas de privacidad
class PrivacyPolicy < ApplicationRecord
extend Mobility
translates :title, type: :string, locale_accessors: true
translates :description, type: :text, locale_accessors: true
translates :content, type: :text, locale_accessors: true
validates :title, presence: true, uniqueness: true
validates :description, presence: true
validates :content, presence: true
end

View file

@ -21,4 +21,8 @@ class Rol < ApplicationRecord
def usuarie?
rol == USUARIE
end
def self.role?(rol)
ROLES.include? rol
end
end

View file

@ -7,6 +7,9 @@ class Site < ApplicationRecord
include Site::Forms
include Site::FindAndReplace
include Site::Api
include Site::DeployDependencies
include Site::BuildStats
include Site::LayoutOrdering
include Tienda
# Cifrar la llave privada que cifra y decifra campos ocultos. Sutty
@ -17,7 +20,7 @@ class Site < ApplicationRecord
# TODO: Hacer que los diferentes tipos de deploy se auto registren
# @see app/services/site_service.rb
DEPLOYS = %i[local private www zip hidden_service].freeze
DEPLOYS = %i[local private www zip hidden_service distributed_press].freeze
validates :name, uniqueness: true, hostname: {
allow_root_label: true
@ -553,10 +556,33 @@ class Site < ApplicationRecord
Dir.chdir path, &block
end
# Instala las gemas cuando es necesario:
#
# * El sitio existe
# * No están instaladas
# * El archivo Gemfile se modificó
# * El archivo Gemfile.lock se modificó
def install_gems
return unless persisted?
return if Rails.root.join('_storage', 'gems', name).directory?
deploys.find_by_type('DeployLocal').send(:bundle)
if !gem_dir? || gemfile_updated? || gemfile_lock_updated?
deploys.find_by_type('DeployLocal').send(:bundle)
touch
end
end
# Detecta si el repositorio de gemas existe
def gem_dir?
Rails.root.join('_storage', 'gems', name).directory?
end
# Detecta si el Gemfile fue modificado
def gemfile_updated?
updated_at < File.mtime(File.join(path, 'Gemfile'))
end
# Detecta si el Gemfile.lock fue modificado
def gemfile_lock_updated?
updated_at < File.mtime(File.join(path, 'Gemfile.lock'))
end
end

View file

@ -0,0 +1,111 @@
# frozen_string_literal: true
class Site
module BuildStats
extend ActiveSupport::Concern
included do
# Devuelve el tiempo promedio de publicación para este sitio
#
# @return [Integer]
def average_publication_time
build_stats.group(:action).average(:seconds).values.reduce(:+).round
end
# Devuelve el tiempo promedio de compilación para sitios similares
# a este.
#
# @return [Integer]
def average_publication_time_for_similar_sites
similar_deploys = Deploy.where(type: deploys.pluck(:type)).pluck(:id)
BuildStat.where(deploy_id: similar_deploys).group(:action).average(:seconds).values.reduce(:+).round
end
# Define si podemos calcular el tiempo promedio de publicación
# para este sitio
#
# @return [Boolean]
def average_publication_time_calculable?
build_stats.jekyll.where(status: true).count > 1
end
def similar_sites?
!design.no_theme?
end
# Detecta si el sitio todavía no ha sido publicado
#
# @return [Boolean]
def not_published_yet?
build_stats.jekyll.where(status: true).count.zero?
end
# Cambios posibles luego de la última publicación exitosa:
#
# * Artículos modificados
# * Configuración modificada
# * Métodos de publicación añadidos
#
# @return [Boolean]
def awaiting_publication?
waiting? && (post_pending? || deploy_pending? || configuration_pending?)
end
# Se modificaron artículos después de publicar el sitio por última
# vez
#
# @return [Boolean]
def post_pending?
last_indexed_post_time > last_publication_time
end
# Se modificó el sitio después de publicarlo por última vez
#
# @return [Boolean]
def deploy_pending?
last_deploy_time > last_publication_time
end
# Se modificó la configuración del sitio
#
# @return [Boolean]
def configuration_pending?
last_configuration_time > last_publication_time
end
private
# Encuentra la fecha del último artículo modificado. Si no hay
# ninguno, devuelve la fecha de modificación del sitio.
#
# @return [Time]
def last_indexed_post_time
indexed_posts.order(updated_at: :desc).select(:updated_at).first&.updated_at || updated_at
end
# Encuentra la fecha de última modificación de los métodos de
# publicación.
#
# @return [Time]
def last_deploy_time
deploys.order(created_at: :desc).select(:created_at).first&.created_at || updated_at
end
# Encuentra la fecha de última publicación exitosa, si no hay
# ninguno, devuelve la fecha de modificación del sitio.
#
# @return [Time]
def last_publication_time
build_stats.jekyll.where(status: true).order(created_at: :desc).select(:created_at).first&.created_at || updated_at
end
# Fecha de última modificación de la configuración
#
# @return [Time]
def last_configuration_time
File.mtime(config.path)
end
end
end
end

View file

@ -31,7 +31,7 @@ class Site
# Escribe los cambios en el repositorio
def write
return if persisted?
return true if persisted?
@saved = Site::Writer.new(site: site, file: path, content: content.to_yaml).save.tap do |result|
# Actualizar el hash para no escribir dos veces

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rgl/adjacency'
require 'rgl/topsort'
class Site
module DeployDependencies
extend ActiveSupport::Concern
included do
# Genera un grafo dirigido de todos los métodos de publicación
#
# @return [RGL::DirectedAdjacencyGraph]
def deployment_graph
@deployment_graph ||= RGL::DirectedAdjacencyGraph.new.tap do |graph|
deploys.each do |deploy|
graph.add_vertex deploy
end
deploys.each do |deploy|
deploy.class.all_dependencies.each do |dependency|
deploys.where(type: dependency.to_s.classify).each do |deploy_dependency|
graph.add_edge deploy_dependency, deploy
end
end
end
end
end
# Devuelve una lista ordenada de todos los métodos de publicación
#
# @return [Array]
def deployment_list
@deployment_list ||= deployment_graph.topsort_iterator.to_a
end
end
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
class Site
# Obtiene un listado de layouts (schemas)
module LayoutOrdering
extend ActiveSupport::Concern
included do
# Obtiene o genera un listado de layouts (schemas) con sus
# dependencias, para poder generar un árbol.
#
# Por defecto, si el sitio no lo soporta, se obtienen los layouts
# ordenados alfabéticamente por traducción.
#
# @return [Hash]
def schema_organization
@schema_organization ||=
begin
schema_organization = data.dig('schema', 'organization')
schema_organization&.symbolize_keys!
schema_organization&.transform_values! do |ary|
ary.map(&:to_sym)
end
schema_organization ||
begin
layouts = self.layouts.sort_by(&:humanized_name).map(&:name)
Hash[layouts.zip([].fill([], 0, layouts.size))]
end
end
end
# TODO: Deprecar cuando renombremos layouts a schemas
alias layout_organization schema_organization
end
end
end

View file

@ -117,6 +117,9 @@ class Site
def commit(file:, usuarie:, message:, remove: false)
file = [file] unless file.respond_to? :each
# Cargar el árbol actual
rugged.index.read_tree rugged.head.target.tree
file.each do |f|
remove ? rm(f) : add(f)
end

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
SiteBuildStat = Struct.new(:site)

View file

@ -2,6 +2,8 @@
# Usuarie de la plataforma
class Usuarie < ApplicationRecord
include Usuarie::Consent
devise :invitable, :database_authenticatable,
:recoverable, :rememberable, :validatable,
:confirmable, :lockable, :registerable
@ -10,6 +12,8 @@ class Usuarie < ApplicationRecord
validates_with EmailAddress::ActiveRecordValidator, field: :email
before_create :lang_from_locale!
before_update :remove_confirmation_invitation_inconsistencies!
before_update :accept_invitation_after_confirmation!
has_many :roles
has_many :sites, through: :roles
@ -41,9 +45,37 @@ class Usuarie < ApplicationRecord
lock_access! if attempts_exceeded? && !access_locked?
end
def send_devise_notification(notification, *args)
I18n.with_locale(lang) do
devise_mailer.send(notification, self, *args).deliver_later
end
end
# Les usuaries necesitan link de invitación si no tenían cuenta
# y todavía no aceptaron la invitación anterior.
def needs_invitation_link?
created_by_invite? && !invitation_accepted?
end
private
def lang_from_locale!
self.lang = I18n.locale.to_s
end
# El invitation_token solo es necesario cuando fue creade por otre
# usuarie. De lo contrario lo que queremos es un proceso de
# confirmación.
def remove_confirmation_invitation_inconsistencies!
self.invitation_token = nil unless created_by_invite?
end
# Si le usuarie (re)confirma su cuenta con una invitación pendiente,
# considerarla aceptada también.
def accept_invitation_after_confirmation!
if confirmed?
self.invitation_token = nil
self.invitation_accepted_at ||= Time.now.utc
end
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Quiénes pueden ver estados de compilación de un sitio
class SiteBuildStatPolicy
attr_reader :site_build_stat, :usuarie
def initialize(usuarie, site_build_stat)
@usuarie = usuarie
@site_build_stat = site_build_stat
end
# Todes les usuaries e invitades de este sitio
def index?
site_build_stat.site.usuarie?(usuarie) || site_build_stat.site.invitade?(usuarie)
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
# Representa un objeto git LFS
class LfsObjectService
attr_reader :site, :blob
# @param :site [Site]
# @param :blob [ActiveStorage::Blob]
def initialize(site:, blob:)
@site = site
@blob = blob
end
def process
# Crear el directorio
FileUtils.mkdir_p(File.dirname(object_path))
# Mover el archivo
FileUtils.mv(path, object_path) unless File.exist? object_path
# Crear el pointer
Site::Writer.new(site: site, file: path, content: pointer).save
# Commitear el pointer
site.repository.commit(file: path, usuarie: author, message: File.basename(path))
# Eliminar el pointer
FileUtils.rm(path)
# Hacer link duro del objeto al archivo
FileUtils.ln(object_path, path)
end
# @return [String]
def path
@path ||= blob.service.path_for(blob.key)
end
# @return [String]
def digest
@digest ||= Digest::SHA256.file(path).hexdigest
end
# @return [String]
def object_path
@object_path ||= File.join(site.path, '.git', 'lfs', 'objects', digest[0..1], digest[2..3], digest)
end
# @return [Integer]
def size
@size ||= File.size(File.exist?(object_path) ? object_path : path)
end
# @return [String]
def pointer
@pointer ||=
<<~POINTER
version https://git-lfs.github.com/spec/v1
oid sha256:#{digest}
size #{size}
POINTER
end
def author
@author ||= GitAuthor.new email: "disk_service@#{Site.domain}", name: 'DiskService'
end
end

View file

@ -12,8 +12,14 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
post.usuaries << usuarie
params[:post][:draft] = true if site.invitade? usuarie
params.require(:post).permit(:slug).tap do |p|
post.slug.value = p[:slug] if p[:slug].present?
end
commit(action: :created, file: update_related_posts) if post.update(post_params)
update_site_license!
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
post
@ -40,6 +46,8 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
# relacionados.
commit(action: :updated, file: update_related_posts) if post.update(post_params)
update_site_license!
# Devolver el post aunque no se haya salvado para poder rescatar los
# errores
post
@ -133,4 +141,12 @@ PostService = Struct.new(:site, :usuarie, :post, :params, keyword_init: true) do
p.path.absolute if p.save(validate: false)
end.compact << post.path.absolute
end
# Si les usuaries modifican o crean una licencia, considerarla
# personalizada en el panel.
def update_site_license!
if site.usuarie?(usuarie) && post.layout.name == :license && !site.licencia.custom?
site.update licencia: Licencia.find_by_icons('custom')
end
end
end

View file

@ -5,7 +5,7 @@
SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
def deploy
site.enqueue!
DeployJob.perform_async site.id
DeployJob.perform_later site.id
end
# Crea un sitio, agrega un rol nuevo y guarda los cambios a la
@ -14,7 +14,9 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
self.site = Site.new params
add_role temporal: false, rol: 'usuarie'
sync_nodes
site.deploys.build type: 'DeployLocal'
# Los sitios de testing no se sincronizan
sync_nodes unless site.name.end_with? '.testing'
I18n.with_locale(usuarie.lang.to_sym || I18n.default_locale) do
# No se puede llamar a site.config antes de save porque el sitio
@ -26,13 +28,14 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
site.save &&
site.config.write &&
commit_config(action: :create)
commit_config(action: :create) &&
site.reset.nil? &&
add_licencias &&
add_code_of_conduct &&
add_privacy_policy &&
deploy
end
add_licencias
deploy
site
end
@ -41,11 +44,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do
site.update(params) &&
site.config.write &&
commit_config(action: :update)
commit_config(action: :update) &&
site.reset.nil? &&
change_licencias
end
change_licencias
site
end
@ -62,14 +65,11 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# Agregar una dirección oculta de Tor al DeployHiddenService y a la
# configuración del Site.
def add_onion
onion = params[:onion].strip
deploy = DeployHiddenService.find_by(site: site)
onion = params[:onion]
deploy = params[:deploy]
return false unless !onion.blank? && deploy
deploy.values[:onion] = onion
deploy.save
site.config['onion-location'] = onion
site.config.write
@ -105,24 +105,28 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
end
# Crea la licencia del sitio para cada locale disponible en el sitio
#
# @return [Boolean]
def add_licencias
site.locales.each do |locale|
next unless I18n.available_locales.include? locale
return true unless site.layout? :license
return true if site.licencia.custom?
Mobility.with_locale(locale) do
add_licencia lang: locale
end
end
with_all_locales do |locale|
add_licencia lang: locale
end.compact.map(&:valid?).all?
end
# Crea una licencia
#
# @return [Post]
def add_licencia(lang:)
params = ActionController::Parameters.new(
post: {
layout: 'license',
slug: Jekyll::Utils.slugify(I18n.t('activerecord.models.licencia')),
lang: lang,
title: site.licencia.name,
description: I18n.t('sites.form.licencia.title'),
author: %w[Sutty],
permalink: "#{I18n.t('activerecord.models.licencia').downcase}/",
description: site.licencia.short_description,
content: CommonMarker.render_html(site.licencia.deed)
}
)
@ -133,25 +137,27 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
# Encuentra la licencia a partir de su enlace permanente y le cambia
# el contenido
#
# TODO: Crear un layout específico para licencias así es más certera
# la búsqueda.
# @return [Boolean]
def change_licencias
site.locales.each do |locale|
next unless I18n.available_locales.include? locale
return true unless site.layout? :license
return true if site.licencia.custom?
Mobility.with_locale(locale) do
permalink = "#{I18n.t('activerecord.models.licencia').downcase}/"
post = site.posts(lang: locale).find_by(permalink: permalink)
with_all_locales do |locale|
post = site.posts(lang: locale).find_by(layout: 'license')
post ? change_licencia(post: post) : add_licencia(lang: locale)
end
end
change_licencia(post: post) if post
end.compact.map(&:valid?).all?
end
# Cambia una licencia
#
# @param :post [Post]
# @return [Post]
def change_licencia(post:)
params = ActionController::Parameters.new(
post: {
title: site.licencia.name,
description: site.licencia.short_description,
content: CommonMarker.render_html(site.licencia.deed)
}
)
@ -160,10 +166,69 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do
params: params).update
end
# Agrega un código de conducta
#
# @return [Boolean]
def add_code_of_conduct
return true unless site.layout?(:code_of_conduct) || site.layout?(:page)
# TODO: soportar más códigos de conducta
coc = CodeOfConduct.first
with_all_locales do |locale|
params = ActionController::Parameters.new(
post: {
layout: site.layout?(:code_of_conduct) ? 'code_of_conduct' : 'page',
lang: locale.to_s,
title: coc.title,
description: coc.description,
content: CommonMarker.render_html(coc.content)
}
)
PostService.new(site: site, usuarie: usuarie, params: params).create
end.compact.map(&:valid?).all?
end
# Agrega política de privacidad
#
# @return [Boolean]
def add_privacy_policy
return true unless site.layout?(:privacy_policy) || site.layout?(:page)
pp = PrivacyPolicy.first
with_all_locales do |locale|
params = ActionController::Parameters.new(
post: {
layout: site.layout?(:privacy_policy) ? 'privacy_policy' : 'page',
lang: locale.to_s,
title: pp.title,
description: pp.description,
content: CommonMarker.render_html(pp.content)
}
)
PostService.new(site: site, usuarie: usuarie, params: params).create
end.compact.map(&:valid?).all?
end
# Crea los deploys necesarios para sincronizar a otros nodos de Sutty
def sync_nodes
Rails.application.nodes.each do |node|
site.deploys.build(type: 'DeployRsync', destination: "sutty@#{node}:#{site.hostname}")
site.deploys.build(type: 'DeployFullRsync', destination: "rsync://rsyncd.#{node}/deploys/", hostname: node)
end
end
private
def with_all_locales(&block)
site.locales.map do |locale|
next unless I18n.available_locales.include? locale
Mobility.with_locale(locale) do
yield locale
end
end
end
end

View file

@ -0,0 +1,6 @@
- help_id = "#{id}_help"
.custom-control.custom-checkbox
%input.custom-control-input{ id: id, type: 'checkbox', name: name, value: value, required: required }
%label.custom-control-label{ for: id, aria: { describedby: help_id } }= content
%small.form-text.text-muted{ id: help_id }= yield

View file

@ -0,0 +1,20 @@
%main.row
%aside.menu.col-md-3
= render 'sites/header', site: @site
.col
%h1= t('.title')
%table.table
%thead
%tr
- @headers.each do |header|
%th{ scope: 'col' }= header
%tbody
- @table.each do |row|
- row[:urls].each do |url|
%tr
%th{ scope: 'row' }= row[:title]
%td= link_to_if url.present?, url, url, class: 'word-break-all'
%td
%time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
%td= row[:size]

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

@ -17,7 +17,8 @@
= sanitize_markdown t('.help', public_url: deploy.object.site.url),
tags: %w[p strong em a]
- if deploy.object.fqdn
- begin
= sanitize_markdown t('.help_2', url: deploy.object.url),
tags: %w[p strong em a]
- rescue ArgumentError
%hr/

View file

@ -0,0 +1 @@
-# nada

View file

@ -1,4 +1,4 @@
- site = @resource.sites.last
- site = @resource.roles.where(temporal: true).last&.site
%p= t('devise.mailer.invitation_instructions.hello',
email: @resource.email)
@ -8,12 +8,17 @@
%h1= site.title
%p= site.description
%p= link_to t('devise.mailer.invitation_instructions.accept'),
accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
- if @resource.needs_invitation_link?
%p= link_to t('devise.mailer.invitation_instructions.accept'),
accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
- if @resource.invitation_due_at
%p= t('devise.mailer.invitation_instructions.accept_until',
due_date: l(@resource.invitation_due_at,
format: :'devise.mailer.invitation_instructions.accept_until_format'))
- if @resource.invitation_due_at
%p= t('devise.mailer.invitation_instructions.accept_until',
due_date: l(@resource.invitation_due_at,
format: :'devise.mailer.invitation_instructions.accept_until_format'))
%p= t('devise.mailer.invitation_instructions.ignore')
%p= t('devise.mailer.invitation_instructions.ignore')
- elsif !@resource.confirmed? && @resource.confirmation_token
= confirmation_url(@resource, confirmation_token: @resource.confirmation_token, change_locale_to: @resource.lang)
- else
%p= link_to t('devise.mailer.invitation_instructions.sign_in'), root_url

View file

@ -1,4 +1,4 @@
- site = @resource.sites.last
- site = @resource.roles.where(temporal: true).last&.site
= t('devise.mailer.invitation_instructions.hello', email: @resource.email)
\
@ -9,11 +9,17 @@
\
= site.description
\
= accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
\
- if @resource.invitation_due_at
= t('devise.mailer.invitation_instructions.accept_until',
due_date: l(@resource.invitation_due_at,
format: :'devise.mailer.invitation_instructions.accept_until_format'))
\
= t('devise.mailer.invitation_instructions.ignore')
- if @resource.needs_invitation_link?
= accept_invitation_url(@resource, invitation_token: @token, change_locale_to: @resource.lang)
\
- if @resource.invitation_due_at
= t('devise.mailer.invitation_instructions.accept_until',
due_date: l(@resource.invitation_due_at,
format: :'devise.mailer.invitation_instructions.accept_until_format'))
\
= t('devise.mailer.invitation_instructions.ignore')
- elsif !@resource.confirmed? && @resource.confirmation_token
= confirmation_url(@resource, confirmation_token: @resource.confirmation_token, change_locale_to: @resource.lang)
- else
= root_url(change_locale_to: @resource.lang)
= t('devise.mailer.invitation_instructions.sign_in')

View file

@ -4,7 +4,7 @@
= render 'devise/shared/error_messages', resource: resource
.row.align-items-center.justify-content-center.full-height
.col-md-5.align-self-center
.col-md-6.align-self-center
%h2= t('.sign_up')
%p= t('.help')
@ -39,6 +39,21 @@
min: @minimum_password_length,
aria: { describedby: 'minimum-password-length' },
placeholder: t("#{password}_confirmation")
.form-group
- Usuarie::CONSENT_FIELDS.each do |field|
- required = t(".#{field}.required", default: '').present?
- id = "usuarie_#{field}"
- name = "usuarie[#{field}]"
- content = t(".#{field}.label")
- href = t(".#{field}.href", default: '')
- help_content = t(".#{field}.help")
= render 'bootstrap/custom_checkbox', id: id, name: name, content: content, required: required, value: "1" do
- if href.present?
= link_to help_content, href, target: '_blank', rel: 'noopener'
- else
= help_content
.actions
= f.submit t('.sign_up'),
class: 'btn btn-lg btn-block'

View file

@ -19,10 +19,15 @@
= link_to t('.tienda'), @site.tienda_url,
role: 'button', class: 'btn'
%li.nav-item
= link_to t('.contact_us'), t('.contact_us_href'),
class: 'btn', rel: 'me', target: '_blank'
%li.nav-item
= link_to t('.logout'), main_app.destroy_usuarie_session_path,
method: :delete, role: 'button', class: 'btn'
- else
- params.permit!
- I18n.available_locales.each do |locale|
- next if locale == I18n.locale
= link_to t("switch_locale.#{locale}"), "?change_locale_to=#{locale}"
= link_to t("switch_locale.#{locale}"), params.to_h.merge(change_locale_to: locale)

View file

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

View file

@ -1,9 +1,10 @@
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td
%ul
- metadata.value.each do |uuid|
- p = site.docs.find(uuid, uuid: true)
%li{ dir: t("locales.#{p.lang.value}.dir"), lang: p.lang.value }
= link_to p.title.value,
site_post_path(site, p.id, locale: p.lang.value)
- if site.locales.count > 1
%tr{ id: attribute }
%th= post_label_t(attribute, post: post)
%td
%ul
- metadata.value.each do |uuid|
- p = site.docs.find(uuid, uuid: true)
%li{ dir: t("locales.#{p.lang.value}.dir"), lang: p.lang.value }
= link_to p.title.value,
site_post_path(site, p.id, locale: p.lang.value)

View file

@ -1,39 +1,19 @@
-#
Crea un input-map para cada idioma por separado. Podríamos hacer uno
solo que tenga todos los idiomas pero puede ser una interfaz confusa.
TODO: Esto permite seleccionar más de una traducción por idioma...
- site.locales.each do |locale|
-# Ignorar el idioma actual
- next if post.lang.value == locale
- locale_t = t("locales.#{locale}.name")
- values = metadata.value.select do |x|
- metadata.values[locale].values.include? x
.form-group
= label_tag "#{base}_#{attribute}_#{locale}", locale_t
.mapable{ dir: t("locales.#{locale}.dir"), lang: locale,
data: { values: values.to_json,
'default-values': metadata.values[locale].to_json,
name: "#{base}[#{attribute}][]",
list: id_for_datalist(attribute, locale),
button: t('posts.attributes.add'),
remove: 'false', legend: locale_t,
described: id_for_help(attribute, locale) } }
= text_field(*field_name_for(base, attribute, '[]'),
value: values.join(', '),
dir: t("locales.#{locale}.dir"), lang: locale,
**field_options(attribute, metadata))
- if site.locales.count > 1
%fieldset
%legend= post_label_t(attribute, post: post)
= render 'posts/attribute_feedback',
post: post,
attribute: [attribute, 'mapable'].flatten,
metadata: metadata
post: post, attribute: attribute, metadata: metadata
%datalist{ id: id_for_datalist(attribute, locale) }
- metadata.values[locale].keys.each do |value|
%option{ value: value }
- site.locales.each do |locale|
- next if post.lang.value == locale
- locale_t = t("locales.#{locale}.name", default: locale.to_s.humanize)
- value = metadata.value.find do |v|
- metadata.values[locale].values.include? v
.form-group
= label_tag "#{base}_#{attribute}_#{locale}", locale_t
= select_tag("#{plain_field_name_for(base, attribute)}[]",
options_for_select(metadata.values[locale], value),
**field_options(attribute, metadata), include_blank: t('.empty'))

View file

@ -1,19 +1,18 @@
%main.row
%aside.menu.col-md-3
%h1= link_to @site.title, @site.url
%p.lead= @site.description
= render 'sites/header', site: @site
= render 'sites/status', site: @site
= render 'sites/build', site: @site, class: 'btn-block'
%h3= t('posts.new')
%table.mb-3
- @site.layouts.each do |layout|
- next if layout.hidden?
%tr
%th= layout.humanized_name
%td.pl-3= link_to t('posts.add'), new_site_post_path(@site, layout: layout.value), class: 'btn btn-secondary btn-sm'
- if @filter_params[:layout] == layout.name.to_s
%td= link_to t('posts.remove_filter'), site_posts_path(@site, **@filter_params.merge(layout: nil)), class: 'btn btn-primary btn-sm'
- else
%td= link_to t('posts.filter'), site_posts_path(@site, **@filter_params.merge(layout: layout.value)), class: 'btn btn-secondary btn-sm'
%table.table.table-sm.mb-3
%tbody
- @site.schema_organization.each do |schema, _|
- schema = @site.layouts[schema]
- next if schema.hidden?
= render 'schemas/row', site: @site, schema: schema, filter: @filter_params
- if policy(@site_stat).index?
= link_to t('stats.index.title'), site_stats_path(@site), class: 'btn'
@ -31,8 +30,6 @@
type: 'info',
link: site_usuaries_path(@site)
= render 'sites/build', site: @site
- if @site.design.credits
= render 'bootstrap/alert' do
= sanitize_markdown @site.design.credits
@ -47,7 +44,8 @@
- next if param == 'q'
%input{ type: 'hidden', name: param, value: value }
.form-group.flex-grow-0.m-0
%input.form-control.border.border-magenta{ type: 'search', placeholder: 'Buscar', name: 'q', value: @filter_params[:q] }
%label.sr-only{for: 'q'}= t('.search')
%input#q.form-control.border.border-magenta{ type: 'search', placeholder: t('.search'), name: 'q', value: @filter_params[:q] }
%input.sr-only{ type: 'submit' }
- if @site.locales.size > 1

View file

@ -0,0 +1 @@
= link_to t('.add'), new_site_post_path(site, layout: schema.value), class: 'btn btn-secondary btn-sm m-0'

View file

@ -0,0 +1,4 @@
- if filter[:layout] == schema.name.to_s
= link_to t('.remove'), site_posts_path(site, **filter.merge(layout: nil)), class: 'btn btn-primary btn-sm m-0'
- else
= link_to t('.filter'), site_posts_path(site, **filter.merge(layout: schema.value)), class: 'btn btn-secondary btn-sm m-0'

View file

@ -0,0 +1,13 @@
%tr
%th.w-100{ scope: 'row' }
- if local_assigns[:parent_schema]
%span.text-muted &mdash;
= schema.humanized_name
%td.px-0.text-nowrap
= render 'schemas/add', **local_assigns
= render 'schemas/filter', **local_assigns
-# XXX: Solo un nivel de recursividad
- unless local_assigns[:parent_schema]
- schema.schemas.each do |s|
= render 'schemas/row', schema: s, site: site, filter: filter, parent_schema: schema

View file

@ -3,7 +3,7 @@
method: :post,
class: 'form-inline inline' do
= submit_tag site.enqueued? ? t('sites.enqueued') : t('sites.enqueue'),
class: 'btn no-border-radius',
class: "btn no-border-radius #{local_assigns[:class]}",
title: site.enqueued? ? t('help.sites.enqueued') : t('help.sites.enqueue'),
data: { disable_with: t('sites.enqueued') },
disabled: site.enqueued?

View file

@ -53,10 +53,10 @@
= render 'bootstrap/alert' do
= t('activerecord.errors.models.site.attributes.design_id.layout_incompatible.help',
layouts: site.incompatible_layouts.to_sentence)
.row.designs
.row.row-cols-1.row-cols-md-2.designs
-# Demasiado complejo para un f.collection_radio_buttons
- Design.all.find_each do |design|
.design.col-md-4.d-flex.flex-column
- Design.all.order(priority: :desc).each do |design|
.design.col.d-flex.flex-column
.custom-control.custom-radio
= f.radio_button :design_id, design.id,
checked: design.id == site.design_id,
@ -81,10 +81,12 @@
%h2= t('.licencia.title')
%p.lead= t('.help.licencia')
- Licencia.all.find_each do |licencia|
- next if licencia.custom? && site.licencia != licencia
.row.license
.col
.media.mt-1
= image_tag licencia.icons, alt: licencia.name, class: 'mr-3 mt-4'
- unless licencia.custom?
= image_tag licencia.icons, alt: licencia.name, class: 'mr-3 mt-4'
.media-body
.custom-control.custom-radio
= f.radio_button :licencia_id, licencia.id,
@ -95,8 +97,8 @@
= sanitize_markdown licencia.description,
tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6]
= link_to t('.licencia.url'), licencia.url,
target: '_blank', class: 'btn'
- unless licencia.custom?
= link_to t('.licencia.url'), licencia.url, target: '_blank', class: 'btn', rel: 'noopener'
%hr/
@ -158,9 +160,6 @@
= f.fields_for :deploys do |deploy|
= render "deploys/#{deploy.object.type.underscore}",
deploy: deploy, site: site
- else
= f.fields_for :deploys do |deploy|
= deploy.hidden_field :type
.form-group
= f.submit submit, class: 'btn btn-lg btn-block'

View file

@ -0,0 +1,3 @@
.hyphens{ lang: site.default_locale }
%h1= site.title
%p.lead= site.description

View file

@ -0,0 +1,21 @@
- link = nil
- if site.not_published_yet?
- message = t('.not_published_yet')
- elsif site.awaiting_publication?
- message = t('.awaiting_publication')
- elsif site.building?
- if site.average_publication_time_calculable?
- average_building_time = site.average_publication_time
- elsif !site.similar_sites?
- average_building_time = 60
- else
- average_building_time = site.average_publication_time_for_similar_sites
- average_publication_time_human = distance_of_time_in_words average_building_time
- message = t('.building', average_time: average_publication_time_human, seconds: average_building_time)
- else
- message = t('.available')
- link = true
= render 'bootstrap/alert' do
= link_to_if link, message.html_safe, site_build_stats_path(site), class: 'alert-link'

View file

@ -27,6 +27,8 @@
%p.lead= t('.urls.description')
%form{ method: 'get', action: '#custom-urls' }
%input{ type: 'hidden', name: 'interval', value: @interval }
%input{ type: 'hidden', name: 'period_start', value: params[:period_start] }
%input{ type: 'hidden', name: 'period_end', value: params[:period_end] }
.form-group
%label{ for: 'urls' }= t('.urls.label')
%textarea#urls.form-control{ name: 'urls', autocomplete: 'on', required: true, rows: @normalized_urls.size + 1, aria_describedby: 'help-urls' }= @normalized_urls.join("\n")

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

@ -147,7 +147,7 @@ Rails.application.configure do
}
config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', "noreply@sutty.nl") }
config.middleware.use ExceptionNotification::Rack, gitlab: {}
config.middleware.use ExceptionNotification::Rack, gitlab: {}, ignore_exceptions: (['DeployJob::DeployAlreadyRunningException'] + ExceptionNotifier.ignored_exceptions)
Rails.application.routes.default_url_options[:host] = "panel.#{ENV.fetch('SUTTY', 'sutty.nl')}"
Rails.application.routes.default_url_options[:protocol] = 'https'

View file

@ -13,6 +13,10 @@ ActiveSupport::Inflector.inflections(:en) do |inflect|
inflect.singular 'roles', 'rol'
inflect.plural 'rollup', 'rollups'
inflect.singular 'rollups', 'rollup'
inflect.plural 'code_of_conduct', 'codes_of_conduct'
inflect.singular 'codes_of_conduct', 'code_of_conduct'
inflect.plural 'privacy_policy', 'privacy_policies'
inflect.singular 'privacy_policies', 'privacy_policy'
end
ActiveSupport::Inflector.inflections(:es) do |inflect|
@ -28,4 +32,8 @@ ActiveSupport::Inflector.inflections(:es) do |inflect|
inflect.singular 'licencias', 'licencia'
inflect.plural 'rollup', 'rollups'
inflect.singular 'rollups', 'rollup'
inflect.plural 'code_of_conduct', 'codes_of_conduct'
inflect.singular 'codes_of_conduct', 'code_of_conduct'
inflect.plural 'privacy_policy', 'privacy_policies'
inflect.singular 'privacy_policies', 'privacy_policy'
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
ActiveJob::Serializers.add_serializers ActiveJob::Serializers::ExceptionSerializer
# Notificar los errores
Que.error_notifier = proc do |error, job|
ExceptionNotifier.notify_exception(error, data: (job || {}))
end

View file

@ -1,6 +1,6 @@
# frozen_string_literal: true
# Enviar una notificación cuando falla una tarea
SuckerPunch.exception_handler = lambda { |ex, _klass, _args|
ExceptionNotifier.notify_exception(ex)
SuckerPunch.exception_handler = lambda { |ex, _, args|
ExceptionNotifier.notify_exception(ex, data: args.last)
}

View file

@ -104,6 +104,25 @@ 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.
privacy_policy_accepted:
label: "I understand and accept the privacy policy"
help: "Read privacy policy"
href: "https://sutty.nl/en/privacy-policy/"
required: true
terms_of_service_accepted:
label: "My sites won't promote hate speech"
help: "Read terms of service"
href: "https://sutty.nl/en/terms-of-service/"
required: true
code_of_conduct_accepted:
label: "I want a more inclusive Internet"
help: "Read codes for sharing"
href: "https://sutty.nl/en/code-of-conduct/"
required: true
available_for_feedback_accepted:
label: "I'm available to provide feedback"
help: "We may contact you occasionaly"
required: false
signed_up: "A message with a confirmation link has been sent to your email address. Please follow the link to activate your account."
signed_up_but_inactive: You have signed up successfully. However, we could not sign you in because your account is not yet activated.
signed_up_but_locked: You have signed up successfully. However, we could not sign you in because your account is locked.

View file

@ -104,6 +104,24 @@ 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.
privacy_policy_accepted:
label: "Comprendo y acepto la política de privacidad"
help: "Leer política de privacidad"
href: "https://sutty.nl/politica-de-privacidad/"
required: "true"
terms_of_service_accepted:
label: "Mis sitios no promueven el discurso de odio"
help: "Leer términos de servicio"
href: "https://sutty.nl/terminos-de-servicio/"
required: "true"
code_of_conduct_accepted:
label: "Quiero una Internet más inclusiva"
help: "Leer códigos para compartir"
href: "https://sutty.nl/codigo-de-convivencia/"
required: "true"
available_for_feedback_accepted:
label: "Estoy disponible para ofrecer retroalimentación"
help: "Te contactaremos ocasionalmente"
signed_up: "Hemos enviado un mensaje con un enlace de confirmación a tu correo electrónico. Por favor, abrí el enlace para terminar de activar tu cuenta."
signed_up_but_inactive: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque tu cuenta aún no está activada.
signed_up_but_locked: Tu cuenta ha sido creada correctamente. Sin embargo, no hemos podido iniciar la sesión porque que tu cuenta está bloqueada.

View file

@ -23,6 +23,7 @@ en:
accept: "Accept invitation"
accept_until: "This invitation will be due in %{due_date}."
ignore: "If you don't want to accept the invitation, please ignore this email. Your account won't be created until you access the link above and set your password."
sign_in: "Sign in to your account to accept or decline the invitation."
time:
formats:
devise:

View file

@ -22,7 +22,8 @@ es:
someone_invited_you: "Alguien te ha invitado a colaborar en %{url}, podés aceptar la invitación con el enlace a continuación."
accept: "Aceptar la invitación"
accept_until: "La invitación vencerá el %{due_date}."
ignore: "Si no querés aceptar la invitación, por favor ignora este correo. Tu cuenta no será creada hasta que aceptes la invitación y configures una contraseña."
ignore: "Si no querés aceptar la invitación, por favor ignorá este correo. Tu cuenta no será creada hasta que aceptes la invitación y configures una contraseña."
sign_in: "Iniciá sesión con tu cuenta para aceptar o rechazar la invitación."
time:
formats:
devise:

View file

@ -15,6 +15,9 @@ en:
ar:
name: Arabic
dir: rtl
ur:
name: Urdu
dir: rtl
zh:
name: Chinese
dir: ltr
@ -116,6 +119,10 @@ 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!
@ -128,6 +135,14 @@ en:
title: Synchronize to backup server
success: Success!
error: Error
deploy_full_rsync:
title: Synchronize to another Sutty node
success: Success!
error: Error
deploy_distributed_press:
title: Distributed Web
success: Success!
error: Error
help: You can contact us by replying to this e-mail
maintenance_mailer:
notice:
@ -193,6 +208,8 @@ en:
title: 'Your location in Sutty'
logout: Log out
mutual_aid: Mutual aid
contact_us: "Contact us"
contact_us_href: "https://sutty.nl/en/#contact"
collaborations:
collaborate:
submit: Register
@ -274,6 +291,22 @@ en:
Only accessible through [Tor
Browser](https://www.torproject.org/download/)
deploy_distributed_press:
title: 'Publish to the distributed Web'
help: |
Make your site available through peer-to-peer protocols,
Inter-Planetary File System (IPFS), Hypercore, and via
BitTorrent, so your site is more resilient and can be available
offline, including in community mesh networks.
**Important:** Only use this option if you would like your data
to be permanently available. If you decide to undo this
selection, a cleared version of the site will be shared in its
place. However, it is possible that nodes on the distributed
storage network may continue retaining copies of the data
indefinitely.
[Learn more](https://sutty.nl/learn-more-about-publish-to-dweb-functionality/)
stats:
index:
title: Statistics
@ -332,6 +365,11 @@ en:
designer_url: 'Support the designer'
static_file_migration: 'File migration'
find_and_replace: 'Search and replace'
status:
building: "Your site is building, refresh this page in <time datetime=\"PT%{seconds}S\">%{average_time}</time>."
not_published_yet: "Your site is being published for the first time, please wait up to 1 minute..."
available: "Your site is available! Click here to find all the different ways to visit it."
awaiting_publication: "There are unpublished changes. Click the button below and wait a moment to find them on your site."
index:
title: 'My Sites'
pull: 'Upgrade'
@ -397,7 +435,7 @@ en:
title: 'Design'
actions: 'Information about this design'
url: 'Demo'
licencia: 'License'
license: 'License'
licencia:
title: 'License for the site and everything published on it'
url: 'Read the license'
@ -506,12 +544,10 @@ en:
order: 'Order'
content: 'Text'
new: 'Post types'
add: 'Add'
filter: 'Filter'
remove_filter: 'Back'
remove_filter_help: 'Remove the filter: %{filter}'
categories: 'Everything'
index: 'Posts'
index:
search: 'Search'
edit: 'Edit'
preview:
btn: 'Preliminary version'
@ -664,3 +700,12 @@ en:
queries:
show:
empty: '(empty)'
schemas:
add:
add: 'Add'
filter:
filter: 'Filter'
remove: 'Back'
build_stats:
index:
title: "Publications"

View file

@ -15,6 +15,9 @@ es:
ar:
name: Árabe
dir: rtl
ur:
name: Urdu
dir: rtl
zh:
name: Chino
dir: ltr
@ -116,6 +119,10 @@ 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!
@ -128,6 +135,14 @@ es:
title: Sincronizar al servidor alternativo
success: ¡Éxito!
error: Hubo un error
deploy_full_rsync:
title: Sincronizar a otro nodo de Sutty
success: ¡Éxito!
error: Hubo un error
deploy_distributed_press:
title: Web distribuida
success: ¡Éxito!
error: Hubo un error
help: Por cualquier duda, responde este correo para contactarte con nosotres.
maintenance_mailer:
notice:
@ -193,6 +208,8 @@ es:
title: 'Tu ubicación en Sutty'
logout: Cerrar sesión
mutual_aid: Ayuda mutua
contact_us: "Contacto"
contact_us_href: "https://sutty.nl/#contacto"
collaborations:
collaborate:
submit: Registrarme
@ -279,6 +296,22 @@ es:
Sólo será accesible a través del [Navegador
Tor](https://www.torproject.org/es/download/).
deploy_distributed_press:
title: 'Publicar a la Web distribuida'
help: |
Utiliza protocolos de pares, Inter-Planetary File System (IPFS),
Hypercore y torrents, para que tu sitio sea más resiliente y
esté disponible _offline_, inclusive en redes _mesh_
comunitarias.
**Importante:** Sólo usa esta opción si te parece correcto que
tu contenido esté disponible permanentemente. Cuando elijas
des-hacer esta acción, una versión "vacía" del sitio será
compartida en su lugar. Sin embargo, es posible que algunos
nodos en la red de almacenamiento distribuida puedan retener
copias de tu contenido indefinidamente.
[Saber más](https://sutty.nl/saber-mas-sobre-publicar-a-la-web-distribuida/)
stats:
index:
title: Estadísticas
@ -337,6 +370,11 @@ es:
designer_url: 'Apoyá a le(s) diseñadore(s)'
static_file_migration: 'Migración de archivos'
find_and_replace: 'Búsqueda y reemplazo'
status:
building: "Tu sitio se está publicando, recargá esta página en <time datetime=\"PT%{seconds}S\">%{average_time}</time>."
not_published_yet: "Tu sitio se está publicando por primera vez, por favor espera hasta un minuto..."
available: "¡Tu sitio está disponible! Cliqueá aquí para encontrar todas las formas en que podés visitarlo."
awaiting_publication: "Hay cambios sin publicar, cliqueá el botón debajo y espera un momento para encontrarlos en tu sitio."
index:
title: 'Mis sitios'
pull: 'Actualizar'
@ -515,11 +553,9 @@ es:
content: 'Cuerpo del artículo'
categories: 'Todos'
new: 'Tipos de artículos'
add: 'Agregar'
filter: 'Filtrar'
remove_filter: 'Volver'
remove_filter_help: 'Quitar este filtro: %{filter}'
index: 'Artículos'
index:
search: 'Buscar'
edit: 'Editar'
preview:
btn: 'Versión preliminar'
@ -672,3 +708,12 @@ es:
queries:
show:
empty: '(vacío)'
schemas:
add:
add: 'Agregar'
filter:
filter: 'Filtrar'
remove: 'Volver'
build_stats:
index:
title: "Publicaciones"

View file

@ -11,8 +11,6 @@ Rails.application.routes.draw do
namespace :v1 do
resources :csp_reports, only: %i[create]
get :'sites/hidden_services', to: 'sites#hidden_services'
post :'sites/add_onion', to: 'sites#add_onion'
resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-.]+/, id: /[a-z0-9\-.]+/ } do
get :'invitades/cookie', to: 'invitades#cookie'
post :'posts/:layout', to: 'posts#create', as: :posts
@ -77,5 +75,7 @@ Rails.application.routes.draw do
get :'stats/host', to: 'stats#host'
get :'stats/uris', to: 'stats#uris'
get :'stats/resources', to: 'stats#resources'
resources :build_stats, only: %i[index]
end
end

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

View file

@ -0,0 +1,37 @@
# frozen_string_literal: true
# Cambia todos los DeployRsync propios de Sutty a DeployFullRsync que se
# encarga de sincronizar todo.
class RenameDeployRsyncToDeployFullRsync < ActiveRecord::Migration[6.1]
def up
DeployRsync.all.find_each do |deploy|
dest = deploy.destination.split(':', 2).first
next unless nodes.include? dest
deploy.destination = "#{dest}:"
deploy.type = 'DeployFullRsync'
deploy.save
end
end
def down
DeployFullRsync.all.find_each do |deploy|
next unless nodes.include? deploy.destination.split(':', 2).first
deploy.destination = "#{deploy.destination}#{deploy.site.hostname}"
deploy.type = 'DeployRsync'
deploy.save
end
end
private
def nodes
@nodes ||= Rails.application.nodes.map do |node|
"sutty@#{node}"
end
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
# Crea códigos de conducta
class AddCodeOfConduct < ActiveRecord::Migration[6.1]
def up
create_table :codes_of_conduct do |t|
t.timestamps
t.string :title
t.text :description
t.text :content
end
# XXX: En lugar de ponerlo en las seeds
YAML.safe_load(File.read('db/seeds/codes_of_conduct.yml')).each do |coc|
CodeOfConduct.new(**coc).save!
end
end
def down
drop_table :codes_of_conduct
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
# Agrega políticas de privacidad
class AddPrivacyPolicy < ActiveRecord::Migration[6.1]
def up
create_table :privacy_policies do |t|
t.timestamps
t.string :title
t.text :description
t.text :content
end
# XXX: En lugar de ponerlo en las seeds
YAML.safe_load(File.read('db/seeds/privacy_policies.yml')).each do |pp|
PrivacyPolicy.new(**pp).save!
end
end
def down
drop_table :privacy_policies
end
end

View file

@ -0,0 +1,16 @@
# frozen_string_literal: true
# Agrega descripciones cortas a las licencias
class AddShortDescriptionToLicencias < ActiveRecord::Migration[6.1]
def up
add_column :licencias, :short_description, :string
YAML.safe_load_file('db/seeds/licencias.yml').each do |licencia|
Licencia.find_by_icons(licencia['icons']).update licencia
end
end
def down
remove_column :licencias, :short_description
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Agrega consentimientos a les usuaries. No usamos un loop de
# Usuarie::CONSENT_FIELDS porque quizás agreguemos campos luego.
class AddConsentToUsuaries < ActiveRecord::Migration[6.1]
def change
add_column :usuaries, :privacy_policy_accepted_at, :datetime
add_column :usuaries, :terms_of_service_accepted_at, :datetime
add_column :usuaries, :code_of_conduct_accepted_at, :datetime
add_column :usuaries, :available_for_feedback_accepted_at, :datetime
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Elimina un campo que nunca se usó
class RemoveAceptaPoliticasDePrivacidadFromUsuaries < ActiveRecord::Migration[6.1]
def change
remove_column :usuaries, :acepta_politicas_de_privacidad, :boolean, default: false
end
end

View file

@ -0,0 +1,5 @@
class AddPriorityToDesigns < ActiveRecord::Migration[6.1]
def change
add_column :designs, :priority, :integer
end
end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
# Envía los cambios a través de rsyncd
class ChangeFullRsyncDestination < ActiveRecord::Migration[6.1]
def up
DeployFullRsync.find_each do |deploy|
Rails.application.nodes.each do |node|
deploy.destination = "rsync://rsyncd.#{node}/deploys/"
deploy.save
end
end
end
def down
DeployFullRsync.find_each do |deploy|
Rails.application.nodes.each do |node|
deploy.destination = "sutty@#{node}:"
deploy.save
end
end
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# Agrega la columna de nodo a los logs
class AddNodeToAccessLogs < ActiveRecord::Migration[6.1]
def change
add_column :access_logs, :node, :string, index: true
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_10_22_225449) do
ActiveRecord::Schema.define(version: 2023_04_15_153231) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_trgm"
@ -217,6 +217,7 @@ ActiveRecord::Schema.define(version: 2021_10_22_225449) do
t.boolean "disabled", default: false
t.text "credits"
t.string "designer_url"
t.integer "priority"
end
create_table "indexed_posts", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@ -380,6 +381,7 @@ ActiveRecord::Schema.define(version: 2021_10_22_225449) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
create_trigger("indexed_posts_before_insert_update_row_tr", :compatibility => 1).
on("indexed_posts").
before(:insert, :update) do

View file

@ -0,0 +1,613 @@
---
- title_en: "Codes for sharing"
title_es: "Códigos para compartir"
description_en: "Codes of conduct allow inclusive communities."
description_es: "Los códigos de convivencia nos permiten alojar comunidades inclusivas."
content_en: |
# Code for sharing
> This code of conduct is based in "[Códigos para compartir, hackear,
> piratear en
> libertad](https://utopia.partidopirata.com.ar/zines/codigos_para_compartir.html)"
> published by [Partido Interdimensional
> Pirata](https://partidopirata.com.ar/).
> We use gender neutral pronouns to include all peoples. In this sense,
> we encourage different forms, strategies and tools used to embody
> practices that aren't anthropocentric, sexist, cis-sexist in our
> language.
## Introduction
This is an example code that strives to give a consensual frame to
enable asistance, permanence and confortable stay to everyone using and
inhabiting [Sutty](https://sutty.nl/), and to welcome new users and
potential allies as well. It sets the floor for desirable and
acceptable, and undesirable and intolerable conducts for its community.
You can use it with or without changes, adapting it to your activities.
This code is in permanent and collective mutation and feeds, copies and
inspires on the following sources:
* <https://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy>
* <https://trans-code.org/code-of-conduct/>
* <https://openhardware.science/logistics/gosh-code-of-conduct/>
* <https://hypatiasoftware.org/code-of-conduct/>
We strive to sustain and foment an open community, that invites and
attains participation from more people, in all their diversity. We know
that spaces related to computing and free software are mostly inhabited
by middle class cis white males, even when there's an acknowledgement of
the need to close the gender gap. In this sense, this is our little
contribution, made from collective practices on multiple dimentions,
reflections, readings, and experiences that grow every day.
## That everyone needs to be well treated
Every being that we share space with deserves good treatment, respect
and compassion. Here we share basic criteria for introduction and care.
### Towards humans
Everyone is deserving of care and greetings and we have a right to
assume good intentions from others.
When we refer to other humans, we try to be careful and respectul
towards their gender identity. For this, these principles are useful:
* Don't assume, judge or try to "interpret" the gender of others.
* Don't use gender beforehand. It's related to the previous item, but
puts emphasis towards naturalized gendering behaviour (ie. assuming
someone wearing a dress uses female pronouns...). The proposal is to
discard them.
* If a person explicits their pronouns and mode in which they want to be
referenced, we respect them, by listening and trying to use their
prefered pronouns.
* When presentations don't include pronouns, we can ask respectfully
for prefered pronouns. But be careful! This question must be asked
to everyone, otherwise the "suspicion" is loaded towards a person, and
it can become a form of harrassment.
* Do we need to know the gender of a person to relate with them? Maybe
a better practice is to evade gendering others. But if this means to
use "them" compulsively, some people may be made to feel bad. (For
instance, trans\* people who use female or male pronouns may feel
upset or outed when refering to them as "them", specially if they're
the only ones to be gendered like this in a group!
* When in doubt, asking and apologizing respectfully is a good way of
being careful towards each other.
## Important points to guarantee our space from being expulsive
**Listening to and between everyone in a caring climate**
* Listen to what everyone has to say, being mindful that everyone has
something valuable to communicate.
* For active listening, we prefer to ask first, before making judgement.
* Sometimes being silent is a condition for others to be able to talk.
To listen is an exercise that requires practice. Also talking.
* We're interested in what everyone has to say. If you're more trained
in participating, talking, and having opinions, take into account that
not everyone does. Give them space if they want to take it. But
remember that encouraging is not the same as pressuring!
* We try to check and stop offensive practices to add to the respectful
climate. This doesn't mean to be submissive or to agree to
everything. At the least, it sets a floor of respect towards enabling
a dialogue when necessary.
* It's at the very least disrepectful to repeat damaging behaviour when
it was already identified as such. It can make others unconfortable,
or hurt and expel them. We'll make this point every time that is
needed and tolerable.
* We avoid this behaviour ourselves and we help others to notice their
own.
* When raising attention becomes insufficient, we need to review this
agreements to keep the coexistence. This implies to act in accord to
them, and that this code can be revised and updated when deemed
necessary (there's no consensus).
* One of the ways in which free software spaces can be and are expulsive
is with attitudes that don't contemplate diversity in knowledges and
interlocutors. By appealing to technicisms, many comrades are kept
out of what's happening, and no one verifies if everyone is keeping up
with the conversation.
Our fervent recommendation is to be attentive to this dynamic so we
can avoid or revert them.
* The counter to the previous situation is "mansplaining": a cis-male
person assuming the authoritative place of knowledge to (over-)explain
everything to others, in a patronizing way and without taking into
account what others want to listen or not, to say, what already know
or do, etc.
* We believe there's no "authoritative voice" to have an opinion and to
participate. Free culture is for everyone to share.
* "Sharing is caring" v. "Google is your friend". Meritocracy and other
traditional codes in cyber-communities work against free culture. We
support the pirate culture that onboards ever more pirates to their
ships. We believe that culture is for everyone and we defy elitisms.
* We don't assume that other people shares our likings, beliefs, class
position, sexuality, etc. We can be violent when we misread others.
We recommend to ask respectfully and to avoid comments or jokes that
can be hurtful to others.
* We speak and act gently and inclusively.
* We respect different points of view, experiences, beliefs, etc. and we
take them into account when we act collectively so it reflects in our
attitudes.
* We welcome criticism, specially the constructive kind ;)
* We focus in what's best for the community, without losing warmth,
respect and diversity amongst ourselves.
* We show empathy toward others. We want to share and communicate.
* It's useful to think everyone has different abilities, stories,
experiences... It's possible to not understand some comments. We try
to avoid acting in bad faith and to use every accessibility tool we
can.
* The last item includes neurodiverse people and those that have
experienced trauma. Sometimes, sarcasm or irony is not well received
or understood by others. We take this into our strategies to include
everyone in our communications. Even more, if we think some topics
could be sensitive to others (memory-triggering, phobias, untolerable,
explicit violence or body images, etc.), we use content warnings (cw)
before what we wanted to share. For instance: "cw: comments about
sexual and physical violence". This allows everyone to opt in to the
content instead of being taken by surprise.
* We're respectful of limits established by others (personal space,
physical contact, interaction mood, privacy, being photographed, etc.)
* We want to and believe in welcoming more pirates!
## Consent for documenting and sharing in media
* If you're going to take pictures or record video, ask consent from
people involved.
* If there're minors, ask their responsible families.
## Our commitment against harassment
In the interest of fomenting an open, diverse and welcoming community,
we contributors and admins make a commitment against harassment in our
projects and community for everyone, without regard of age, body
diversity, capacity, neuro-diversity, ethnicity, gender identity and
expression, experience level, nationality, physical appearance,
religion, sexual identity or orientation.
Examples of unacceptable behaviour from participants:
* Offensive comments about gender/s, gender identity and expression,
sexual orientation, capacity, mental sickness, neuro-(a)tipicality,
physical appearance, body size, ethnicity or religion.
* Unwelcomed comments related to personal and life choices, including
amongst others, those related to food, health, children upbringing,
drug use and employment.
* Insulting or despective comments and personal or political attacks.
**Trolling**.
* Assuming others' gender. If you're in doubt, ask politely about
pronouns. Don't use the name(s) that people don't use anymore, use
the name, _nickname_ or pseudonym that they prefer. Do you really
need the name, ID number, biometric data, birth certificate of others?
* Sexual comments, images or behaviour, unneeded or in spaces where they
weren't appropiate.
* Unconsented physical contact or repeated after being asked to stop.
* Threatening others.
* Inciting violence towards others, including self-damage.
* Deliberate intimidation.
* Stalking.
* To harass by photographing or recording without consent, including
uploading personal information to the Internet.
* Interrupting a conversation constantly.
* Making unwanted sexual comments.
* Unappropiate patterns of social contact, like asking/assuming
inappropiate intimacy levels with others.
* Trying to interact with a person after being asked not to.
* Exposing deliberately any aspect of a person identity without consent,
except when necessary for protecting others against intentional abuse.
* Making public any kind of private conversation.
* Other kinds of conduct that can be considered inappropiate in an
environment of camaraderie.
* Repeating attitudes that others find offensive or violatory of this
code.
## Consequences
* Any person that has been asked to stop offensive behaviour is expected
to respond immediately, even when in disagreement.
* Admins can take any action deemed necessary and adequate, including
expelling the person or removing their site without advertence. This
decision is taken by consensus between admins and is reserved for
extreme cases that compromise the community or the permanence of
others without feeling wronged or threatened.
* Admins reserve the right to forbid participation to any future
activity or publication.
As we mentioned before, this code is in permanent collective mutation.
It's main objective is to generate an inclusive and non-expulsive
environment that is also transparent and open without [missing
stairs](https://en.wikipedia.org/wiki/Missing_stair) ("the missing stair
from a house that everyone knows about but no one wants to take
responsibility for"). It's important to adapt it to different
activities and that it nurtures from contributions from its users.
Receiving your comments and input will help us to achieve this
objective.
## Let's keep in contact!
Si pasaste por alguna situación que quieras compartir --te hayas animado
o no a decirlo en el momento--, podés ponerte en contacto con nosotres.
Con respecto a quejas o avisos acerca de situaciones de violencia,
acoso, abuso o repetición de conductas que se advirtieron como
intolerables, tomamos la responsabilidad de tenerlas en cuenta y
trabajar en ellas para que el resultado sea el favorable al espíritu de
colectiva que elegimos y describimos aquí. Si bien consideramos que las
prácticas punitivistas no van con nosotres, nuestra decisión explícita
es escuchar a la persona que se manifiesta como violentada o víctima y
acompañarla.
You can contact us if you were part of a situation you want to share
--even if you didn't pointed it in the moment.
In regards to complaints or notices about violence, harassment, abuse or
repeated untolerable conducts, we take the responsibility of working on
them for a favorable result towards the collective spirit we defined
here. Even when we don't condone punitivist practices, our explicit
decision is for the victim to be listened and accompanied.
content_es: |
# Códigos para compartir
> Este código de convivencia está basado en los "[Códigos para
> compartir, hackear, piratear en
> libertad](https://utopia.partidopirata.com.ar/zines/codigos_para_compartir.html)"
> publicados por el [Partido Interdimensional
> Pirata](https://partidopirata.com.ar/).
> Utilizamos preferentemente la 'e' para referirnos a las personas en
> general. En ese sentido, alentamos las diferentes formas, estrategias
> y herramientas para incorporar prácticas no antropocéntricas,
> sexistas, ni cisexistas en la lengua. Otras alternativas que apoyamos
> --y eventualmente usamos-- son el uso del femenino, la letra e, arrobas,
> equis, asteriscos, etc.
## Introducción
Este es un ejemplo de código que busca aportar un marco de consenso para
garantizar la asistencia, permanencia y cómoda estadía de todas las
personas que habitan y utilizan Sutty, así como para bienvenir a nueves
usuaries y potenciales aliades. Para esto, fija un piso de conductas
deseables, aceptables, indeseables y/o intolerables para la comunidad.
Podés usarlo sin cambios o modificarlo para adaptarlo a tus actividades.
Este código está en permanente mutación colectiva y se alimenta, copia e
inspira de las siguientes fuentes:
* <https://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy>
* <https://trans-code.org/code-of-conduct/>
* <https://openhardware.science/logistics/gosh-code-of-conduct/>
* <https://hypatiasoftware.org/code-of-conduct/>
Procuramos mantener y fomentar una comunidad abierta, que invite y logre
la participación de cada vez más personas, en toda su diversidad.
Sabemos que los espacios de Software Libre, informática, sistemas, etc.
son habitados mayormente por varones cis, blancos y de clase media, pese
al reconocimiento de la necesidad de eliminar la brecha de géneros. En
este sentido, este es nuestro pequeño aporte, hecho de prácticas
colectivas de múltiples dimensiones, reflexiones, lecturas, experiencias
que crecen día a día.
## Que todes les seres sean bien tratades
Cada ser con el que compartamos el espacio es merecedore de buen trato,
respeto y compasión. En otras palabras, compartimos a continuación los
criterios básicos de presentación y cuidados.
Para les humanes
Todes somos dignes de cuidados y de saludos y tenemos derecho a suponer
las buenas intenciones de le otre.
Para referirnos a otres humanes, trataremos de ser cuidadoses y
respuestuoses de su identidad de género. Para ello, son útiles los
siguientes principios:
* No presuponer, juzgar o "interpretar" el género de le otre.
* No generizar de antemano. Se desprende del punto anterior, pero hace
especial énfasis en comportamientos naturalizados de generización (EJ:
presuponer que porque una persona usa un vestido se nombra en
femenino...). La propuesta es desecharlos.
* Si la persona explicita sus pronombres y modos en que quiere ser
referenciade, lo respetamos, escuchando y procurando referirnos a elle
usando sus pronombres elegidos.
* Si no se incluye en la presentación los pronombres preferidos, podemos
preguntar respetuosamente qué pronombres se usan. ¡Pero atención! Es
una pregunta que debe dirigirse a todes por igual, de lo contrario,
carga la "sospecha" sobre la persona señalada y puede resultar en una
forma de hostigamiento.
* ¿Es necesario conocer el género de una persona para relacionarnos o
referirnos a ella? Quizás una buena práctica es evitar generizar para
todas las personas. Pero si esto implica el uso compulsivo de la "e"
para todes, puede ser que alguna persona se sienta molesta. (Por
ejemplo, las personas trans\* que se identifican en femenino o
masculino suelen sentirse molestas y "sacadas del clóset" u *outeadas*
si se refieren a ellas con la "e", ¡en especial si son las únicas en
ser generizadas de esta forma en un grupo!).
* Ante cualquier duda, preguntar respetuosamente y disculparse
respetuosamente es una buena idea para ayudar a cuidarnos.
## Puntos importantes para garantizar que nuestro espacio no resulte expulsivo
**Escucharnos a todas y entre todas en un clima de cuidados**
* Escuchar lo que cada quien tiene para decir, conscientes de que todes
tenemos algo valioso para comunicar(nos).
* Para la escucha activa, preferimos preguntar primero, en lugar de
hacer juicios.
* Hacer silencio a veces es la condición para que otres puedan animarse
a hablar. Escuchar es un ejercicio que requiere práctica. También lo
es hablar.
* Nos interesa lo que todes tengan para decir. Por lo tanto, si estás
más entrenade en el ejercicio de participar, hablar, opinar, tené en
cuenta que quizás haya otres que no lo estén tanto: darles el espacio
si quieren tomarlo. ¡Pero recordá que incentivar no es lo mismo que
presionar!
* Tratamos de revisar y discontinuar alguna práctica que pueda haber
resultado ofensiva, para sumar al clima de respeto. Sin embargo, esto
no significa "bajar la cabeza" o estar necesariamente de acuerdo. Al
menos, fija un piso de respeto para comenzar un diálogo en el caso en
que sea necesario.
* Es --al menos-- una falta de respeto repetir un comportamiento dañino
que ya se identificó como tal. Puede incomodar, lastimar y expulsar a
otres, por lo que preferimos llamar la atención sobre este punto todas
las veces que sea necesario y tolerable.
* Evitamos esto nosotres y ayudamos a otres a darse cuenta cuando lo
están haciendo.
* En los casos en los que los llamados de atención resulten
insuficientes, hemos de revisar estos acuerdos para sostener la
convivencia. Eso implica actuar de acuerdo a ellos. Y también que
estos códigos pueden ser revisados y actualizados en caso de que se
considere necesario (deje de haber consenso).
* Una manera en la que los espacios de Software Libre y tecnologías
pueden y suelen ser expulsivos es mediante actitudes que no contemplan
la diversidad de saberes e interlocutor\*s. So pretexto de incluir
tecnicismos, muches compañeres quedan al margen de lo que está
sucediendo, muchas veces, sin que nadie tenga la mínima delicadeza de
verificar que todes estén siguiendo la conversación.
Recomendamos fervientemente estar atentes a estas dinámicas para poder
evitarlas y/o revertirlas.
* La otra cara de la situación anterior es el famoso _mansplaining_: un
tipo cis poniéndose en el lugar de la autoridad del saber para
(sobre-)explicar todo a le otre, de manera paternalista y sin tener en
cuenta lo que le otre quiere o no escuchar, decir, lo que sabe o hace,
etc.
* Creemos que no hace falta ser "una voz autorizada" para opinar y
participar. La cultura libre se comparte entre todes.
* "Compartir es bueno" vs. "Google es tu amigo". La meritocracia y
ciertos códigos tradicionales de ciertas ciber-comunidades suelen
operar de manera contraria a la propuesta de la cultura libre de
compartir. Apoyamos la cultura piratil que suma más piratas a los
barcos. Creemos que la cultura es para todes y desafiamos las
prácticas elitistas.
* No damos por sentado que la persona con la que estamos interactuando
comparte gustos, creencias, pertenencias de clase, sexualidad, etc.
Podemos ser violentes si hacemos una lectura equivocada de le otre.
Recomendamos siempre preguntar de manera respetuosa y evitar
comentarios o chistes que puedan herir a les otres.
* Usamos lenguaje amable e inclusivo y mostramos conductas amables e
inclusivas.
* Respetamos los diferentes puntos de vista, experiencias, creencias,
etc. y lo tenemos en cuenta cuando estamos en grupo para verlo
reflejado en nuestras actitudes.
* Aceptamos las críticas. En especial las constructivas ;)
* Nos enfocamos en lo que es mejor para la comunidad, sin por ello
perder de vista la calidez, el respeto y la diversidad entre cada une
de nosotres.
* Mostramos empatía con les otres. Queremos comunicarnos y compartir.
* Es útil tener en cuenta que las personas tenemos capacidades,
historias, recorridos... diferentes. Es posible que algunos
comentarios no sean comprendidos. Trataremos de evitar la mala fe y
sumar todas las herramientas de accesibilidad para todas las personas.
* El punto anterior incluye a personas neurodiversas y con experiencias
de trauma. A veces el sarcasmo o la ironía no es bien recibido o
comprendido por todes. Será útil tenerlo en cuenta para buscar
estrategias que no excluyan a las personas de nuestros intercambios.
Por otro lado, si creemos que determinados temas pueden ser sensibles
(desencadenantes de recuerdos, fobias, difíciles de tolerar o cargados
de violencia o imágenes corporales muy explícitas, por ejemplo) para
algunas personas y nos valemos de las advertencias de contenido o
_content warning_ (cw) (ej: "cw: comentarios de violencia sexual y
violencia física") antes del contenido a introducir. Esto permite que
cada cual pueda elegir si acceder o no a esos contenidos y que no le
tomen por sorpresa.
* Respetamos los límites que establecen otras personas (espacio
personal, contacto físico, ganas de interactuar, no querer dar datos
de contacto o ser fotografiades, etc.)
* ¡Queremos y (creemos) en sumar piratas!
## Consentimiento para documentar o compartir en medios
* Si vas a publicar video o fotos, obtené el consentimiento de las
personas.
* Si hay menores, consultalo con su familia responsable.
## Nuestro compromiso contra el acoso
En el interés de fomentar una comunidad abierta, diversa y hospitalaria,
nosotres como contribuyentes y administradores nos comprometemos a hacer
de la participación en nuestro proyecto y nuestra comunidad una
experiencia libre de acoso para todes, independientemente de la edad,
diversidad corporal, capacidades, neuro-diversidad, etnia, identidad y
expresión de género, nivel de experiencia, nacionalidad, apariencia
física, raza, religión, identidad u orientación sexual y otras.
Ejemplos de comportamiento inaceptable por parte de participantes:
* Comentarios ofensivos relacionados con el/los género/s, la identidad
y expresión de género, la orientación sexual, las capacidades, las
enfermedades mentales, la neuro(a)tipicalidad, la apariencia física,
el tamaño corporal, la raza o la religión.
* Comentarios indeseados relacionados con las elecciones y las prácticas
de estilo de vida de una persona, incluidas, entre otras, las
relacionadas con alimentos, salud, crianza de les hijes, drogas y
empleo.
* Comentarios insultantes o despectivos (_trolling_) y ataques
personales o políticos.
* Dar por sentado el género de las demás personas. En caso de duda,
preguntá educadamente por los pronombres. No uses nombres con los que
las personas no se identifican, usá el nombre, _nickname_ o apodo que
hayan elegido (¿Realmente necesitás el nombre y el número de DNI,
datos biométricos, carta natal, etc.?).
* Comentarios, imágenes o comportamientos sexuales innecesarios o fuera
de lugar en espacios en los que no son apropiados.
* Contacto físico sin consentimiento o reiterado tras un pedido de cese.
En el mismo sentido, invasión del espacio corporal (y espacios en
general).
* Amenazas contra otras personas.
* Incitación a la violencia contra otra persona, que también incluye
alentar a una persona a autolesionarse.
* Intimidación deliberada.
* Acechar (_stalkear_) o perseguir.
* Acosar fotografiando o grabando sin consentimiento, incluyendo también
subir información personal a Internet sobre alguien para acosarle.
* Interrumpir constantemente en una conversación.
* Hacer comentarios sexuales indeseados.
* Patrones de contacto social inapropiados, como por ejemplo
pedir/suponer niveles de intimidad inapropiados con les demás.
* Seguir tratando de entablar conversación con una persona cuando se te
pidió que no lo hagas.
* Divulgar deliberadamente cualquier aspecto de la identidad de una
persona sin su consentimiento, excepto que sea necesario para proteger
a otras personas de abuso intencional.
* Hacer pública una conversación privada de cualquier tipo.
* Otros tipos de conducta que pudieran considerarse inapropiadas en un
entorno de camaradería.
* Reiteración de actitudes que les participantes señalen como ofensivas
o violatorias de este código.
## Consecuencias
* Se espera que la persona a la que se la haya pedido que cese un
comportamiento que infringe este código acate el pedido de forma
inmediata, incluso si no está de acuerdo con este.
* Les administradores pueden tomar cualquier acción que juzguen
necesaria y adecuada, incluyendo expulsar a la persona o dar de baja
sus sitios sin advertencia. Esta decisión la toman les administradores
en consenso y se reserva para casos extremos que comprometan la
continuidad de la comunidad o bien la posibilidad de permanencia en
ella de otres participantes sin sentirse agraviades o amenazades.
* Les administradores se reservan el derecho a prohibir la asistencia a
cualquier actividad futura o publicación de sitios.
Como mencionamos antes, este código está en permanente mutación
colectiva. El objetivo principal es generar un ambiente inclusivo y no
expulsivo, un ambiente transparente y abierto en el que no haya
escalones faltantes ("el escalón que falta en la escalera y todo el
mundo sabe y avisa pero nadie se quiere hacer cargo"). Es importante que
se adapte a las actividades y se nutra de las contribuciones de les
usuaries. Recibir tus comentarios y aportes nos ayudará a cumplir con
su objetivo principal.
## ¡Sigamos en contacto!
Si pasaste por alguna situación que quieras compartir --te hayas animado
o no a decirlo en el momento--, podés ponerte en contacto con nosotres.
Con respecto a quejas o avisos acerca de situaciones de violencia,
acoso, abuso o repetición de conductas que se advirtieron como
intolerables, tomamos la responsabilidad de tenerlas en cuenta y
trabajar en ellas para que el resultado sea el favorable al espíritu de
colectiva que elegimos y describimos aquí. Si bien consideramos que las
prácticas punitivistas no van con nosotres, nuestra decisión explícita
es escuchar a la persona que se manifiesta como violentada o víctima y
acompañarla.

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