5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-15 10:01:40 +00:00

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

This commit is contained in:
f 2023-05-13 11:53:30 -03:00
commit 91bad0cdd4
168 changed files with 3772 additions and 696 deletions

View file

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

View file

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

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"

9
.profile Normal file
View file

@ -0,0 +1,9 @@
Color_Off='\e[0m'
BPurple='\e[1;35m'
BBlue='\e[1;34m'
is_git() {
git rev-parse --abbrev-ref HEAD 2>/dev/null
}
PS1="\[${BPurple}\]\$(is_git) \[${BBlue}\]\W\[${Color_Off}\] >_ "

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

21
Gemfile
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'
@ -48,9 +51,9 @@ gem 'image_processing'
gem 'icalendar'
gem 'inline_svg'
gem 'httparty'
gem 'safe_yaml', source: 'https://gems.sutty.nl'
gem 'safe_yaml'
gem 'jekyll', '~> 4.2'
gem 'jekyll-data', source: 'https://gems.sutty.nl'
gem 'jekyll-data'
gem 'jekyll-commonmark'
gem 'jekyll-images'
gem 'jekyll-include-cache'
@ -89,7 +92,7 @@ gem 'stackprof'
gem 'prometheus_exporter'
# debug
gem 'fast_jsonparser'
gem 'fast_jsonparser', '~> 0.5.0'
gem 'down'
gem 'sourcemap'
gem 'rack-cors'
@ -99,18 +102,6 @@ gem 'net-ssh'
gem 'ed25519'
gem 'bcrypt_pbkdf'
group :themes do
gem 'adhesiones-jekyll-theme', require: false
gem 'editorial-autogestiva-jekyll-theme', require: false
gem 'minima', require: false
gem 'sutty-minima', require: false
gem 'radios-comunitarias-jekyll-theme', require: false
gem 'share-to-fediverse-jekyll-theme', require: false
gem 'sutty-donaciones-jekyll-theme', require: false
gem 'sutty-jekyll-theme', require: false
gem 'recursero-jekyll-theme', require: false
end
group :production do
gem 'lograge'
end

View file

@ -88,15 +88,6 @@ GEM
zeitwerk (~> 2.3)
addressable (2.8.0)
public_suffix (>= 2.0.2, < 5.0)
adhesiones-jekyll-theme (0.2.1)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-locales (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
ast (2.4.2)
autoprefixer-rails (10.3.3.0)
execjs (~> 2)
@ -124,6 +115,7 @@ GEM
xpath (>= 2.0, < 4.0)
chartkick (4.1.2)
childprocess (4.1.0)
climate_control (1.2.0)
coderay (1.1.3)
colorator (1.1.0)
commonmarker (0.21.2-x86_64-linux-musl)
@ -162,32 +154,46 @@ GEM
devise_invitable (2.0.5)
actionmailer (>= 5.0)
devise (>= 4.6)
distributed-press-api-client (0.2.2)
addressable (~> 2.3, >= 2.3.0)
climate_control
dry-schema
httparty (~> 0.18)
json (~> 2.1, >= 2.1.0)
jwt (~> 2.6.0)
dotenv (2.7.6)
dotenv-rails (2.7.6)
dotenv (= 2.7.6)
railties (>= 3.2)
down (5.2.4)
addressable (~> 2.8)
dry-configurable (1.0.1)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-core (1.0.0)
concurrent-ruby (~> 1.0)
zeitwerk (~> 2.6)
dry-inflector (1.0.0)
dry-initializer (3.1.1)
dry-logic (1.5.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
zeitwerk (~> 2.6)
dry-schema (1.13.0)
concurrent-ruby (~> 1.0)
dry-configurable (~> 1.0, >= 1.0.1)
dry-core (~> 1.0, < 2)
dry-initializer (~> 3.0)
dry-logic (>= 1.5, < 2)
dry-types (>= 1.7, < 2)
zeitwerk (~> 2.6)
dry-types (1.7.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0, < 2)
dry-inflector (~> 1.0, < 2)
dry-logic (>= 1.4, < 2)
zeitwerk (~> 2.6)
ed25519 (1.2.4-x86_64-linux-musl)
editorial-autogestiva-jekyll-theme (0.3.4)
jekyll (~> 4)
jekyll-commonmark (~> 1.3)
jekyll-data (~> 1.1)
jekyll-dotenv (>= 0.2)
jekyll-feed (~> 0.15)
jekyll-hardlinks (~> 0)
jekyll-ignore-layouts (~> 0)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-order (~> 0)
jekyll-relative-urls (~> 0)
jekyll-seo-tag (~> 2)
jekyll-spree-client (~> 0)
jekyll-unique-urls (~> 0)
jekyll-write-and-commit-changes (~> 0)
sutty-liquid (~> 0)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
@ -244,8 +250,8 @@ GEM
thor
hiredis (0.6.3-x86_64-linux-musl)
http_parser.rb (0.8.0-x86_64-linux-musl)
httparty (0.18.1)
mime-types (~> 3.0)
httparty (0.21.0)
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.8.11)
concurrent-ruby (~> 1.0)
@ -289,7 +295,7 @@ GEM
jekyll (~> 4)
jekyll-ignore-layouts (0.1.2)
jekyll (~> 4)
jekyll-images (0.3.0)
jekyll-images (0.3.2)
jekyll (~> 4)
ruby-filemagic (~> 0.7)
ruby-vips (~> 2)
@ -320,6 +326,7 @@ GEM
jekyll-write-and-commit-changes (0.2.1)
jekyll (~> 4)
rugged (~> 1)
jwt (2.6.0)
kaminari (1.2.1)
activesupport (>= 4.1.0)
kaminari-actionview (= 1.2.1)
@ -369,10 +376,6 @@ GEM
mini_magick (4.11.0)
mini_mime (1.1.2)
mini_portile2 (2.6.1)
minima (2.5.1)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.14.4)
mobility (1.2.4)
i18n (>= 0.6.10, < 2)
@ -384,7 +387,11 @@ GEM
nokogiri (1.12.5-x86_64-linux-musl)
mini_portile2 (~> 2.6.1)
racc (~> 1.4)
njalla-api-client (0.2.0)
dry-schema
httparty (~> 0.18)
orm_adapter (0.5.0)
pairing_heap (3.0.0)
parallel (1.21.0)
parser (3.0.2.0)
ast (~> 2.4.1)
@ -415,17 +422,6 @@ GEM
rack
rack-test (1.1.0)
rack (>= 1.0, < 3)
radios-comunitarias-jekyll-theme (0.1.5)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
jekyll-turbolinks (~> 0)
rails (6.1.4.1)
actioncable (= 6.1.4.1)
actionmailbox (= 6.1.4.1)
@ -462,24 +458,6 @@ GEM
rb-fsevent (0.11.0)
rb-inotify (0.10.1)
ffi (~> 1.0)
recursero-jekyll-theme (0.2.0)
jekyll (~> 4)
jekyll-commonmark (~> 1.3)
jekyll-data (~> 1.1)
jekyll-dotenv (>= 0.2)
jekyll-feed (~> 0.15)
jekyll-ignore-layouts (~> 0)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-linked-posts (~> 0)
jekyll-locales (~> 0.1)
jekyll-lunr (~> 0.1)
jekyll-order (~> 0)
jekyll-relative-urls (~> 0)
jekyll-seo-tag (~> 2)
jekyll-unique-urls (~> 0.1)
sutty-archives (~> 2.2)
sutty-liquid (~> 0)
redis (4.5.1)
redis-actionpack (5.2.0)
actionpack (>= 5, < 7)
@ -504,6 +482,10 @@ GEM
actionpack (>= 5.0)
railties (>= 5.0)
rexml (3.2.5)
rgl (0.6.2)
pairing_heap (>= 0.3.0)
rexml (~> 3.2, >= 3.2.4)
stream (~> 0.5.3)
rouge (3.26.1)
rubocop (1.23.0)
parallel (~> 1.10)
@ -552,14 +534,6 @@ GEM
rubyzip (>= 1.2.2)
semantic_range (3.0.0)
sexp_processor (4.16.0)
share-to-fediverse-jekyll-theme (0.1.4)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
simpleidn (0.2.1)
unf (~> 0.1.4)
sourcemap (0.1.1)
@ -579,34 +553,14 @@ GEM
sprockets (>= 3.0.0)
sqlite3 (1.4.2-x86_64-linux-musl)
stackprof (0.2.17-x86_64-linux-musl)
stream (0.5.5)
sucker_punch (3.0.1)
concurrent-ruby (~> 1.0)
sutty-archives (2.5.4)
jekyll (>= 3.6, < 5.0)
sutty-donaciones-jekyll-theme (0.1.2)
jekyll (~> 4.0)
jekyll-data (~> 1.1)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-locales (~> 0.1)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
sutty-archives (~> 2.2)
sutty-jekyll-theme (0.1.2)
jekyll (~> 4.0)
jekyll-feed (~> 0.9)
jekyll-images (~> 0.2)
jekyll-include-cache (~> 0)
jekyll-relative-urls (~> 0.0)
jekyll-seo-tag (~> 2.1)
sutty-liquid (0.7.4)
fast_blank (~> 1.0)
jekyll (~> 4)
sutty-minima (2.5.0)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
symbol-fstring (1.0.2-x86_64-linux-musl)
sysexits (1.2.0)
temple (0.8.2)
@ -654,7 +608,6 @@ PLATFORMS
x86_64-linux-musl
DEPENDENCIES
adhesiones-jekyll-theme
bcrypt (~> 3.1.7)
bcrypt_pbkdf
blazer
@ -669,10 +622,10 @@ DEPENDENCIES
devise
devise-i18n
devise_invitable
distributed-press-api-client (~> 0.2.3)
dotenv-rails
down
ed25519
editorial-autogestiva-jekyll-theme
email_address!
exception_notification
factory_bot_rails
@ -691,7 +644,7 @@ DEPENDENCIES
jbuilder (~> 2.5)
jekyll (~> 4.2)
jekyll-commonmark
jekyll-data!
jekyll-data
jekyll-images
jekyll-include-cache
kaminari
@ -702,9 +655,9 @@ DEPENDENCIES
lograge
memory_profiler
mini_magick
minima
mobility
net-ssh
njalla-api-client
nokogiri
pg
pg_search
@ -714,31 +667,26 @@ DEPENDENCIES
pundit
rack-cors
rack-mini-profiler
radios-comunitarias-jekyll-theme
rails (~> 6)
rails-i18n
rails_warden
recursero-jekyll-theme
redis
redis-rails
rgl
rollups!
rubocop-rails
rubyzip
rugged
safe_yaml!
safe_yaml
sassc-rails
selenium-webdriver
share-to-fediverse-jekyll-theme
sourcemap
spring
spring-watcher-listen (~> 2.0.0)
sqlite3
stackprof
sucker_punch
sutty-donaciones-jekyll-theme
sutty-jekyll-theme
sutty-liquid (>= 0.7.3)
sutty-minima
symbol-fstring
terminal-table
timecop

View file

@ -5,4 +5,7 @@ blazer_1h: bundle exec rake blazer:run_checks SCHEDULE="1 hour"
blazer_1d: bundle exec rake blazer:run_checks SCHEDULE="1 day"
blazer: bundle exec rake blazer:send_failing_checks
prometheus: bundle exec prometheus_exporter -b 0.0.0.0 --prefix "sutty_"
distributed_press_tokens_renew: bundle exec rake distributed_press:tokens:renew
cleanup: bundle exec rake cleanup:everything
stats: bundle exec rake stats:process_all
distributed_press_renew_tokens: bundle exec rake distributed_press:tokens:renew

View file

@ -25,6 +25,15 @@ $spacers: (
2-plus: 0.75rem
);
$sizes: (
"70ch": 70ch,
);
.btn {
background-color: var(--foreground);
color: var(--background);
}
@import "bootstrap";
@import "editor";
@ -154,6 +163,12 @@ ol.breadcrumb {
transition: all 3s;
}
fieldset {
legend {
font-size: 1rem;
}
}
.mapable,
.taggable {
.input-map,
@ -194,8 +209,6 @@ svg {
}
.btn {
background-color: var(--foreground);
color: var(--background);
border: none;
border-radius: 0;
margin-right: 0.3rem;
@ -373,6 +386,9 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
}
}
.word-break-all { word-break: break-all !important; }
.hyphens { hyphens: auto; }
/*
* Modificadores de Bootstrap que no tienen versión responsive.
*/
@ -395,6 +411,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
.text-#{$grid-breakpoint}-right { text-align: right !important; }
.text-#{$grid-breakpoint}-center { text-align: center !important; }
.word-break-#{$grid-breakpoint}-all { word-break: break-all !important; }
// posición
@each $position in $positions {
.position-#{$grid-breakpoint}-#{$position} { position: $position !important; }
@ -404,6 +422,8 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1);
@each $prop, $abbrev in (width: w, height: h) {
@each $size, $length in $sizes {
.#{$abbrev}-#{$grid-breakpoint}-#{$size} { #{$prop}: $length !important; }
.min-#{$abbrev}-#{$grid-breakpoint}-#{$size} { min-#{$prop}: $length !important; }
.max-#{$abbrev}-#{$grid-breakpoint}-#{$size} { max-#{$prop}: $length !important; }
}
}

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

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

View file

@ -9,44 +9,27 @@ module Api
# Lista de nombres de dominios a emitir certificados
def index
render json: sites_names + alternative_names + api_names
end
# Sitios con hidden service de Tor
#
# @return [Array] lista de nombres de sitios sin onion aun
def hidden_services
render json: DeployHiddenService.where(values: nil).includes(:site).pluck(:name)
end
# Tor va a enviar el onion junto con el nombre del sitio y tenemos
# que guardarlo en su deploy_hidden_service.
#
# @params [String] name
# @params [String] onion
def add_onion
site = Site.find_by(name: params[:name])
if site
usuarie = GitAuthor.new email: 'tor@' + Site.domain, name: 'Tor'
service = SiteService.new site: site, usuarie: usuarie,
params: params
service.add_onion
end
head :ok
render json: sites_names + alternative_names + api_names + www_names
end
private
def canonicalize(name)
name.end_with?('.') ? name[0..-2] : "#{name}.#{Site.domain}"
end
# Nombres de los sitios
def sites_names
Site.all.order(:name).pluck(:name)
Site.all.order(:name).pluck(:name).map do |name|
canonicalize name
end
end
# Dominios alternativos
def alternative_names
DeployAlternativeDomain.all.map(&:hostname)
(DeployAlternativeDomain.all.map(&:hostname) + DeployLocalizedDomain.all.map(&:hostname)).map do |name|
canonicalize name
end
end
# Obtener todos los sitios con API habilitada, es decir formulario
@ -56,7 +39,16 @@ module Api
def api_names
Site.where(contact: true)
.or(Site.where(colaboracion_anonima: true))
.select("'api.' || name as name").map(&:name)
.select("'api.' || name as name").map(&:name).map do |name|
canonicalize name
end
end
# Todos los dominios con WWW habilitado
def www_names
Site.where(id: DeployWww.all.pluck(:site_id)).select("'www.' || name as name").map(&:name).map do |name|
canonicalize name
end
end
end
end

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
@ -46,17 +56,19 @@ class ApplicationController < ActionController::Base
# defecto.
#
# Esto se refiere al idioma de la interfaz, no de los artículos.
def current_locale(include_params: true, site: nil)
return params[:locale] if include_params && params[:locale].present?
#
# @return [String,Symbol]
def current_locale
session[:locale] = params[:change_locale_to] if params[:change_locale_to].present?
current_usuarie&.lang || I18n.locale
session[:locale] || current_usuarie&.lang || I18n.locale
end
# El idioma es el preferido por le usuarie, pero no necesariamente se
# corresponde con el idioma de los artículos, porque puede querer
# traducirlos.
def set_locale(&action)
I18n.with_locale(current_locale(include_params: false), &action)
I18n.with_locale(current_locale, &action)
end
# Muestra una página 404
@ -79,13 +91,26 @@ class ApplicationController < ActionController::Base
breadcrumb 'stats.index', root_path, match: :exact
end
def site
@site ||= find_site
end
protected
def configure_permitted_parameters
devise_parameter_sanitizer.permit(:sign_up, keys: Usuarie::CONSENT_FIELDS)
devise_parameter_sanitizer.permit(:account_update, keys: %i[lang])
end
def prepare_exception_notifier
request.env['exception_notifier.exception_data'] = { usuarie: current_usuarie }
end
# Olvidar el idioma elegido antes de iniciar la sesión y reenviar a
# los sitios en el idioma de le usuarie.
def after_sign_in_path_for(resource)
session[:locale] = nil
sites_path
end
end

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

@ -12,7 +12,7 @@ class PostsController < ApplicationController
# Las URLs siempre llevan el idioma actual o el de le usuarie
def default_url_options
{ locale: current_locale }
{ locale: locale }
end
def index
@ -159,10 +159,6 @@ class PostsController < ApplicationController
end.transform_keys(&:to_sym)
end
def site
@site ||= find_site
end
def post
@post ||= site.posts(lang: locale).find(params[:post_id] || params[:id])
end

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

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

@ -0,0 +1,81 @@
import { Controller } from 'stimulus'
require("leaflet/dist/leaflet.css")
import L from 'leaflet'
delete L.Icon.Default.prototype._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: require('leaflet/dist/images/marker-icon-2x.png'),
iconUrl: require('leaflet/dist/images/marker-icon.png'),
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
})
export default class extends Controller {
static targets = [ 'lat', 'lng', 'map', 'overlay' ]
async connect () {
this.marker()
this.latTarget.addEventListener('change', event => this.marker())
this.lngTarget.addEventListener('change', event => this.marker())
window.addEventListener('resize', event => this.map.invalidateSize())
this.map.on('click', event => {
this.latTarget.value = event.latlng.lat
this.lngTarget.value = event.latlng.lng
this.latTarget.dispatchEvent(new Event('change'))
})
}
marker () {
if (this._marker) this.map.removeLayer(this._marker)
this._marker = L.marker(this.coords).addTo(this.map)
return this._marker
}
get lat () {
const lat = parseFloat(this.latTarget.value)
return isNaN(lat) ? 0 : lat
}
get lng () {
const lng = parseFloat(this.lngTarget.value)
return isNaN(lng) ? 0 : lng
}
get coords () {
return [this.lat, this.lng]
}
get bounds () {
return [
[0, 0],
[
this.svgOverlay.viewBox.baseVal.height,
this.svgOverlay.viewBox.baseVal.width,
]
];
}
get map () {
if (!this._map) {
this._map = L.map(this.mapTarget, {
minZoom: 0,
maxZoom: 5
}).setView(this.coords, 0);
this._layer = L.tileLayer(`${this.element.dataset.site}public/map/{z}/{y}/{x}.png`, {
minNativeZoom: 0,
maxNativeZoom: 5,
noWrap: true
}).addTo(this._map);
}
return this._map
}
}

View file

@ -103,11 +103,7 @@ export default class extends Controller {
this.reorder()
// Mantenemos el primero a la vista
if ("scrollIntoViewIfNeeded" in rows[0].row) {
rows[0].row.scrollIntoViewIfNeeded()
} else {
rows[0].row.scrollIntoView()
}
rows[0].row.scrollIntoView({ block: "center" });
}
counter () {
@ -146,7 +142,7 @@ export default class extends Controller {
this.reorder()
// Mantenemos el primero a la vista
rows[0].row.scrollIntoViewIfNeeded()
rows[0].row.scrollIntoView({ block: "center" });
}
bottom (event) {
@ -167,7 +163,7 @@ export default class extends Controller {
this.reorder()
// Mantenemos el primero a la vista
rows[0].row.scrollIntoViewIfNeeded()
rows[0].row.scrollIntoView({ block: "center" });
}
/*

View file

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

View file

@ -3,6 +3,8 @@
# Notifica excepciones a una instancia de Gitlab, como incidencias
# nuevas o como comentarios a las incidencias pre-existentes.
class GitlabNotifierJob < ApplicationJob
class GitlabNotifierError < StandardError; end
include ExceptionNotifier::BacktraceCleaner
# Variables que vamos a acceder luego
@ -14,26 +16,32 @@ class GitlabNotifierJob < ApplicationJob
# @param [Hash] opciones de ExceptionNotifier
def perform(exception, **options)
@exception = exception
@options = options
@options = fix_options options
@issue_data = { count: 1 }
# Necesitamos saber si el issue ya existía
@cached = false
@issue = {}
# Traemos los datos desde la caché si existen, sino generamos un
# issue nuevo e inicializamos la caché
@issue_data = Rails.cache.fetch(cache_key) do
issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
@issue = client.new_issue confidential: true, title: title, description: description, issue_type: 'incident'
@cached = true
{
count: 1,
issue: issue['iid'],
issue: @issue['iid'],
user_agents: [user_agent].compact,
params: [request&.filtered_parameters].compact,
urls: [url].compact
}
end
if @issue['iid'].blank? && issue_data[:issue].blank?
Rails.cache.delete(cache_key)
raise GitlabNotifierError, @issue.dig('message', 'title')&.join(', ')
end
# No seguimos actualizando si acabamos de generar el issue
return if cached
@ -53,9 +61,9 @@ class GitlabNotifierJob < ApplicationJob
Rails.cache.write(cache_key, issue_data)
# Si este trabajo genera una excepción va a entrar en un loop, así que
# la notificamos por correo
rescue Exception => e
email_notification.call(e)
email_notification.call(exception, options)
rescue StandardError => e
email_notification.call(e, data: @issue)
email_notification.call(exception, data: @options)
end
private
@ -76,10 +84,15 @@ class GitlabNotifierJob < ApplicationJob
exception.class.name,
Digest::SHA1.hexdigest(exception.message),
Digest::SHA1.hexdigest(backtrace&.first.to_s),
Digest::SHA1.hexdigest(options.dig(:data, :params, 'errors').to_s)
Digest::SHA1.hexdigest(errors.to_s)
].join('/')
end
# @return [Array]
def errors
options.dig(:data, :params, 'errors') || []
end
# Define si es una excepción de javascript o local
#
# @see BacktraceJob
@ -104,6 +117,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def description
@description ||= ''.dup.tap do |d|
d << log_section
d << request_section
d << javascript_section
d << javascript_footer
@ -117,6 +131,7 @@ class GitlabNotifierJob < ApplicationJob
# @return [String]
def body
@body ||= ''.dup.tap do |b|
b << log_section
b << request_section
b << javascript_footer
b << data_section
@ -151,6 +166,21 @@ class GitlabNotifierJob < ApplicationJob
@client ||= GitlabApiClient.new
end
# @return [String]
def log_section
return '' unless options.dig(:data, :log)
<<~LOG
# Build log
```
#{options[:data].delete(:log)}
```
LOG
end
# Muestra información de la petición
#
# @return [String]
@ -235,8 +265,8 @@ class GitlabNotifierJob < ApplicationJob
## Data
```
#{pp options[:data]}
```yaml
#{options[:data].to_yaml}
```
DATA
@ -257,4 +287,16 @@ class GitlabNotifierJob < ApplicationJob
def url
@url ||= request&.url || options.dig(:data, :params, 'context', 'url')
end
# Define llaves necesarias
#
# @param :options [Hash]
# @return [Hash]
def fix_options(options)
options = { data: options } unless options.is_a? Hash
options[:data] ||= {}
options[:data][:params] ||= {}
options
end
end

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
#
@ -20,6 +15,21 @@ module ActiveStorage
end
end
# Solo copiamos el archivo si no existe
#
# @param :key [String]
# @param :io [IO]
# @param :checksum [String]
def upload(key, io, checksum: nil, **)
instrument :upload, key: key, checksum: checksum do
unless exist?(key)
IO.copy_stream(io, make_path_for(key))
LfsObjectService.new(site: site, blob: blob_for(key)).process
end
ensure_integrity_of(key, checksum) if checksum
end
end
# Lo mismo que en DiskService agregando el nombre de archivo en la
# firma. Esto permite que luego podamos guardar el archivo donde
# corresponde.
@ -67,7 +77,9 @@ module ActiveStorage
# @param :key [String]
# @return [String]
def filename_for(key)
ActiveStorage::Blob.where(key: key).limit(1).pluck(:filename).first
blob_for(key).filename.to_s.tap do |filename|
raise ArgumentError, "Filename for key #{key} is blank" if filename.blank?
end
end
# Crea una ruta para la llave con un nombre conocido.
@ -77,6 +89,15 @@ module ActiveStorage
def path_for(key)
File.join root, folder_for(key), filename_for(key)
end
# @return [Site]
def site
@site ||= Site.find_by_name(name)
end
def blob_for(key)
ActiveStorage::Blob.find_by(key: key, service_name: name)
end
end
end
end

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

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

View file

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

View file

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

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

@ -12,12 +12,14 @@ class DeployLocal < Deploy
#
# Pasamos variables de entorno mínimas para no filtrar secretos de
# Sutty
def deploy
def deploy(output: false)
return false unless mkdir
return false unless yarn
return false unless bundle
return false unless git_lfs(output: output)
return false unless yarn(output: output)
return false unless pnpm(output: output)
return false unless bundle(output: output)
jekyll_build
jekyll_build(output: output)
end
# Sólo permitimos un deploy local
@ -25,6 +27,10 @@ class DeployLocal < Deploy
1
end
def url
site.url
end
# Obtener el tamaño de todos los archivos y directorios (los
# directorios son archivos :)
def size
@ -45,6 +51,17 @@ class DeployLocal < Deploy
File.join(Rails.root, '_deploy', site.hostname)
end
# Libera espacio eliminando archivos temporales
#
# @return [nil]
def cleanup!
FileUtils.rm_rf(gems_dir)
FileUtils.rm_rf(yarn_cache_dir)
FileUtils.rm_rf(File.join(site.path, 'node_modules'))
FileUtils.rm_rf(File.join(site.path, '.sass-cache'))
FileUtils.rm_rf(File.join(site.path, '.jekyll-cache'))
end
private
def mkdir
@ -52,27 +69,35 @@ class DeployLocal < Deploy
end
# Un entorno que solo tiene lo que necesitamos
#
# @return [Hash]
def env
# XXX: This doesn't support Windows paths :B
paths = [File.dirname(`which bundle`), '/usr/bin', '/bin']
paths = [File.dirname(`which bundle`), '/usr/local/bin', '/usr/bin', '/bin']
{
'HOME' => home_dir,
'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url,
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
'JEKYLL_ENV' => Rails.env,
'LANG' => ENV['LANG'],
'YARN_CACHE_FOLDER' => yarn_cache_dir
}
# Las variables de entorno extra no pueden superponerse al local.
extra_env.merge({
'HOME' => home_dir,
'PATH' => paths.join(':'),
'SPREE_API_KEY' => site.tienda_api_key,
'SPREE_URL' => site.tienda_url,
'AIRBRAKE_PROJECT_ID' => site.id.to_s,
'AIRBRAKE_PROJECT_KEY' => site.airbrake_api_key,
'JEKYLL_ENV' => Rails.env,
'LANG' => ENV['LANG'],
'YARN_CACHE_FOLDER' => yarn_cache_dir,
'GEMS_SOURCE' => ENV['GEMS_SOURCE']
})
end
def yarn_cache_dir
Rails.root.join('_yarn_cache').to_s
end
def pnpm_cache_dir
Rails.root.join('_pnpm_cache').to_s
end
def yarn_lock
File.join(site.path, 'yarn.lock')
end
@ -81,27 +106,43 @@ class DeployLocal < Deploy
File.exist? yarn_lock
end
def gem
run %(gem install bundler --no-document)
def pnpm_lock
File.join(site.path, 'pnpm-lock.yaml')
end
def pnpm_lock?
File.exist? pnpm_lock
end
def git_lfs(output: false)
run %(git lfs fetch), output: output
run %(git lfs checkout), output: output
end
def gem(output: false)
run %(gem install bundler --no-document), output: output
end
# Corre yarn dentro del repositorio
def yarn
def yarn(output: false)
return true unless yarn_lock?
run 'yarn install --production'
run 'yarn install --production', output: output
end
def bundle
if Rails.env.production?
run %(bundle install --no-cache --path="#{gems_dir}")
else
run %(bundle install)
end
def pnpm(output: false)
return true unless pnpm_lock?
run %(pnpm config set store-dir "#{pnpm_cache_dir}"), output: output
run 'pnpm install --production', output: output
end
def jekyll_build
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}")
def bundle(output: false)
run %(bundle install --deployment --no-cache --path="#{gems_dir}" --clean --without test development), output: output
end
def jekyll_build(output: false)
run %(bundle exec jekyll build --trace --profile --destination "#{escaped_destination}"), output: output
end
# no debería haber espacios ni caracteres especiales, pero por si
@ -114,4 +155,18 @@ class DeployLocal < Deploy
def remove_destination!
FileUtils.rm_rf destination
end
# Consigue todas las variables de entorno configuradas por otros
# deploys.
#
# @deprecated Solo tenía sentido para Distributed Press v0
# @return [Hash]
def extra_env
@extra_env ||=
non_local_deploys.reduce({}) do |extra_env, deploy|
extra_env.tap do |e|
e.merge! deploy.local_env
end
end
end
end

View file

@ -0,0 +1,12 @@
# frozen_string_literal: true
# Soportar dominios localizados
class DeployLocalizedDomain < DeployAlternativeDomain
store :values, accessors: %i[hostname locale], coder: JSON
# Generar un link simbólico del sitio principal al alternativo
def deploy(**)
File.symlink?(destination) ||
File.symlink(File.join(site.hostname, locale), destination).zero?
end
end

View file

@ -6,9 +6,11 @@
# XXX: La plantilla tiene que soportar esto con el plugin
# jekyll-private-data
class DeployPrivate < DeployLocal
DEPENDENCIES = %i[deploy_local]
# No es necesario volver a instalar dependencias
def deploy
jekyll_build
def deploy(output: false)
jekyll_build(output: output)
end
# Hacer el deploy a un directorio privado
@ -16,6 +18,10 @@ class DeployPrivate < DeployLocal
File.join(Rails.root, '_private', site.name)
end
def url
"#{ENV['PANEL_URL']}/sites/private/#{site.name}"
end
# No usar recursos en compresión y habilitar los datos privados
def env
@env ||= super.merge({

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
# Reindexa los artículos al terminar la compilación
class DeployReindex < Deploy
def deploy(**)
time_start
site.reset
Site.transaction do
site.indexed_posts.destroy_all
site.index_posts!
end
time_stop
build_stats.create action: 'reindex',
log: 'Reindex',
seconds: time_spent_in_seconds,
bytes: size,
status: true
site.touch
end
def size
0
end
def limit
1
end
def hostname; end
def url; end
def destination; end
end

View file

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

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
@ -27,6 +31,10 @@ class DeployWww < Deploy
"www.#{site.hostname}"
end
def url
"https://#{fqdn}/"
end
private
def remove_destination!

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,15 +62,33 @@ class DeployZip < Deploy
File.size path
end
# @return [String]
def destination
File.join(Rails.root, '_deploy', site.hostname)
Rails.root.join('_deploy', site.hostname).realpath.to_s
rescue Errno::ENOENT
Rails.root.join('_deploy', site.hostname).to_s
end
def file
"#{site.hostname}.zip"
end
def url
"#{site.url}#{file}"
end
def path
File.join(destination, file)
end
private
# @param :line [String]
# @param :output [Boolean]
def log(line, output)
@log ||= []
@log << line
puts line if output
end
end

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

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

View file

@ -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,3 @@
# frozen_string_literal: true
class MetadataNonGeo < MetadataGeo; end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
# Almacena una contraseña
class MetadataPassword < MetadataString
# Las contraseñas no son indexables
#
# @return [boolean]
def indexable?
false
end
private
alias_method :original_sanitize, :sanitize
# Sanitizar la string y generar un hash Bcrypt
#
# @param :string [String]
# @return [String]
def sanitize(string)
string = original_sanitize string
::BCrypt::Password.create(string).to_s
end
end

View file

@ -2,12 +2,6 @@
# Este metadato permite generar rutas manuales.
class MetadataPermalink < MetadataString
# El valor por defecto una vez creado es la URL que le asigne Jekyll,
# de forma que nunca cambia aunque se cambie el título.
def default_value
document.url.sub(%r{\A/}, '') unless post.new?
end
# Los permalinks nunca pueden ser privados
def private?
false

View file

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

View file

@ -134,7 +134,11 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type,
# En caso de que algún campo necesite realizar acciones antes de ser
# guardado
def save
return true unless changed?
if !changed?
self[:value] = document_value if private?
return true
end
self[:value] = sanitize value
self[:value] = encrypt(value) if private?

View file

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

View file

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

View file

@ -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
@ -54,10 +57,6 @@ class Site < ApplicationRecord
before_create :clone_skel!
# Elimina el directorio al destruir un sitio
before_destroy :remove_directories!
# Carga el sitio Jekyll una vez que se inicializa el modelo o después
# de crearlo
after_initialize :load_jekyll
after_create :load_jekyll
# Cambiar el nombre del directorio
before_update :update_name!
before_save :add_private_key_if_missing!
@ -183,10 +182,20 @@ class Site < ApplicationRecord
# Siempre tiene que tener algo porque las traducciones están
# incorporadas a los sitios de Sutty, aunque les usuaries no traduzcan
# sus sitios.
#
# @return [Array]
def locales
@locales ||= config.fetch('locales', I18n.available_locales).map(&:to_sym)
end
# Modificar los locales disponibles
#
# @param :new_locales [Array]
# @return [Array]
def locales=(new_locales)
@locales = new_locales.map(&:to_sym).uniq
end
# Similar a site.i18n en jekyll-locales
#
# @return [Hash]
@ -254,6 +263,8 @@ class Site < ApplicationRecord
layout = layouts[Post.find_layout(doc.path)]
@posts[lang].build(document: doc, layout: layout, lang: lang)
rescue TypeError => e
ExceptionNotifier.notify_exception(e, data: { site: name, site_id: id, path: doc.path })
end
@posts[lang]
@ -355,10 +366,19 @@ class Site < ApplicationRecord
status == 'building'
end
def jekyll?
File.directory? path
end
def jekyll
run_in_path do
@jekyll ||= Jekyll::Site.new(configuration)
end
@jekyll ||=
begin
install_gems
Jekyll::Site.new(configuration).tap do |site|
site.reader = JekyllData::Reader.new(site) if site.theme
end
end
end
# Cargar el sitio Jekyll
@ -404,9 +424,6 @@ class Site < ApplicationRecord
@configuration[unneeded] = [] if @configuration.key? unneeded
end
# Eliminar el theme si no es una gema válida
@configuration.delete('theme') unless theme_available?
# Si estamos usando nuestro propio plugin de i18n, los posts están
# en "colecciones"
locales.map(&:to_s).each do |i|
@ -416,20 +433,6 @@ class Site < ApplicationRecord
@configuration
end
# Lista los nombres de las plantillas disponibles como gemas,
# tomándolas dinámicamente de las que agreguemos en el grupo :themes
# del Gemfile.
def available_themes
@available_themes ||= Bundler.load.current_dependencies.select do |gem|
gem.groups.include? :themes
end.map(&:name)
end
# Detecta si el tema actual es una gema
def theme_available?
available_themes.include? design&.gem
end
# Devuelve el dominio actual
def self.domain
ENV.fetch('SUTTY', 'sutty.nl')
@ -437,7 +440,7 @@ class Site < ApplicationRecord
# El directorio donde se almacenan los sitios
def self.site_path
@site_path ||= ENV.fetch('SITE_PATH', Rails.root.join('_sites'))
@site_path ||= File.realpath(ENV.fetch('SITE_PATH', Rails.root.join('_sites')))
end
def self.default
@ -468,7 +471,7 @@ class Site < ApplicationRecord
# Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada
# si el sitio ya existe
def clone_skel!
return if File.directory? path
return if jekyll?
Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path
end
@ -496,6 +499,7 @@ class Site < ApplicationRecord
config.title = title
config.url = url(slash: false)
config.hostname = hostname
config.locales = locales.map(&:to_s)
end
# Valida si el sitio tiene al menos una forma de alojamiento asociada
@ -551,4 +555,36 @@ class Site < ApplicationRecord
def run_in_path(&block)
Dir.chdir path, &block
end
# Instala las gemas cuando es necesario:
#
# * El sitio existe
# * No están instaladas
# * El archivo Gemfile se modificó
# * El archivo Gemfile.lock se modificó
def install_gems
return unless persisted?
deploys.find_by_type('DeployLocal').send(:git_lfs)
if !gem_dir? || gemfile_updated? || gemfile_lock_updated?
deploys.find_by_type('DeployLocal').send(:bundle)
touch
end
end
# Detecta si el repositorio de gemas existe
def gem_dir?
Rails.root.join('_storage', 'gems', name).directory?
end
# Detecta si el Gemfile fue modificado
def gemfile_updated?
updated_at < File.mtime(File.join(path, 'Gemfile'))
end
# Detecta si el Gemfile.lock fue modificado
def gemfile_lock_updated?
updated_at < File.mtime(File.join(path, 'Gemfile.lock'))
end
end

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

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

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

View file

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

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
@ -9,6 +11,10 @@ class Usuarie < ApplicationRecord
validates_uniqueness_of :email
validates_with EmailAddress::ActiveRecordValidator, field: :email
before_create :lang_from_locale!
before_update :remove_confirmation_invitation_inconsistencies!
before_update :accept_invitation_after_confirmation!
has_many :roles
has_many :sites, through: :roles
has_many :blazer_audits, foreign_key: 'user_id', class_name: 'Blazer::Audit'
@ -38,4 +44,38 @@ class Usuarie < ApplicationRecord
increment_failed_attempts
lock_access! if attempts_exceeded? && !access_locked?
end
def send_devise_notification(notification, *args)
I18n.with_locale(lang) do
devise_mailer.send(notification, self, *args).deliver_later
end
end
# Les usuaries necesitan link de invitación si no tenían cuenta
# y todavía no aceptaron la invitación anterior.
def needs_invitation_link?
created_by_invite? && !invitation_accepted?
end
private
def lang_from_locale!
self.lang = I18n.locale.to_s
end
# El invitation_token solo es necesario cuando fue creade por otre
# usuarie. De lo contrario lo que queremos es un proceso de
# confirmación.
def remove_confirmation_invitation_inconsistencies!
self.invitation_token = nil unless created_by_invite?
end
# Si le usuarie (re)confirma su cuenta con una invitación pendiente,
# considerarla aceptada también.
def accept_invitation_after_confirmation!
if confirmed?
self.invitation_token = nil
self.invitation_accepted_at ||= Time.now.utc
end
end
end

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,47 @@
# frozen_string_literal: true
# Realiza tareas de limpieza en todos los sitios, para optimizar y
# liberar espacio.
class CleanupService
# Días de antigüedad de los sitios
attr_reader :before
# @param :before [ActiveSupport::TimeWithZone] Cuánto tiempo lleva sin usarse un sitio.
def initialize(before: 30.days.ago)
@before = before
end
# Limpieza general
#
# @return [nil]
def cleanup_everything!
cleanup_older_sites!
cleanup_newer_sites!
end
# Encuentra todos los sitios sin actualizar y realiza limpieza.
#
# @return [nil]
def cleanup_older_sites!
Site.where('updated_at < ?', before).find_each do |site|
next unless File.directory? site.path
site.deploys.find_each(&:cleanup!)
site.repository.gc
site.touch
end
end
# Tareas para los sitios en uso
#
# @return [nil]
def cleanup_newer_sites!
Site.where('updated_at >= ?', before).find_each do |site|
next unless File.directory? site.path
site.repository.gc
site.touch
end
end
end

View file

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

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

View file

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

View file

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

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

View file

@ -1,17 +1,21 @@
%h1= t('.hi')
%h1= @hi
= sanitize_markdown t('.explanation', fqdn: @deploy_local.site.hostname),
tags: %w[p a strong em]
= sanitize_markdown @explanation, tags: %w[p a strong em]
%table
%thead
%tr
%th= t('.th.type')
%th= t('.th.status')
- @headers.each do |header|
%th= header
%tbody
- @deploys.each do |deploy, value|
%tr
%td= t(".#{deploy}.title")
%td= value ? t(".#{deploy}.success") : t(".#{deploy}.error")
- @table.each do |row|
- row[:urls].each do |url|
%tr
%td= row[:title]
%td= row[:status]
%td= link_to_if url.present?, url, url
%td
%time{ datetime: row[:seconds][:machine] }= row[:seconds][:human]
%td= row[:size]
= sanitize_markdown t('.help'), tags: %w[p a strong em]
= sanitize_markdown @help, tags: %w[p a strong em]

View file

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

View file

@ -0,0 +1,21 @@
-# Publicar a la web distribuida
.row
.col
= deploy.hidden_field :id
= deploy.hidden_field :type
.custom-control.custom-switch
-#
El checkbox invierte la lógica de destrucción porque queremos
crear el deploy si está activado y destruirlo si está
desactivado.
= deploy.check_box :_destroy,
{ checked: deploy.object.persisted?, class: 'custom-control-input' },
'0', '1'
= deploy.label :_destroy, class: 'custom-control-label' do
%h3= t('.title')
= sanitize_markdown t('.help', public_url: deploy.object.site.url),
tags: %w[p strong em a]
%hr/

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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)
\
- 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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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