From 166b549d63f44cd9b1f8cd417b9f4df2cb43eb7e Mon Sep 17 00:00:00 2001 From: f Date: Wed, 10 Jul 2019 19:50:24 -0300 Subject: [PATCH 01/44] oops --- app/views/devise/mailer/invitation_instructions.haml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/views/devise/mailer/invitation_instructions.haml b/app/views/devise/mailer/invitation_instructions.haml index 74551234..bd0f144f 100644 --- a/app/views/devise/mailer/invitation_instructions.haml +++ b/app/views/devise/mailer/invitation_instructions.haml @@ -1,4 +1,3 @@ -- binding.pry %p= t("devise.mailer.invitation_instructions.hello", email: @resource.email) %p= t("devise.mailer.invitation_instructions.someone_invited_you", url: @resource.sites.first.name) %p= link_to t("devise.mailer.invitation_instructions.accept"), accept_invitation_url(@resource, invitation_token: @token) From 237cfb4a5e6f092698e6bd1f9df61037c57a573f Mon Sep 17 00:00:00 2001 From: f Date: Thu, 11 Jul 2019 16:00:28 -0300 Subject: [PATCH 02/44] =?UTF-8?q?WIP=20creaci=C3=B3n=20de=20sitios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 1 + Gemfile | 7 ++ Gemfile.lock | 14 +++ app/models/site.rb | 2 + .../20190711183726_add_unique_to_site_name.rb | 9 ++ db/schema.rb | 4 +- doc/crear_sitios.md | 107 ++++++++++++++++++ test/factories/rol.rb | 14 +++ test/factories/site.rb | 7 ++ test/factories/usuarie.rb | 9 ++ test/test_helper.rb | 5 + 11 files changed, 177 insertions(+), 2 deletions(-) create mode 100644 db/migrate/20190711183726_add_unique_to_site_name.rb create mode 100644 doc/crear_sitios.md create mode 100644 test/factories/rol.rb create mode 100644 test/factories/site.rb create mode 100644 test/factories/usuarie.rb diff --git a/.env.example b/.env.example index cf01e616..2661f007 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,4 @@ SECRET_KEY_BASE= IMAP_SERVER= DEFAULT_FROM= DEVISE_PEPPER= +SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl diff --git a/Gemfile b/Gemfile index f9edac69..dbf7ad1a 100644 --- a/Gemfile +++ b/Gemfile @@ -53,6 +53,8 @@ gem 'mini_magick' gem 'pundit' gem 'rails-i18n' gem 'rails_warden' +gem 'rugged' +gem 'validates_hostname' gem 'whenever', require: false group :development, :test do @@ -82,3 +84,8 @@ group :development do gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' end + +group :test do + gem 'database_cleaner' + gem 'factory_bot_rails' +end diff --git a/Gemfile.lock b/Gemfile.lock index 5be9949d..f9da67dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -98,6 +98,7 @@ GEM ruby-enum (~> 0.5) concurrent-ruby (1.1.5) crass (1.0.4) + database_cleaner (1.7.0) devise (4.6.2) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -127,6 +128,11 @@ GEM actionmailer (>= 4.0, < 6) activesupport (>= 4.0, < 6) execjs (2.7.0) + factory_bot (5.0.2) + activesupport (>= 4.2.0) + factory_bot_rails (5.0.2) + factory_bot (~> 5.0.2) + railties (>= 4.2.0) fastimage (2.1.5) ffi (1.11.1) font-awesome-rails (4.7.0.4) @@ -286,6 +292,7 @@ GEM ruby_parser (3.13.1) sexp_processor (~> 4.9) rubyzip (1.2.2) + rugged (0.28.2) safe_yaml (1.0.5) sass (3.7.4) sass-listen (~> 4.0.0) @@ -344,6 +351,9 @@ GEM unf_ext unf_ext (0.0.7.5) unicode-display_width (1.5.0) + validates_hostname (1.0.8) + activerecord (>= 3.0) + activesupport (>= 3.0) warden (1.2.8) rack (>= 2.0.6) web-console (3.7.0) @@ -376,6 +386,7 @@ DEPENDENCIES carrierwave-bombshelter carrierwave-i18n commonmarker + database_cleaner devise devise-i18n devise_invitable @@ -383,6 +394,7 @@ DEPENDENCIES ed25519 email_address exception_notification + factory_bot_rails font-awesome-rails friendly_id haml-rails @@ -400,6 +412,7 @@ DEPENDENCIES rails_warden rbnacl (< 5.0) rubocop + rugged sass-rails (~> 5.0) selenium-webdriver spring @@ -407,6 +420,7 @@ DEPENDENCIES sqlite3 (~> 1.3.6) turbolinks (~> 5) uglifier (>= 1.3.0) + validates_hostname web-console (>= 3.3.0) whenever diff --git a/app/models/site.rb b/app/models/site.rb index bb85d2af..a7d68699 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -5,6 +5,8 @@ class Site < ApplicationRecord include FriendlyId + validates :name, uniqueness: true, hostname: true + friendly_id :name, use: %i[finders] has_many :roles diff --git a/db/migrate/20190711183726_add_unique_to_site_name.rb b/db/migrate/20190711183726_add_unique_to_site_name.rb new file mode 100644 index 00000000..8fd5a9f0 --- /dev/null +++ b/db/migrate/20190711183726_add_unique_to_site_name.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Los nombres de los sitios son únicos +class AddUniqueToSiteName < ActiveRecord::Migration[5.2] + def change + remove_index :sites, :name + add_index :sites, :name, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6e3c298f..c01df6a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_706_002_615) do +ActiveRecord::Schema.define(version: 20_190_711_183_726) do create_table 'roles', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false @@ -29,7 +29,7 @@ ActiveRecord::Schema.define(version: 20_190_706_002_615) do t.datetime 'created_at', null: false t.datetime 'updated_at', null: false t.string 'name' - t.index ['name'], name: 'index_sites_on_name' + t.index ['name'], name: 'index_sites_on_name', unique: true end create_table 'usuaries', force: :cascade do |t| diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md new file mode 100644 index 00000000..f953d88f --- /dev/null +++ b/doc/crear_sitios.md @@ -0,0 +1,107 @@ +# Crear sitios + +Para que les usuaries puedan crear sitios, vamos a tener el siguiente +flujo: + +* Nombre del sitio, en minúsculas, sin puntos. + +* Descripción, una descripción de qué hace el sitio para Sutty y otres + usuaries del sitio. + +* Plantilla. Lista de plantillas disponibles, con captura, nombre, link + a vista previa, link a autore, licencia y usos posibles (blogs, medios + alternativos, denuncias, etc.) + + Tengo mi propio diseño! Explicar que vamos a dar esta posibilidad más + adelante, pero que necesitamos financiamiento. Esto permitiría + agregar un repositorio o gema de plantilla. + +* Licencia. Elegir la licencia del contenido de un listado con + licencias piolas: + + * PPL + * CC-BY + * CC-BY-SA + * CC-0 + +* Lugares donde se van a subir los sitios. Por defecto nombre.sutty.nl, + otras opciones en un desplegable: + + * Tengo mi propio dominio: Explica que todavía no tenemos esto + autogestionado pero que si quieren apoyar el trabajo que hacemos + pueden donarnos o ponerse en contacto. + + Más adelante pide los dominios, explica cómo comprarlos en njal.la y + qué información poner los DNS para poder alojarlos. Cuando Sutty + tenga su propio DNS, indica los NS. + + * Neocities: Explicar qué es neocities y permitir agregar una cuenta. + Podríamos varias pero queremos estar bien con neocities y además no + tiene mucho sentido tener varias páginas en el mismo host. + + Pedir usuarie y contraseña o API key. Dar link directo a dónde + sacar la API key y explicar para qué es. + + **En realidad esta sería nuestra primera opción?** + + * Zip: Descargar el sitio en un archivo zip, proponiendo posibles usos + offline (raspberries, etc!) + + Da una URL desde donde se puede descargar el sitio. + + * SSH/SFTP: Explicar que permite enviar el sitio a servidores propios. + Permite agregar varios servidores, pide usuario, dominio y puerto. + Da la llave pública SSH de Sutty y explica cómo agregarla al + servidor remoto. También da un link de descarga para que puedan + hacer ssh-copy-id. + + **Esto todavía no** + + * IPFS: Da la opción de activar/desactivar soporte para IPFS. + Explica qué es y para qué sirve. Vincula a documentación sobre + instalar IPFS de escritorio y cómo pinear el hash de sutty, instalar + el companion, etc. + + **Esto todavía no** + + * Torrent: Genera un torrent y lo siembra. + + **Esto todavía no** + + * Syncthing: Explica qué es y da la ID del nodo introductor de Sutty. + + **Esto todavía no** + + * Zeronet: Idem syncthing? + + **Esto todavía no** + + * Archive.org + + **Esto todavía no** + +* Crear sitio! + +## Sitios + +Tenemos un sitio esqueleto que tiene lo básico para salir andando: + +* Gemas de Jekyll +* Gemas de Sutty +* Gemas de Temas +* Esqueleto de la configuración +* Directorios base (con i18n también) + +El sitio esqueleto es un repositorio Git que se clona al directorio del +sitio. Esto permite luego pullear actualizaciones desde el esqueleto a +los sitios, esperamos que sin conflictos! + +## Plantillas + +Las plantillas son plantillas Jekyll adaptadas a Sutty. Vamos a empezar +adaptando las que estén disponibles en y +otras fuentes, agregando features de Sutty y simplificando donde haga +falta (algunas plantillas tienen requisitos extraños). + +Las plantillas se instalan como gemas en los sitios, de forma que +podemos cambiarla desde las opciones del sitio luego. diff --git a/test/factories/rol.rb b/test/factories/rol.rb new file mode 100644 index 00000000..103f12a3 --- /dev/null +++ b/test/factories/rol.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :rol do + usuarie + site + rol { 'usuarie' } + temporal { false } + + factory :rol_invitade do + rol { 'invitade' } + end + end +end diff --git a/test/factories/site.rb b/test/factories/site.rb new file mode 100644 index 00000000..7947e8af --- /dev/null +++ b/test/factories/site.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :site do + name { SecureRandom.hex } + end +end diff --git a/test/factories/usuarie.rb b/test/factories/usuarie.rb new file mode 100644 index 00000000..18a4042e --- /dev/null +++ b/test/factories/usuarie.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :usuarie do + email { SecureRandom.hex + '@sutty.nl' } + password { SecureRandom.hex } + confirmed_at { Date.today } + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 8a5709ef..49ac63e7 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,8 +4,12 @@ require File.expand_path('../config/environment', __dir__) require 'rails/test_help' require 'open3' +# rubocop:disable Style/ClassAndModuleChildren class ActiveSupport::TestCase + include FactoryBot::Syntax::Methods # Resetear el repositorio a su estado original antes de leerlo + # + # TODO mover a Site.reset! cuando empecemos a trabajar con git def reset_git_repo(path) Dir.chdir(path) do Open3.popen3('git reset --hard') do |_, _, _, thread| @@ -15,3 +19,4 @@ class ActiveSupport::TestCase end end end +# rubocop:enable Style/ClassAndModuleChildren From 3171ca7177b7ab632d69b6492f3c6abd793dc48b Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 14:11:24 -0300 Subject: [PATCH 03/44] serializar booleanos en sqlite3 --- config/application.rb | 15 +++++++---- db/migrate/20190703200455_create_sitios.rb | 9 +++++-- db/migrate/20190712165059_sqlite_boolean.rb | 28 +++++++++++++++++++++ db/schema.rb | 2 +- 4 files changed, 46 insertions(+), 8 deletions(-) create mode 100644 db/migrate/20190712165059_sqlite_boolean.rb diff --git a/config/application.rb b/config/application.rb index 2cc92e8e..579177e0 100644 --- a/config/application.rb +++ b/config/application.rb @@ -19,13 +19,18 @@ require 'rails/test_unit/railtie' Bundler.require(*Rails.groups) module Sutty + # Sutty! class Application < Rails::Application - # Initialize configuration defaults for originally generated Rails version. + # Initialize configuration defaults for originally generated Rails + # version. config.load_defaults 5.1 - # Settings in config/environments/* take precedence over those specified here. - # Application configuration should go into files in config/initializers - # -- all .rb files in that directory are automatically loaded. - config.action_dispatch.rescue_responses['Pundit::NotAuthorizedError'] = :forbidden + # Settings in config/environments/* take precedence over those + # specified here. Application configuration should go into files in + # config/initializers -- all .rb files in that directory are + # automatically loaded. + config.action_dispatch + .rescue_responses['Pundit::NotAuthorizedError'] = :forbidden + config.active_record.sqlite3.represent_boolean_as_integer = true end end diff --git a/db/migrate/20190703200455_create_sitios.rb b/db/migrate/20190703200455_create_sitios.rb index c8e45d34..7f8ae8fc 100644 --- a/db/migrate/20190703200455_create_sitios.rb +++ b/db/migrate/20190703200455_create_sitios.rb @@ -44,7 +44,10 @@ class CreateSitios < ActiveRecord::Migration[5.2] usuarie ||= Usuarie.create(email: email, password: SecureRandom.hex, confirmed_at: Date.today) - site.usuaries << usuarie + + sql = "insert into sites_usuaries (site_id, usuarie_id) + values (#{site.id}, #{usuarie.id});" + ActiveRecord::Base.connection.execute(sql) end invitadxs.each do |email| @@ -52,7 +55,9 @@ class CreateSitios < ActiveRecord::Migration[5.2] usuarie ||= Usuarie.create(email: email, password: SecureRandom.hex, confirmed_at: Date.today) - site.invitades << usuarie + sql = "insert into invitades_sites (site_id, usuarie_id) + values (#{site.id}, #{usuarie.id});" + ActiveRecord::Base.connection.execute(sql) end end end diff --git a/db/migrate/20190712165059_sqlite_boolean.rb b/db/migrate/20190712165059_sqlite_boolean.rb new file mode 100644 index 00000000..7f41afd8 --- /dev/null +++ b/db/migrate/20190712165059_sqlite_boolean.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Convertir los valores binarios de sqlite +class SqliteBoolean < ActiveRecord::Migration[5.2] + def up + return unless adapter_name == 'SQLite' + + Usuarie.where("acepta_politicas_de_privacidad = 't'") + .update_all(acepta_politicas_de_privacidad: 1) + Usuarie.where("acepta_politicas_de_privacidad = 'f'") + .update_all(acepta_politicas_de_privacidad: 0) + + change_column :usuaries, :acepta_politicas_de_privacidad, :boolean, + default: 0 + end + + def down + return unless adapter_name == 'SQLite' + + Usuarie.where('acepta_politicas_de_privacidad = 1') + .update_all(acepta_politicas_de_privacidad: 't') + Usuarie.where('acepta_politicas_de_privacidad = 0') + .update_all(acepta_politicas_de_privacidad: 'f') + + change_column :usuaries, :acepta_politicas_de_privacidad, :boolean, + default: 'f' + end +end diff --git a/db/schema.rb b/db/schema.rb index c01df6a3..98e398c9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_711_183_726) do +ActiveRecord::Schema.define(version: 20_190_712_165_059) do create_table 'roles', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false From 4b87501709473b1b6302c99044968b1d35a51579 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 14:13:51 -0300 Subject: [PATCH 04/44] usar rubocop-rails --- .rubocop.yml | 4 ---- Gemfile | 2 +- Gemfile.lock | 23 ++++++++++++----------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index a42adca8..f9765ba7 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -45,10 +45,6 @@ Metrics/ClassLength: - 'app/models/site.rb' - 'app/controllers/posts_controller.rb' -Performance/TimesMap: - Exclude: - - 'app/models/site.rb' - Lint/HandleExceptions: Exclude: - 'app/controllers/posts_controller.rb' diff --git a/Gemfile b/Gemfile index dbf7ad1a..3bf8e931 100644 --- a/Gemfile +++ b/Gemfile @@ -80,7 +80,7 @@ group :development do gem 'ed25519' gem 'letter_opener' gem 'rbnacl', '< 5.0' - gem 'rubocop' + gem 'rubocop-rails' gem 'spring' gem 'spring-watcher-listen', '~> 2.0.0' end diff --git a/Gemfile.lock b/Gemfile.lock index f9da67dd..872f4c9e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -159,7 +159,7 @@ GEM http_parser.rb (0.6.0) i18n (0.9.5) concurrent-ruby (~> 1.0) - jaro_winkler (1.5.2) + jaro_winkler (1.5.3) jbuilder (2.8.0) activesupport (>= 4.2.0) multi_json (>= 1.2) @@ -220,8 +220,8 @@ GEM nokogiri (1.10.3) mini_portile2 (~> 2.4.0) orm_adapter (0.5.0) - parallel (1.16.0) - parser (2.6.2.0) + parallel (1.17.0) + parser (2.6.3.0) ast (~> 2.4.0) pathutil (0.16.2) forwardable-extended (~> 2.6) @@ -229,7 +229,6 @@ GEM pry (0.12.2) coderay (~> 1.1.0) method_source (~> 0.9.0) - psych (3.1.0) public_suffix (3.0.3) puma (3.12.1) pundit (2.0.1) @@ -277,17 +276,19 @@ GEM actionpack (>= 5.0) railties (>= 5.0) rouge (3.3.0) - rubocop (0.66.0) + rubocop (0.72.0) jaro_winkler (~> 1.5.1) parallel (~> 1.10) - parser (>= 2.5, != 2.5.1.1) - psych (>= 3.1.0) + parser (>= 2.6) rainbow (>= 2.2.2, < 4.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 1.4.0, < 1.6) + unicode-display_width (>= 1.4.0, < 1.7) + rubocop-rails (2.2.0) + rack (>= 1.1) + rubocop (>= 0.72.0) ruby-enum (0.7.2) i18n - ruby-progressbar (1.10.0) + ruby-progressbar (1.10.1) ruby_dep (1.5.0) ruby_parser (3.13.1) sexp_processor (~> 4.9) @@ -350,7 +351,7 @@ GEM unf (0.1.4) unf_ext unf_ext (0.0.7.5) - unicode-display_width (1.5.0) + unicode-display_width (1.6.0) validates_hostname (1.0.8) activerecord (>= 3.0) activesupport (>= 3.0) @@ -411,7 +412,7 @@ DEPENDENCIES rails-i18n rails_warden rbnacl (< 5.0) - rubocop + rubocop-rails rugged sass-rails (~> 5.0) selenium-webdriver From 1c18064a247a4edc6d8ef57fc8f152c1bd13cc80 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 15:22:37 -0300 Subject: [PATCH 05/44] crear sitios clonando el repositorio skel --- app/models/site.rb | 16 +++++++- test/models/site_test.rb | 84 ++++++---------------------------------- 2 files changed, 26 insertions(+), 74 deletions(-) diff --git a/app/models/site.rb b/app/models/site.rb index a7d68699..71cb7bc9 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -15,8 +15,12 @@ class Site < ApplicationRecord has_many :invitades, -> { where('roles.rol = ?', 'invitade') }, through: :roles, source: :usuarie - # Carga el sitio Jekyll una vez que se inicializa el modelo + # Clonar el directorio de esqueleto antes de crear el sitio + before_create :clone_skel! + # Carga el sitio Jekyll una vez que se inicializa el modelo o después + # de crearlo after_initialize :load_jekyll! + after_create :load_jekyll! attr_accessor :jekyll, :collections @@ -343,8 +347,18 @@ class Site < ApplicationRecord private + # 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 + + Rugged::Repository.clone_at ENV['SKEL_SUTTY'], path + end + # Carga el sitio Jekyll def load_jekyll! + return unless name + Dir.chdir(path) do @jekyll ||= Site.load_jekyll(Dir.pwd) end diff --git a/test/models/site_test.rb b/test/models/site_test.rb index ce6e4750..98e9940f 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -3,81 +3,19 @@ require 'test_helper' class SiteTest < ActiveSupport::TestCase - setup do - @user = Usuaria.find('f@kefir.red') - @path = File.join(@user.path, 'cyber-women.com') - reset_git_repo(@path) - @site = @user.sites.select { |s| s.name == 'cyber-women.com' }.first - @site.read + test 'se puede crear un sitio' do + site = create :site + + assert site.valid? + # TODO: Mover a la validación del sitio o hacer algo similar + assert File.directory?(site.path) + assert File.directory?(File.join(site.path, '.git')) end - test 'El directorio es un sitio jekyll' do - assert Site.jekyll?(@path) - end + test 'se puede leer un sitio' do + site = create :site, name: 'sutty.nl' - test 'Un directorio se puede cargar como un sitio Jekyll' do - jekyll = Site.load_jekyll @path - - assert_equal Jekyll::Site, jekyll.class - end - - test 'Los artículos no están ordenados si a alguno le falta orden' do - assert_not @site.ordered? - assert @site.reorder_collection! - assert @site.ordered? - end - - test 'No podemos poner órdenes arbitrarios' do - total = @site.posts.count - new_order = Hash[total.times.map { |i| [i.to_s, rand(total)] }] - - assert_not @site.reorder_collection('posts', new_order) - end - - test 'Si les damos un orden alternativo los reordenamos' do - total = @site.posts.count - order = total.times.map(&:to_s) - new_order = Hash[order.zip(order.shuffle)] - - assert @site.reorder_collection('posts', new_order) - - # podemos hacer este test porque reordenar los posts no ordena el - # array - new_order.each do |k, v| - v = v.to_i - k = k.to_i - assert_equal v, @site.posts[k].order - end - end - - test 'Podemos reordenar solo una parte de los artículos' do - total = @site.posts.count - order = (total - rand(total - 1)).times.map(&:to_s) - new_order = Hash[order.zip(order.shuffle)] - - assert @site.reorder_collection('posts', new_order) - - # podemos hacer este test porque reordenar los posts no ordena el - # array - new_order.each do |k, v| - v = v.to_i - k = k.to_i - assert_equal v, @site.posts[k].order - end - end - - test 'Un sitio tiene traducciones' do - assert_equal %w[ar es en], @site.translations - assert @site.i18n? - end - - test 'El idioma por defecto es el idioma actual de la plataforma' do - assert_equal 'es', @site.default_lang - end - - test 'El sitio tiene layouts' do - assert_equal %w[anexo archive default feed header.ar header.en - header.es header license.ar license.en license.es license pandoc - politicas sesion simple style ].sort, @site.layouts + assert site.valid? + assert !site.posts.empty? end end From afb68bfcbd9bdb7042172ccb00c1928a1cc9093c Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 15:34:16 -0300 Subject: [PATCH 06/44] se pueden eliminar sitios --- app/models/site.rb | 6 ++++++ test/factories/site.rb | 2 +- test/models/site_test.rb | 7 +++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/app/models/site.rb b/app/models/site.rb index 71cb7bc9..d2e7d0b9 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -17,6 +17,8 @@ class Site < ApplicationRecord # Clonar el directorio de esqueleto antes de crear el sitio 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! @@ -363,4 +365,8 @@ class Site < ApplicationRecord @jekyll ||= Site.load_jekyll(Dir.pwd) end end + + def remove_directories! + FileUtils.rm_rf path + end end diff --git a/test/factories/site.rb b/test/factories/site.rb index 7947e8af..9d80180c 100644 --- a/test/factories/site.rb +++ b/test/factories/site.rb @@ -2,6 +2,6 @@ FactoryBot.define do factory :site do - name { SecureRandom.hex } + name { "test-#{SecureRandom.hex}" } end end diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 98e9940f..66ad25b9 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -10,6 +10,13 @@ class SiteTest < ActiveSupport::TestCase # TODO: Mover a la validación del sitio o hacer algo similar assert File.directory?(site.path) assert File.directory?(File.join(site.path, '.git')) + assert site.destroy + end + + test 'al destruir un sitio se eliminan los archivos' do + site = create :site + assert site.destroy + assert !File.directory?(site.path) end test 'se puede leer un sitio' do From af68d93a6cfb498e945f4904c4ff15da2bbfb0d7 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 15:37:11 -0300 Subject: [PATCH 07/44] =?UTF-8?q?el=20nombre=20del=20sitio=20es=20=C3=BAni?= =?UTF-8?q?co?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/models/site_test.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 66ad25b9..3cc695d1 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -13,6 +13,13 @@ class SiteTest < ActiveSupport::TestCase assert site.destroy end + test 'el nombre tiene que ser único' do + site = create :site + site2 = build :site, name: site.name + + assert_not site2.valid? + end + test 'al destruir un sitio se eliminan los archivos' do site = create :site assert site.destroy From 5d4c7971328a18a9f38745f88793468e70fa4db3 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 16:11:07 -0300 Subject: [PATCH 08/44] validar el nombre del sitio --- app/models/site.rb | 1 + test/models/site_test.rb | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/app/models/site.rb b/app/models/site.rb index d2e7d0b9..da3838e5 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -366,6 +366,7 @@ class Site < ApplicationRecord end end + # Elimina el directorio del sitio def remove_directories! FileUtils.rm_rf path end diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 3cc695d1..8d73a2bf 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -3,6 +3,11 @@ require 'test_helper' class SiteTest < ActiveSupport::TestCase + # Asegurarse que el sitio se destruye al terminar de usarlo + teardown do + @site&.destroy + end + test 'se puede crear un sitio' do site = create :site @@ -14,12 +19,36 @@ class SiteTest < ActiveSupport::TestCase end test 'el nombre tiene que ser único' do - site = create :site - site2 = build :site, name: site.name + @site = create :site + site2 = build :site, name: @site.name assert_not site2.valid? end + test 'el nombre del sitio puede contener subdominios' do + site = build :site, name: 'hola.chau' + + assert site.valid? + end + + test 'el nombre del sitio no puede terminar con punto' do + site = build :site, name: 'hola.chau.' + + assert_not site.valid? + end + + test 'el nombre del sitio no puede contener wildcard' do + site = build :site, name: '*.chau' + + assert_not site.valid? + end + + test 'el nombre del sitio solo tiene letras, numeros y guiones' do + site = build :site, name: 'A_Z!' + + assert_not site.valid? + end + test 'al destruir un sitio se eliminan los archivos' do site = create :site assert site.destroy From 3a3dd7a2de6ffed96ec779c8a5c6108a7990d52a Mon Sep 17 00:00:00 2001 From: f Date: Fri, 12 Jul 2019 20:40:44 -0300 Subject: [PATCH 09/44] crear sitios! --- app/controllers/sites_controller.rb | 26 ++++++++++++- app/models/site.rb | 2 +- app/policies/site_policy.rb | 40 +++++++++++++++++++- app/views/layouts/_breadcrumb.haml | 2 +- app/views/layouts/application.html.haml | 2 +- app/views/sites/index.haml | 6 ++- app/views/sites/new.haml | 14 +++++++ config/initializers/devise.rb | 2 +- config/locales/en.yml | 3 ++ config/locales/es.yml | 5 +++ config/routes.rb | 4 +- doc/crear_sitios.md | 8 ++++ test/controllers/sites_controller_test.rb | 45 +++++++++++++++++++++++ 13 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 app/views/sites/new.haml create mode 100644 test/controllers/sites_controller_test.rb diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 6ca91f10..3f280dd9 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -14,12 +14,30 @@ class SitesController < ApplicationController # No tenemos propiedades de un sitio aún, así que vamos al listado de # artículos def show - authorize Site site = find_site + authorize site redirect_to site_posts_path(site) end + def new + @site = Site.new + authorize @site + end + + def create + @site = Site.new(site_params) + current_usuarie.roles << Rol.new(site: @site, + temporal: false, + rol: 'usuarie') + + if current_usuarie.save + redirect_to site_path(@site) + else + render 'new' + end + end + # Envía un archivo del directorio público de Jekyll def send_public_file authorize Site @@ -79,4 +97,10 @@ class SitesController < ApplicationController redirect_to site_posts_path @site end + + private + + def site_params + params.require(:site).permit(:name) + end end diff --git a/app/models/site.rb b/app/models/site.rb index da3838e5..00d14838 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -359,7 +359,7 @@ class Site < ApplicationRecord # Carga el sitio Jekyll def load_jekyll! - return unless name + return unless name.present? && File.directory?(path) Dir.chdir(path) do @jekyll ||= Site.load_jekyll(Dir.pwd) diff --git a/app/policies/site_policy.rb b/app/policies/site_policy.rb index 1042f521..900c8f53 100644 --- a/app/policies/site_policy.rb +++ b/app/policies/site_policy.rb @@ -16,13 +16,35 @@ class SitePolicy # Todes les usuaries pueden ver el sitio si aceptaron la invitación def show? - !@usuarie.rol_for_site(@site).temporal + !current_role.temporal + end + + # Todes pueden crear nuevos sitios + def new? + true + end + + def create? + new? + end + + # Para poder editarlos también tienen que haber aceptado la invitación + def edit? + show? && usuarie? + end + + def update? + edit? + end + + def destroy? + edit? end # Les invitades no pueden generar el sitio y les usuaries solo hasta # que aceptan la invitación def build? - show? && !site.invitade?(usuarie) + show? && usuarie? end def send_public_file? @@ -40,4 +62,18 @@ class SitePolicy def reorder_posts? build? end + + private + + def current_role + usuarie.rol_for_site(site) + end + + def usuarie? + site.usuarie? usuarie + end + + def invitade? + site.invitade? usuarie + end end diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml index c8793762..01484ac6 100644 --- a/app/views/layouts/_breadcrumb.haml +++ b/app/views/layouts/_breadcrumb.haml @@ -5,7 +5,7 @@ data: { toggle: 'tooltip' }, title: t('help.logout'), role: 'button', class: 'btn-text' do = fa_icon 'sign-out', title: t('help.logout') - - if help = @site.try(:config).try(:dig, 'help') + - if @site.try(:persisted?) && (help = @site.try(:config).try(:dig, 'help')) %li.breadcrumb-item= link_to t('.help'), help, target: '_blank' - crumbs.compact.each do |crumb| - if current_user.is_a? Invitadx diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index e5f5fc5f..384ce108 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -6,7 +6,7 @@ = csrf_meta_tags = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' = javascript_include_tag 'application', 'data-turbolinks-track': 'reload' - - if @site.try(:config).try(:dig, 'css') + - if @site.try(:persisted?) && @site.try(:config).try(:dig, 'css') %link{rel: 'stylesheet', type: 'text/css', href: @site.get_url_from_site(@site.config.dig('css'))} - style = "background-image: url(#{@site.try(:cover) || image_url('background.jpg')})" %body{class: @has_cover ? 'background-cover' : '', style: @has_cover ? style : ''} diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index d953b8c5..b1085caa 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -4,7 +4,11 @@ = render 'layouts/breadcrumb', crumbs: [ t('sites.index') ] .row .col - %h1= t('sites.title') + %h1 + = t('sites.title') + - if policy(Site).new? + = link_to t('sites.new.title'), new_site_path, + class: 'btn btn-info' = render 'layouts/help', help: t('help.sites.index') diff --git a/app/views/sites/new.haml b/app/views/sites/new.haml new file mode 100644 index 00000000..2e28dcde --- /dev/null +++ b/app/views/sites/new.haml @@ -0,0 +1,14 @@ +.row + .col + = render 'layouts/breadcrumb', + crumbs: [ link_to(t('sites.index'), sites_path), t('.title') ] +.row + .col + %h1= t('.title') + + = form_for @site do |f| + .form-group + = f.label :name + = f.text_field :name, class: 'form-control' + .form-group + = f.submit t('.submit'), class: 'btn btn-success' diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index f7e7acdc..e3dce7c2 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -78,7 +78,7 @@ Devise.setup do |config| # `config.http_authenticatable = [:database]` will enable it only for # database authentication. The supported strategies are: :database # = Support basic authentication with authentication key + password - # config.http_authenticatable = false + config.http_authenticatable = true # If 401 status code should be returned for AJAX requests. True by # default. diff --git a/config/locales/en.yml b/config/locales/en.yml index 3475e70c..3e0ecc23 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -152,6 +152,9 @@ en: invitations: accept: 'Accept invitation' reject: 'No, thanks' + new: + title: 'Create site' + submit: 'Create site' footer: powered_by: 'is developed by' templates: diff --git a/config/locales/es.yml b/config/locales/es.yml index 77899aed..96086185 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -7,6 +7,8 @@ es: email: 'Correo electrónico' password: 'Contraseña' password_confirmation: 'Confirmación de contraseña' + site: + name: 'Nombre' errors: models: invitadx: @@ -155,6 +157,9 @@ es: invitations: accept: 'Aceptar la invitación' reject: 'No, gracias' + new: + title: 'Crear un sitio' + submit: 'Crear sitio' footer: powered_by: 'es desarrollada por' i18n: diff --git a/config/routes.rb b/config/routes.rb index 35bdd760..086fe8c5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,9 +11,7 @@ Rails.application.routes.draw do # como un objeto válido resources :invitadxs, only: [:create] - resources :sites, only: %i[index show], - constraints: { site_id: %r{[^/]+}, id: %r{[^/]+} } do - + resources :sites, constraints: { site_id: %r{[^/]+}, id: %r{[^/]+} } do get 'public/:type/:basename', to: 'sites#send_public_file' # Gestionar usuaries diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index f953d88f..e7cc3edb 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -105,3 +105,11 @@ falta (algunas plantillas tienen requisitos extraños). Las plantillas se instalan como gemas en los sitios, de forma que podemos cambiarla desde las opciones del sitio luego. + +## Internamente + +Al crear un sitio, clonar el esqueleto en el lugar correcto. Al +eliminarlo, eliminar el directorio. + +Lo correcto sería preguntar a todes les usuaries si están de acuerdo en +borrar el sitio. Si una no está de acuerdo, el borrado se cancela. diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb new file mode 100644 index 00000000..2c3c334c --- /dev/null +++ b/test/controllers/sites_controller_test.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class SitesControllerTest < ActionDispatch::IntegrationTest + setup do + @rol = create :rol + @site = @rol.site + @usuarie = @rol.usuarie + + @authorization = { + Authorization: ActionController::HttpAuthentication::Basic + .encode_credentials(@usuarie.email, @usuarie.password) + } + end + + teardown do + @site.destroy + end + + test 'se pueden ver' do + get sites_url, headers: @authorization + + assert_match @site.name, response.body + end + + test 'se puede ver el formulario de creación' do + get new_site_url, headers: @authorization + + assert_match(/ Date: Fri, 12 Jul 2019 21:20:36 -0300 Subject: [PATCH 10/44] actualizar sitios --- app/controllers/sites_controller.rb | 16 ++++++++++++++++ app/models/site.rb | 14 +++++++++++++- app/views/sites/_form.haml | 6 ++++++ app/views/sites/edit.haml | 10 ++++++++++ app/views/sites/new.haml | 9 ++------- config/locales/en.yml | 3 +++ config/locales/es.yml | 3 +++ test/models/site_test.rb | 11 +++++++++++ 8 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 app/views/sites/_form.haml create mode 100644 app/views/sites/edit.haml diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 3f280dd9..42fbaba8 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -38,6 +38,22 @@ class SitesController < ApplicationController end end + def edit + @site = find_site + authorize @site + end + + def update + @site = find_site + authorize @site + + if @site.update(site_params) + redirect_to sites_path + else + render 'edit' + end + end + # Envía un archivo del directorio público de Jekyll def send_public_file authorize Site diff --git a/app/models/site.rb b/app/models/site.rb index 00d14838..ac449f98 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -23,6 +23,8 @@ class Site < ApplicationRecord # de crearlo after_initialize :load_jekyll! after_create :load_jekyll! + # Cambiar el nombre del directorio + before_update :update_name! attr_accessor :jekyll, :collections @@ -38,7 +40,11 @@ class Site < ApplicationRecord # # Equivale a _sites + nombre def path - @path ||= File.join(Site.site_path, name) + File.join(Site.site_path, name) + end + + def old_path + File.join(Site.site_path, name_was) end # Este sitio acepta invitadxs? @@ -370,4 +376,10 @@ class Site < ApplicationRecord def remove_directories! FileUtils.rm_rf path end + + def update_name! + return unless name_changed? + + FileUtils.mv old_path, path + end end diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml new file mode 100644 index 00000000..1f5650bc --- /dev/null +++ b/app/views/sites/_form.haml @@ -0,0 +1,6 @@ += form_for @site do |f| + .form-group + = f.label :name + = f.text_field :name, class: 'form-control' + .form-group + = f.submit submit, class: 'btn btn-success' diff --git a/app/views/sites/edit.haml b/app/views/sites/edit.haml new file mode 100644 index 00000000..a7e96a1c --- /dev/null +++ b/app/views/sites/edit.haml @@ -0,0 +1,10 @@ +.row + .col + = render 'layouts/breadcrumb', + crumbs: [link_to(t('sites.index'), sites_path), + t('.title', site: @site.name)] +.row + .col + %h1= t('.title', site: @site.name) + + = render 'form', submit: t('.submit') diff --git a/app/views/sites/new.haml b/app/views/sites/new.haml index 2e28dcde..0b883c1d 100644 --- a/app/views/sites/new.haml +++ b/app/views/sites/new.haml @@ -1,14 +1,9 @@ .row .col = render 'layouts/breadcrumb', - crumbs: [ link_to(t('sites.index'), sites_path), t('.title') ] + crumbs: [link_to(t('sites.index'), sites_path), t('.title')] .row .col %h1= t('.title') - = form_for @site do |f| - .form-group - = f.label :name - = f.text_field :name, class: 'form-control' - .form-group - = f.submit t('.submit'), class: 'btn btn-success' + = render 'form', submit: t('.submit') diff --git a/config/locales/en.yml b/config/locales/en.yml index 3e0ecc23..64bde6bf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -155,6 +155,9 @@ en: new: title: 'Create site' submit: 'Create site' + edit: + title: 'Edit %{site}' + submit: 'Save changes' footer: powered_by: 'is developed by' templates: diff --git a/config/locales/es.yml b/config/locales/es.yml index 96086185..b0fa8ef5 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -160,6 +160,9 @@ es: new: title: 'Crear un sitio' submit: 'Crear sitio' + edit: + title: 'Editar %{site}' + submit: 'Guardar cambios' footer: powered_by: 'es desarrollada por' i18n: diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 8d73a2bf..2040bd94 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -61,4 +61,15 @@ class SiteTest < ActiveSupport::TestCase assert site.valid? assert !site.posts.empty? end + + test 'se pueden renombrar' do + @site = create :site + path = @site.path + + @site.update_attribute :name, SecureRandom.hex + + assert_not_equal path, @site.path + assert File.directory?(@site.path) + assert_not File.directory?(path) + end end From f7ca99a7a4ba10416ef7de24c98393132c847272 Mon Sep 17 00:00:00 2001 From: f Date: Sat, 13 Jul 2019 13:33:49 -0300 Subject: [PATCH 11/44] aumentar la performance con hamlit --- Gemfile | 3 ++- Gemfile.lock | 30 +++++++++++++++++------------- 2 files changed, 19 insertions(+), 14 deletions(-) diff --git a/Gemfile b/Gemfile index 3bf8e931..672b0027 100644 --- a/Gemfile +++ b/Gemfile @@ -46,7 +46,7 @@ gem 'email_address' gem 'exception_notification' gem 'font-awesome-rails' gem 'friendly_id' -gem 'haml-rails' +gem 'hamlit-rails' gem 'jekyll' gem 'jquery-rails' gem 'mini_magick' @@ -78,6 +78,7 @@ group :development do gem 'capistrano-rails' gem 'capistrano-rbenv' gem 'ed25519' + gem 'haml-lint', require: false gem 'letter_opener' gem 'rbnacl', '< 5.0' gem 'rubocop-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 872f4c9e..bef6cfc7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -122,7 +122,6 @@ GEM netaddr (~> 2.0) simpleidn erubi (1.8.0) - erubis (2.7.0) eventmachine (1.2.7) exception_notification (4.3.0) actionmailer (>= 4.0, < 6) @@ -145,17 +144,23 @@ GEM haml (5.0.4) temple (>= 0.8.0) tilt - haml-rails (1.0.0) + haml-lint (0.999.999) + haml_lint + haml_lint (0.32.0) + haml (>= 4.0, < 5.2) + rainbow + rake (>= 10, < 13) + rubocop (>= 0.50.0) + sysexits (~> 1.1) + hamlit (2.9.3) + temple (>= 0.8.0) + thor + tilt + hamlit-rails (0.2.3) actionpack (>= 4.0.1) activesupport (>= 4.0.1) - haml (>= 4.0.6, < 6.0) - html2haml (>= 1.0.1) + hamlit (>= 1.2.0) railties (>= 4.0.1) - html2haml (2.2.0) - erubis (~> 2.7.0) - haml (>= 4.0, < 6) - nokogiri (>= 1.6.0) - ruby_parser (~> 3.5) http_parser.rb (0.6.0) i18n (0.9.5) concurrent-ruby (~> 1.0) @@ -290,8 +295,6 @@ GEM i18n ruby-progressbar (1.10.1) ruby_dep (1.5.0) - ruby_parser (3.13.1) - sexp_processor (~> 4.9) rubyzip (1.2.2) rugged (0.28.2) safe_yaml (1.0.5) @@ -318,7 +321,6 @@ GEM selenium-webdriver (3.141.0) childprocess (~> 0.5) rubyzip (~> 1.2, >= 1.2.2) - sexp_processor (4.12.0) simpleidn (0.1.1) unf (~> 0.1.4) spring (2.0.2) @@ -337,6 +339,7 @@ GEM sshkit (1.18.2) net-scp (>= 1.1.2) net-ssh (>= 2.8.0) + sysexits (1.2.0) temple (0.8.1) thor (0.20.3) thread_safe (0.3.6) @@ -398,7 +401,8 @@ DEPENDENCIES factory_bot_rails font-awesome-rails friendly_id - haml-rails + haml-lint + hamlit-rails jbuilder (~> 2.5) jekyll jquery-rails From cff18bf8612920b5ba9eb634c86b0329e8b1ad5d Mon Sep 17 00:00:00 2001 From: f Date: Tue, 16 Jul 2019 16:47:44 -0300 Subject: [PATCH 12/44] poder actualizar el sitio a partir del skel --- app/controllers/sites_controller.rb | 20 ++++++++ app/models/site.rb | 12 +++++ app/models/site/repository.rb | 78 +++++++++++++++++++++++++++++ app/models/usuarie.rb | 4 ++ app/policies/site_policy.rb | 12 +++++ app/views/layouts/_time.haml | 1 + app/views/sites/fetch.haml | 34 +++++++++++++ app/views/sites/index.haml | 21 +++++--- config/locales/en.yml | 10 ++++ config/locales/es.yml | 10 ++++ config/routes.rb | 6 +++ doc/crear_sitios.md | 53 +++++++++++++++++++- test/models/site/repository_test.rb | 37 ++++++++++++++ 13 files changed, 289 insertions(+), 9 deletions(-) create mode 100644 app/models/site/repository.rb create mode 100644 app/views/layouts/_time.haml create mode 100644 app/views/sites/fetch.haml create mode 100644 test/models/site/repository_test.rb diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 42fbaba8..ec7ad31d 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -114,6 +114,26 @@ class SitesController < ApplicationController redirect_to site_posts_path @site end + def fetch + @site = find_site + authorize @site + + @commits = @site.repository.commits + end + + def merge + @site = find_site + authorize @site + + if @site.repository.merge(current_usuarie) + flash[:success] = I18n.t('sites.fetch.merge.success') + else + flash[:error] = I18n.t('sites.fetch.merge.error') + end + + redirect_to sites_path + end + private def site_params diff --git a/app/models/site.rb b/app/models/site.rb index ac449f98..24f80b08 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -28,6 +28,18 @@ class Site < ApplicationRecord attr_accessor :jekyll, :collections + # El repositorio git para este sitio + def repository + @repository ||= Site::Repository.new path + end + + # Trae los cambios del skel y verifica que haya cambios + def needs_pull? + !repository.commits.empty? + end + + # TODO: Mover esta consulta a la base de datos para no traer un montón + # de cosas a la memoria def invitade?(usuarie) invitades.pluck(:id).include? usuarie.id end diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb new file mode 100644 index 00000000..e8d49c09 --- /dev/null +++ b/app/models/site/repository.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Site + # Acciones para el repositorio Git de un sitio. Por ahora hacemos un + # uso muy básico de Git, con lo que asumimos varias cosas, por ejemplo + # que un sitio tiene un solo origen, que siempre se trabaja con la + # rama master, etc. + class Repository + attr_reader :rugged, :changes + + def initialize(path) + @rugged = Rugged::Repository.new(path) + @changes = 0 + end + + def remote + @remote ||= rugged.remotes.first + end + + # Trae los cambios del repositorio de origen sin aplicarlos y + def fetch + if remote.check_connection :fetch + @changes = rugged.fetch(remote)[:received_objects] + else + 0 + end + end + + # Incorpora los cambios en el repositorio actual + # + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/MethodLength + def merge(author) + master = rugged.branches['master'].target + origin = rugged.branches['origin/master'].target + merge = rugged.merge_commits(master, origin) + + # No hacemos nada si hay conflictos + # + # TODO: Enviar un correo a administración para poder revisar + # manualmente. Idealmente no deberíamos tener conflictos pero + # quién sabe. + return if merge.conflicts? + + author = { name: author.name, email: author.email } + commit = Rugged::Commit + .create(rugged, + parents: [master, origin], + tree: merge.write_tree(rugged), + message: I18n.t('sites.fetch.merge.message'), + author: author, + committer: author, + update_ref: 'HEAD') + + # Forzamos el checkout para mover el HEAD al último commit y + # escribir los cambios + rugged.checkout 'HEAD', strategy: :force + commit + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/MethodLength + + # Compara los commits entre el repositorio remoto y el actual para + # que luego los podamos mostrar. + def commits + walker = Rugged::Walker.new rugged + + # Obtenemos todos los commits que existen en origin/master que no + # están en la rama master local + # + # XXX: monitorear esto por performance + walker.push 'refs/remotes/origin/master' + walker.hide 'refs/heads/master' + + walker.each.to_a + end + end +end diff --git a/app/models/usuarie.rb b/app/models/usuarie.rb index a1a2be76..c2704a1a 100644 --- a/app/models/usuarie.rb +++ b/app/models/usuarie.rb @@ -11,6 +11,10 @@ class Usuarie < ApplicationRecord has_many :roles has_many :sites, through: :roles + def name + email.split('@', 2).first + end + def rol_for_site(site) site.roles.merge(roles).first end diff --git a/app/policies/site_policy.rb b/app/policies/site_policy.rb index 900c8f53..702d41cd 100644 --- a/app/policies/site_policy.rb +++ b/app/policies/site_policy.rb @@ -63,6 +63,18 @@ class SitePolicy build? end + def pull? + build? + end + + def fetch? + pull? + end + + def merge? + pull? + end + private def current_role diff --git a/app/views/layouts/_time.haml b/app/views/layouts/_time.haml new file mode 100644 index 00000000..4fa3151a --- /dev/null +++ b/app/views/layouts/_time.haml @@ -0,0 +1 @@ +%time{ datetime: time, title: time }= time_ago_in_words time diff --git a/app/views/sites/fetch.haml b/app/views/sites/fetch.haml new file mode 100644 index 00000000..ac6c66b1 --- /dev/null +++ b/app/views/sites/fetch.haml @@ -0,0 +1,34 @@ +.row + .col + = render 'layouts/breadcrumb', + crumbs: [link_to(t('sites.index'), sites_path), t('.title')] +.row.justify-content-center + .col-md-8#pull + %h1= t('.title') + %p.lead= sanitize_markdown t('.help.fetch'), tags: %w[em strong a] + + %h2= t('.toc') + %ul.toc + - @commits.each do |commit| + %li= link_to commit.summary, "##{commit.oid}" + +- @commits.each do |commit| + .row.justify-content-center + .col-md-8{ id: commit.oid } + %h1= commit.summary + %p.lead= render 'layouts/time', time: commit.time + + -# + No hay forma de obtener el cuerpo del commit separado del + resumen, cortamos por el primer salto de línea doble y obtenemos + todo lo demás + = sanitize_markdown commit.message.split("\n\n", 2).last, + tags: %w[p a h1 h2 h3 h4 h5 h6 ol ul li strong em] + + %hr + +- unless @commits.empty? + .row.justify-content-center + .col-md-8 + = link_to t('.merge.request'), site_pull_path(@site), + method: 'post', class: 'btn btn-lg btn-success' diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index b1085caa..d082408c 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -1,7 +1,6 @@ - .row .col - = render 'layouts/breadcrumb', crumbs: [ t('sites.index') ] + = render 'layouts/breadcrumb', crumbs: [t('sites.index')] .row .col %h1 @@ -20,14 +19,15 @@ %h2 - if policy(site).show? = link_to site.name, site_path(site) - - else + - else = site.name - if site.invitade? current_usuarie - %span.badge.badge-warning{data: { toggle: 'tooltip' }, - title: t('help.sites.invitade')} + %span.badge.badge-warning{ data: { toggle: 'tooltip' }, + title: t('help.sites.invitade') } = t('.invitade') %br - .btn-group{role: 'group', 'aria-label': t('sites.actions')} + .btn-group{ role: 'group', + 'aria-label': t('sites.actions') } - if current_usuarie.rol_for_site(site).temporal = button_to t('sites.invitations.accept'), site_usuaries_accept_invitation_path(site), @@ -68,7 +68,8 @@ type: 'secondary', link: nil - else - = form_tag site_enqueue_path(site), method: :post, class: 'form-inline' do + = form_tag site_enqueue_path(site), + method: :post, class: 'form-inline' do = button_tag type: 'submit', class: 'btn btn-success', title: t('help.sites.enqueue'), @@ -85,3 +86,9 @@ text: t('sites.build_log'), type: 'warning', link: site_build_log_path(site) + - if policy(site).pull? && site.needs_pull? + = render 'layouts/btn_with_tooltip', + tooltip: t('help.sites.pull'), + text: t('.pull'), + type: 'info', + link: site_pull_path(site) diff --git a/config/locales/en.yml b/config/locales/en.yml index 64bde6bf..8f64db91 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -158,6 +158,16 @@ en: edit: title: 'Edit %{site}' submit: 'Save changes' + fetch: + title: 'Upgrade the site' + help: + fetch: 'Any changes made to the site are saved into a _git_ repository. Git saves the differences between previous and current versions of files so we can explore them as the history of the project. Also, we can bring and send changes between repositories. In this case, every site managed with Sutty share a common root that we call [skeleton](https://0xacab.org/sutty/skel.sutty.nl). When we upgrade this skeleton, you can explore the changes here and accept them to make your site better.' + toc: 'Table of contents' + merge: + request: 'Upgrade my site with these changes' + success: 'Site upgrade has been completed. Your next build will run this upgrade :)' + error: "There was an error when we were trying to upgrade your site. This could be due to conflicts that couldn't be solved automatically. We've sent a report of the issue to Sutty's admins so they already know about it. Sorry! :(" + message: 'Skeleton upgrade' footer: powered_by: 'is developed by' templates: diff --git a/config/locales/es.yml b/config/locales/es.yml index b0fa8ef5..fc37c162 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -163,6 +163,16 @@ es: edit: title: 'Editar %{site}' submit: 'Guardar cambios' + fetch: + title: 'Actualizar el sitio' + help: + fetch: 'Todos los cambios en el sitio se guardan en un repositorio _git_. En git, se guarda la diferencia entre una versión anterior y la actual de todos los archivos y podemos explorar la historia de un proyecto. Además, podemos traer y enviar cambios con otros repositorios. En este caso, todos los sitios gestionados desde Sutty tienen una raíz común, que llamamos [esqueleto](https://0xacab.org/sutty/skel.sutty.nl). Cuando hacemos cambios en el esqueleto para mejorar los sitios, podés explorar los cambios aquí y aceptarlos.' + toc: 'Tabla de contenidos' + merge: + request: 'Incorporar los cambios en mi sitio' + success: 'Ya se incorporaron los cambios en el sitio, se aplicarán en la próxima compilación que hagas :)' + error: 'Hubo un error al incorporar los cambios en el sitio. Esto puede deberse a conflictos entre cambios que no se pueden resolver automáticamente. Hemos enviado un reporte del problema a les administradores de Sutty para que estén al tanto de la situación. ¡Lo sentimos! :(' + message: 'Actualización del esqueleto' footer: powered_by: 'es desarrollada por' i18n: diff --git a/config/routes.rb b/config/routes.rb index 086fe8c5..3b701878 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# rubocop:disable Metrics/BlockLength Rails.application.routes.draw do devise_for :usuaries @@ -14,6 +15,10 @@ Rails.application.routes.draw do resources :sites, constraints: { site_id: %r{[^/]+}, id: %r{[^/]+} } do get 'public/:type/:basename', to: 'sites#send_public_file' + # Gestionar actualizaciones del sitio + get 'pull', to: 'sites#fetch' + post 'pull', to: 'sites#merge' + # Gestionar usuaries get 'usuaries/invite', to: 'usuaries#invite' post 'usuaries/invite', to: 'usuaries#send_invitations' @@ -41,3 +46,4 @@ Rails.application.routes.draw do post 'reorder_posts', to: 'sites#reorder_posts' end end +# rubocop:enable Metrics/BlockLength diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index e7cc3edb..b89e192a 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -96,9 +96,9 @@ El sitio esqueleto es un repositorio Git que se clona al directorio del sitio. Esto permite luego pullear actualizaciones desde el esqueleto a los sitios, esperamos que sin conflictos! -## Plantillas +## Diseño -Las plantillas son plantillas Jekyll adaptadas a Sutty. Vamos a empezar +Los diseños son plantillas Jekyll adaptadas a Sutty. Vamos a empezar adaptando las que estén disponibles en y otras fuentes, agregando features de Sutty y simplificando donde haga falta (algunas plantillas tienen requisitos extraños). @@ -113,3 +113,52 @@ eliminarlo, eliminar el directorio. Lo correcto sería preguntar a todes les usuaries si están de acuerdo en borrar el sitio. Si una no está de acuerdo, el borrado se cancela. + +### Licencias + +Las licencias disponibles se pueden gestionar desde la base de datos. +Los atributos son: + +* Titulo +* URL +* Descripción, por qué la recomendamos, etc. + +El problema que tenemos es que las queremos tener traducidas, entonces +hay varias opciones: + +* Incorporar columna idioma en la base de datos y cada vez que se + muestren las licencias filtrar por idioma actual (o idioma por + defecto). + + Esto nos permitiría ofrecer licencias por jurisdicción también, aunque + empezaríamos con las internacionales... + +* Incorporar la gema de traducción y poner las traducciones en la base + de datos. Esto permite tener una sola licencia con sus distintas + traducciones en un solo registro. + + Pensábamos que necesitábamos cambiar a PostgreSQL, pero la gema + [Mobility](https://github.com/shioyama/mobility) permite usar + distintas estrategias. + +* Incorporar las traducciones a los locales de sutty. Esta es la opción + menos flexible, porque implica agregar licencias a la base de datos y + al mismo tiempo actualizar los archivos y re-deployear Sutty... mejor + no. + +Pero es importante que estén asociadas entre sí por idioma. + +Permitir que les usuaries elijan licencia+privacidad+codigo de +convivencia e informarles que van a ser los primeros artículos dentro de +su sitio y que los pueden modificar después. Que esta es nuestra +propuesta tecnopolítica para que los espacios digitales y analógicos +sean espacios amables siguiente una lógica de cuidados colectivos. + +## Actualizar skel + +Cuando actualizamos el skel, sutty pide a todos los sitios +consentimiento para aplicar las actualizaciones. Antes de aplicar las +actualizaciones muestra el historial para que les usuaries vean cuales +son los cambios que se van a aplicar. Este historial viene del +repositorio git con lo que tenemos que tomarnos la costumbre de escribir +commits completos con explicación. diff --git a/test/models/site/repository_test.rb b/test/models/site/repository_test.rb new file mode 100644 index 00000000..f23925ad --- /dev/null +++ b/test/models/site/repository_test.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class RepositoryTest < ActiveSupport::TestCase + setup do + @rol = create :rol + @site = @rol.site + @usuarie = @rol.usuarie + + # Volver al principio para poder traer cambios + Dir.chdir(@site.path) do + `git reset --hard e0627e34c6ef6ae2592d7f289b82def20ba56685` + end + end + + teardown do + @site.destroy + end + + test 'se pueden traer cambios' do + assert @site.repository.fetch.is_a?(Integer) + end + + test 'se pueden mergear los cambios' do + assert !@site.repository.commits.empty? + assert @site.repository.merge(@usuarie) + assert @site.repository.commits.empty? + + assert_equal @usuarie.name, + @site.repository.rugged + .branches['master'].target.committer[:name] + + Dir.chdir(@site.path) do + assert_equal 'nothing to commit, working tree clean', + `LC_ALL=C git status`.strip.split("\n").last + end + end +end From 1cc4c4de643867f91aa75351ed7bb2d14697bee2 Mon Sep 17 00:00:00 2001 From: f Date: Wed, 17 Jul 2019 19:18:48 -0300 Subject: [PATCH 13/44] =?UTF-8?q?WIP:=20elegir=20el=20dise=C3=B1o=20del=20?= =?UTF-8?q?sitio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Gemfile | 1 + Gemfile.lock | 8 +- app/controllers/sites_controller.rb | 2 +- app/models/design.rb | 19 ++++ app/models/site.rb | 3 + app/views/sites/_form.haml | 27 +++++- app/views/sites/edit.haml | 2 +- app/views/sites/new.haml | 2 +- config/initializers/mobility.rb | 97 +++++++++++++++++++ config/locales/en.yml | 9 ++ config/locales/es.yml | 9 ++ db/migrate/20190716195155_create_designs.rb | 15 +++ .../20190716195449_add_lang_to_usuaries.rb | 9 ++ ...20190716195811_create_text_translations.rb | 16 +++ ...190716195812_create_string_translations.rb | 17 ++++ .../20190716202024_add_design_to_sites.rb | 8 ++ .../20190717214308_add_disabled_to_designs.rb | 8 ++ db/schema.rb | 41 +++++++- db/seeds.rb | 14 +-- db/seeds/designs.yml | 22 +++++ test/controllers/sites_controller_test.rb | 3 +- test/factories/design.rb | 12 +++ test/factories/site.rb | 1 + test/models/design_test.rb | 10 ++ test/models/site_test.rb | 12 ++- 25 files changed, 348 insertions(+), 19 deletions(-) create mode 100644 app/models/design.rb create mode 100644 config/initializers/mobility.rb create mode 100644 db/migrate/20190716195155_create_designs.rb create mode 100644 db/migrate/20190716195449_add_lang_to_usuaries.rb create mode 100644 db/migrate/20190716195811_create_text_translations.rb create mode 100644 db/migrate/20190716195812_create_string_translations.rb create mode 100644 db/migrate/20190716202024_add_design_to_sites.rb create mode 100644 db/migrate/20190717214308_add_disabled_to_designs.rb create mode 100644 db/seeds/designs.yml create mode 100644 test/factories/design.rb create mode 100644 test/models/design_test.rb diff --git a/Gemfile b/Gemfile index 672b0027..537cabc5 100644 --- a/Gemfile +++ b/Gemfile @@ -50,6 +50,7 @@ gem 'hamlit-rails' gem 'jekyll' gem 'jquery-rails' gem 'mini_magick' +gem 'mobility' gem 'pundit' gem 'rails-i18n' gem 'rails_warden' diff --git a/Gemfile.lock b/Gemfile.lock index bef6cfc7..7941b035 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -212,10 +212,13 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2018.0812) mimemagic (0.3.3) - mini_magick (4.9.3) + mini_magick (4.9.4) mini_mime (1.0.1) mini_portile2 (2.4.0) minitest (5.11.3) + mobility (0.8.7) + i18n (>= 0.6.10, < 2) + request_store (~> 1.0) multi_json (1.13.1) net-scp (2.0.0) net-ssh (>= 2.6.5, < 6.0.0) @@ -277,6 +280,8 @@ GEM ffi (~> 1.0) rbnacl (4.0.2) ffi + request_store (1.4.1) + rack (>= 1.4) responders (3.0.0) actionpack (>= 5.0) railties (>= 5.0) @@ -409,6 +414,7 @@ DEPENDENCIES letter_opener listen (>= 3.0.5, < 3.2) mini_magick + mobility pry puma (~> 3.7) pundit diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index ec7ad31d..f3e2a6de 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -137,6 +137,6 @@ class SitesController < ApplicationController private def site_params - params.require(:site).permit(:name) + params.require(:site).permit(:name, :design_id) end end diff --git a/app/models/design.rb b/app/models/design.rb new file mode 100644 index 00000000..aeacb79b --- /dev/null +++ b/app/models/design.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +# El diseño de un sitio es la plantilla/tema. En este modelo cargamos +# las propiedades para poder verlas desde el panel y elegir un diseño +# para el sitio. +# +# TODO: Agregar captura de pantalla con ActiveStorage +class Design < ApplicationRecord + extend Mobility + + translates :name, type: :string, locale_accessors: true + translates :description, type: :text, locale_accessors: true + + has_many :sites + + validates :name, presence: true, uniqueness: true + validates :gem, presence: true, uniqueness: true + validates :description, presence: true +end diff --git a/app/models/site.rb b/app/models/site.rb index 24f80b08..0ed363a8 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -6,9 +6,12 @@ class Site < ApplicationRecord include FriendlyId validates :name, uniqueness: true, hostname: true + validates :design_id, presence: true friendly_id :name, use: %i[finders] + belongs_to :design + has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index 1f5650bc..927cd2ed 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -1,6 +1,29 @@ -= form_for @site do |f| += form_for site do |f| .form-group - = f.label :name + %h2= f.label :name + %p.lead= t('.help.name') = f.text_field :name, class: 'form-control' + .form-group + %h2= t('.design.title') + %p.lead= t('.help.design') + .row + -# Demasiado complejo para un f.collection_radio_buttons + - Design.all.each do |design| + .col + %h3 + = f.radio_button :design_id, design.id, + checked: design.id == site.design_id, + disabled: design.disabled + = f.label "design_id_#{design.id}", design.name + = sanitize_markdown design.description, + tags: %w[p a strong em] + + .btn-group{ role: 'group', 'aria-label': t('.design.actions') } + - if design.url + = link_to t('.design.url'), design.url, + target: '_blank', class: 'btn btn-info' + - if design.license + = link_to t('.design.license'), design.license, + target: '_blank', class: 'btn btn-info' .form-group = f.submit submit, class: 'btn btn-success' diff --git a/app/views/sites/edit.haml b/app/views/sites/edit.haml index a7e96a1c..a461bb50 100644 --- a/app/views/sites/edit.haml +++ b/app/views/sites/edit.haml @@ -7,4 +7,4 @@ .col %h1= t('.title', site: @site.name) - = render 'form', submit: t('.submit') + = render 'form', site: @site, submit: t('.submit') diff --git a/app/views/sites/new.haml b/app/views/sites/new.haml index 0b883c1d..b5760f3c 100644 --- a/app/views/sites/new.haml +++ b/app/views/sites/new.haml @@ -6,4 +6,4 @@ .col %h1= t('.title') - = render 'form', submit: t('.submit') + = render 'form', site: @site, submit: t('.submit') diff --git a/config/initializers/mobility.rb b/config/initializers/mobility.rb new file mode 100644 index 00000000..8176045a --- /dev/null +++ b/config/initializers/mobility.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +Mobility.configure do |config| + # Sets the default backend to use in models. This can be overridden in + # models by passing +backend: ...+ to +translates+. + config.default_backend = :key_value + + # By default, Mobility uses the +translates+ class method in models to + # describe translated attributes, but you can configure this method to + # be whatever you like. This may be useful if using Mobility alongside + # another translation gem which uses the same method name. + config.accessor_method = :translates + + # To query on translated attributes, you need to append a scope to + # your model. The name of this scope is +i18n+ by default, but this + # can be changed to something else. + config.query_method = :i18n + + # Uncomment and remove (or add) items to (from) this list to + # completely disable/enable plugins globally (so they cannot be used + # and are never even loaded). Note that if you remove an item from the + # list, you will not be able to use the plugin at all, and any options + # for the plugin will be ignored by models. (In most cases, you + # probably don't want to change this.) + # + # config.plugins = %i[ + # query + # cache + # dirty + # fallbacks + # presence + # default + # attribute_methods + # fallthrough_accessors + # locale_accessors + # ] + + # The translation cache is on by default, but you can turn it off by + # uncommenting this line. (This may be helpful in debugging.) + # + # config.default_options[:cache] = false + + # Dirty tracking is disabled by default. Uncomment this line to enable + # it. If you enable this, you should also enable +locale_accessors+ + # by default (see below). + # + # config.default_options[:dirty] = true + + # No fallbacks are used by default. To define default fallbacks, + # uncomment and set the default fallback option value here. A "true" + # value will use whatever is defined by +I18n.fallbacks+ (if defined), + # or alternatively will fallback to your +I18n.default_locale+. + # + config.default_options[:fallbacks] = true + + # The Presence plugin converts empty strings to nil when fetching and + # setting translations. By default it is on, uncomment this line to + # turn it off. + # + # config.default_options[:presence] = false + + # Set a default value to use if the translation is nil. By default + # this is off, uncomment and set a default to use it across all models + # (you probably don't want to do that). + # + # config.default_options[:default] = ... + + # Uncomment to enable locale_accessors by default on models. A true + # value will use the locales defined either in + # Rails.application.config.i18n.available_locales or + # I18n.available_locales. If you want something else, pass an array + # of locales instead. + # + # config.default_options[:locale_accessors] = true + + # Uncomment to enable fallthrough accessors by default on models. This + # will allow you to call any method with a suffix like _en or _pt_br, + # and Mobility will catch the suffix and convert it into a locale in + # +method_missing+. If you don't need this kind of open-ended + # fallthrough behavior, it's better to use locale_accessors instead + # (which define methods) since method_missing is very slow. (You can + # use both fallthrough and locale accessor plugins together without + # conflict.) + # + # Note: The dirty plugin enables fallthrough_accessors by default. + # + # config.default_options[:fallthrough_accessors] = true + + # You can also include backend-specific default options. For example, + # if you want to default to using the text-type translation table with + # the KeyValue backend, you can set that as a default by uncommenting + # this line, or change it to :string to default to the string-type + # translation table instead. (For other backends, this option is + # ignored.) + # + # config.default_options[:type] = :text +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 8f64db91..cdb5a457 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -158,6 +158,15 @@ en: edit: title: 'Edit %{site}' submit: 'Save changes' + form: + help: + name: "Your site's name. It can only contain numbers and letters." + design: 'Select the design for your site. You can change it later. We add more designs from time to time.' + design: + title: 'Design' + actions: 'Information about this design' + url: 'Preview' + licencia: 'Read the license' fetch: title: 'Upgrade the site' help: diff --git a/config/locales/es.yml b/config/locales/es.yml index fc37c162..5cf84bd1 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -163,6 +163,15 @@ es: edit: title: 'Editar %{site}' submit: 'Guardar cambios' + form: + help: + name: 'El nombre de tu sitio. Solo puede contener letras y números.' + design: 'Elegí el diseño que va a tener tu sitio aquí. Podés cambiarlo luego. De tanto en tanto vamos sumando diseños nuevos.' + design: + title: 'Diseño' + actions: 'Información sobre este diseño' + url: 'Vista previa' + licencia: 'Leer la licencia' fetch: title: 'Actualizar el sitio' help: diff --git a/db/migrate/20190716195155_create_designs.rb b/db/migrate/20190716195155_create_designs.rb new file mode 100644 index 00000000..b040749a --- /dev/null +++ b/db/migrate/20190716195155_create_designs.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Crea la tabla de diseños +class CreateDesigns < ActiveRecord::Migration[5.2] + def change + create_table :designs do |t| + t.timestamps + t.string :name, unique: true + t.text :description + t.string :gem, unique: true + t.string :url + t.string :license + end + end +end diff --git a/db/migrate/20190716195449_add_lang_to_usuaries.rb b/db/migrate/20190716195449_add_lang_to_usuaries.rb new file mode 100644 index 00000000..598de77e --- /dev/null +++ b/db/migrate/20190716195449_add_lang_to_usuaries.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Agrega la columna de idioma a cada usuarie para que pueda ver el sitio +# y escribir artículos en su idioma. +class AddLangToUsuaries < ActiveRecord::Migration[5.2] + def change + add_column :usuaries, :lang, :string, default: 'es' + end +end diff --git a/db/migrate/20190716195811_create_text_translations.rb b/db/migrate/20190716195811_create_text_translations.rb new file mode 100644 index 00000000..caa129fc --- /dev/null +++ b/db/migrate/20190716195811_create_text_translations.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Tabla de traducción de textos utilizada por Mobility +class CreateTextTranslations < ActiveRecord::Migration[5.2] + def change + create_table :mobility_text_translations do |t| + t.string :locale, null: false + t.string :key, null: false + t.text :value + t.references :translatable, polymorphic: true, index: false + t.timestamps null: false + end + add_index :mobility_text_translations, %i[translatable_id translatable_type locale key], unique: true, name: :index_mobility_text_translations_on_keys + add_index :mobility_text_translations, %i[translatable_id translatable_type key], name: :index_mobility_text_translations_on_translatable_attribute + end +end diff --git a/db/migrate/20190716195812_create_string_translations.rb b/db/migrate/20190716195812_create_string_translations.rb new file mode 100644 index 00000000..5389e025 --- /dev/null +++ b/db/migrate/20190716195812_create_string_translations.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Tabla de traducción de cadenas usada por Mobility +class CreateStringTranslations < ActiveRecord::Migration[5.2] + def change + create_table :mobility_string_translations do |t| + t.string :locale, null: false + t.string :key, null: false + t.string :value + t.references :translatable, polymorphic: true, index: false + t.timestamps null: false + end + add_index :mobility_string_translations, %i[translatable_id translatable_type locale key], unique: true, name: :index_mobility_string_translations_on_keys + add_index :mobility_string_translations, %i[translatable_id translatable_type key], name: :index_mobility_string_translations_on_translatable_attribute + add_index :mobility_string_translations, %i[translatable_type key value locale], name: :index_mobility_string_translations_on_query_keys + end +end diff --git a/db/migrate/20190716202024_add_design_to_sites.rb b/db/migrate/20190716202024_add_design_to_sites.rb new file mode 100644 index 00000000..44958ea4 --- /dev/null +++ b/db/migrate/20190716202024_add_design_to_sites.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Los sitios tienen un diseño +class AddDesignToSites < ActiveRecord::Migration[5.2] + def change + add_belongs_to :sites, :design, index: true + end +end diff --git a/db/migrate/20190717214308_add_disabled_to_designs.rb b/db/migrate/20190717214308_add_disabled_to_designs.rb new file mode 100644 index 00000000..6432e346 --- /dev/null +++ b/db/migrate/20190717214308_add_disabled_to_designs.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Algunos diseños están deshabilitados +class AddDisabledToDesigns < ActiveRecord::Migration[5.2] + def change + add_column :designs, :disabled, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 98e398c9..0d9fb79f 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,43 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_712_165_059) do +ActiveRecord::Schema.define(version: 20_190_717_214_308) do + create_table 'designs', force: :cascade do |t| + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'name' + t.text 'description' + t.string 'gem' + t.string 'url' + t.string 'license' + t.boolean 'disabled', default: false + end + + create_table 'mobility_string_translations', force: :cascade do |t| + t.string 'locale', null: false + t.string 'key', null: false + t.string 'value' + t.string 'translatable_type' + t.integer 'translatable_id' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index %w[translatable_id translatable_type key], name: 'index_mobility_string_translations_on_translatable_attribute' + t.index %w[translatable_id translatable_type locale key], name: 'index_mobility_string_translations_on_keys', unique: true + t.index %w[translatable_type key value locale], name: 'index_mobility_string_translations_on_query_keys' + end + + create_table 'mobility_text_translations', force: :cascade do |t| + t.string 'locale', null: false + t.string 'key', null: false + t.text 'value' + t.string 'translatable_type' + t.integer 'translatable_id' + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.index %w[translatable_id translatable_type key], name: 'index_mobility_text_translations_on_translatable_attribute' + t.index %w[translatable_id translatable_type locale key], name: 'index_mobility_text_translations_on_keys', unique: true + end + create_table 'roles', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false @@ -29,6 +65,8 @@ ActiveRecord::Schema.define(version: 20_190_712_165_059) do t.datetime 'created_at', null: false t.datetime 'updated_at', null: false t.string 'name' + t.integer 'design_id' + t.index ['design_id'], name: 'index_sites_on_design_id' t.index ['name'], name: 'index_sites_on_name', unique: true end @@ -56,6 +94,7 @@ ActiveRecord::Schema.define(version: 20_190_712_165_059) do t.string 'invited_by_type' t.integer 'invited_by_id' t.integer 'invitations_count', default: 0 + t.string 'lang', default: 'es' t.index ['confirmation_token'], name: 'index_usuaries_on_confirmation_token', unique: true t.index ['email'], name: 'index_usuaries_on_email', unique: true t.index ['invitation_token'], name: 'index_usuaries_on_invitation_token', unique: true diff --git a/db/seeds.rb b/db/seeds.rb index ebd18895..9e3d2c5b 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,9 @@ # frozen_string_literal: true -# This file should contain all the record creation needed to seed the database with its default values. -# The data can then be loaded with the rails db:seed command (or created alongside the database with db:setup). -# -# Examples: -# -# movies = Movie.create([{ name: 'Star Wars' }, { name: 'Lord of the Rings' }]) -# Character.create(name: 'Luke', movie: movies.first) +designs = YAML.safe_load(File.read('db/seeds/designs.yml')) + +designs.each do |d| + design = Design.find_or_create_by(gem: d['gem']) + + design.update_attributes d +end diff --git a/db/seeds/designs.yml b/db/seeds/designs.yml new file mode 100644 index 00000000..26f2e9a1 --- /dev/null +++ b/db/seeds/designs.yml @@ -0,0 +1,22 @@ +--- +- name_en: 'My own design' + name_es: 'Mi propio diseño' + gem: 'sutty-theme-none' + url: 'https://sutty.nl' + disabled: true + description_en: "Your own design. [This feature is in development, help us!]()" + description_es: "Tu propio diseño. [Esta posibilidad está en desarrollo, ¡ayudanos!]()" +- name_en: 'Minima' + name_es: 'Mínima' + gem: 'minima' + url: 'https://jekyll.github.io/minima/' + description_en: "Minima is the default design for Jekyll sites. It's made for general-purpose writing." + description_es: 'Mínima es el diseño oficial de los sitios Jekyll, hecho para escritura de propósitos generales.' + license: 'https://github.com/jekyll/minima/blob/master/LICENSE.txt' +- name_en: 'EDSL' + name_es: 'EDSL' + gem: 'sutty-theme-edsl' + url: 'https://endefensadelsl.org/' + description_en: "_En defensa del software libre_'s design" + description_es: 'El diseño de En defensa del software libre' + license: 'https://endefensadelsl.org/ppl_deed_es.html' diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index 2c3c334c..058d20b1 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -33,7 +33,8 @@ class SitesControllerTest < ActionDispatch::IntegrationTest post sites_url, headers: @authorization, params: { site: { - name: name + name: name, + design_id: create(:design).id } } diff --git a/test/factories/design.rb b/test/factories/design.rb new file mode 100644 index 00000000..b210b3ad --- /dev/null +++ b/test/factories/design.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :design do + name { SecureRandom.hex } + description { SecureRandom.hex } + license { SecureRandom.hex } + gem { SecureRandom.hex } + url { SecureRandom.hex } + disabled { false } + end +end diff --git a/test/factories/site.rb b/test/factories/site.rb index 9d80180c..3593c9fc 100644 --- a/test/factories/site.rb +++ b/test/factories/site.rb @@ -3,5 +3,6 @@ FactoryBot.define do factory :site do name { "test-#{SecureRandom.hex}" } + design end end diff --git a/test/models/design_test.rb b/test/models/design_test.rb new file mode 100644 index 00000000..000be74d --- /dev/null +++ b/test/models/design_test.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DesignTest < ActiveSupport::TestCase + test 'se pueden crear' do + design = create :design + + assert design.valid? + assert design.persisted? + end +end diff --git a/test/models/site_test.rb b/test/models/site_test.rb index 2040bd94..b74daaf2 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -27,26 +27,30 @@ class SiteTest < ActiveSupport::TestCase test 'el nombre del sitio puede contener subdominios' do site = build :site, name: 'hola.chau' + site.validate - assert site.valid? + assert_not site.errors.messages[:name].present? end test 'el nombre del sitio no puede terminar con punto' do site = build :site, name: 'hola.chau.' + site.validate - assert_not site.valid? + assert site.errors.messages[:name].present? end test 'el nombre del sitio no puede contener wildcard' do site = build :site, name: '*.chau' + site.validate - assert_not site.valid? + assert site.errors.messages[:name].present? end test 'el nombre del sitio solo tiene letras, numeros y guiones' do site = build :site, name: 'A_Z!' + site.validate - assert_not site.valid? + assert site.errors.messages[:name].present? end test 'al destruir un sitio se eliminan los archivos' do From 1dd8c7399125b59ca4a57d830e532bf519ea0272 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 19 Jul 2019 19:37:53 -0300 Subject: [PATCH 14/44] WIP: licencias --- app/controllers/sites_controller.rb | 2 +- app/models/licencia.rb | 18 ++ app/models/site.rb | 1 + config/initializers/inflections.rb | 4 + db/migrate/20190718185817_create_licencias.rb | 16 + .../20190719221653_add_icons_to_licenses.rb | 9 + db/schema.rb | 14 +- db/seeds.rb | 8 + db/seeds/licencias.yml | 290 ++++++++++++++++++ test/controllers/sites_controller_test.rb | 3 +- test/factories/licencia.rb | 11 + test/factories/site.rb | 1 + test/models/licencia_test.rb | 9 + 13 files changed, 383 insertions(+), 3 deletions(-) create mode 100644 app/models/licencia.rb create mode 100644 db/migrate/20190718185817_create_licencias.rb create mode 100644 db/migrate/20190719221653_add_icons_to_licenses.rb create mode 100644 db/seeds/licencias.yml create mode 100644 test/factories/licencia.rb create mode 100644 test/models/licencia_test.rb diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index f3e2a6de..d13d3030 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -137,6 +137,6 @@ class SitesController < ApplicationController private def site_params - params.require(:site).permit(:name, :design_id) + params.require(:site).permit(:name, :design_id, :licencia_id) end end diff --git a/app/models/licencia.rb b/app/models/licencia.rb new file mode 100644 index 00000000..c0eb1c80 --- /dev/null +++ b/app/models/licencia.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Las licencias son completamente traducibles +class Licencia < ApplicationRecord + extend Mobility + + translates :name, type: :string, locale_accessors: true + translates :url, type: :string, locale_accessors: true + translates :description, type: :text, locale_accessors: true + translates :deed, type: :text, locale_accessors: true + + has_many :sites + + validates :name, presence: true, uniqueness: true + validates :url, presence: true + validates :description, presence: true + validates :deed, presence: true +end diff --git a/app/models/site.rb b/app/models/site.rb index 0ed363a8..607bbbf4 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -11,6 +11,7 @@ class Site < ApplicationRecord friendly_id :name, use: %i[finders] belongs_to :design + belongs_to :licencia has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index 62a4533c..35b309ea 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -7,6 +7,8 @@ ActiveSupport::Inflector.inflections(:en) do |inflect| inflect.singular 'invitades', 'invitade' inflect.plural 'usuarie', 'usuaries' inflect.singular 'usuaries', 'usuarie' + inflect.plural 'licencia', 'licencias' + inflect.singular 'licencias', 'licencia' inflect.plural 'rol', 'roles' inflect.singular 'roles', 'rol' end @@ -20,4 +22,6 @@ ActiveSupport::Inflector.inflections(:es) do |inflect| inflect.singular 'usuaries', 'usuarie' inflect.plural 'rol', 'roles' inflect.singular 'roles', 'rol' + inflect.plural 'licencia', 'licencias' + inflect.singular 'licencias', 'licencia' end diff --git a/db/migrate/20190718185817_create_licencias.rb b/db/migrate/20190718185817_create_licencias.rb new file mode 100644 index 00000000..11232d51 --- /dev/null +++ b/db/migrate/20190718185817_create_licencias.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Crea la tabla de licencias +class CreateLicencias < ActiveRecord::Migration[5.2] + def change + create_table :licencias do |t| + t.timestamps + t.string :name, unique: true + t.text :description + t.text :deed + t.string :url + end + + add_belongs_to :sites, :licencia, index: true + end +end diff --git a/db/migrate/20190719221653_add_icons_to_licenses.rb b/db/migrate/20190719221653_add_icons_to_licenses.rb new file mode 100644 index 00000000..650b1481 --- /dev/null +++ b/db/migrate/20190719221653_add_icons_to_licenses.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Agrega íconos a las licencias +class AddIconsToLicenses < ActiveRecord::Migration[5.2] + def change + # XXX: Cambiar por ActiveStorage? + add_column :licencias, :icons, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 0d9fb79f..edcf00e3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_717_214_308) do +ActiveRecord::Schema.define(version: 20_190_719_221_653) do create_table 'designs', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false @@ -24,6 +24,16 @@ ActiveRecord::Schema.define(version: 20_190_717_214_308) do t.boolean 'disabled', default: false end + create_table 'licencias', force: :cascade do |t| + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.string 'name' + t.text 'description' + t.text 'deed' + t.string 'url' + t.string 'icons' + end + create_table 'mobility_string_translations', force: :cascade do |t| t.string 'locale', null: false t.string 'key', null: false @@ -66,7 +76,9 @@ ActiveRecord::Schema.define(version: 20_190_717_214_308) do t.datetime 'updated_at', null: false t.string 'name' t.integer 'design_id' + t.integer 'licencia_id' t.index ['design_id'], name: 'index_sites_on_design_id' + t.index ['licencia_id'], name: 'index_sites_on_licencia_id' t.index ['name'], name: 'index_sites_on_name', unique: true end diff --git a/db/seeds.rb b/db/seeds.rb index 9e3d2c5b..2e91b9d1 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,3 +7,11 @@ designs.each do |d| design.update_attributes d end + +licencias = YAML.safe_load(File.read('db/seeds/licencias.yml')) + +licencias.each do |l| + licencia = Licencia.find_or_create_by(icons: l['icons']) + + licencia.update_attributes l +end diff --git a/db/seeds/licencias.yml b/db/seeds/licencias.yml new file mode 100644 index 00000000..6995ab07 --- /dev/null +++ b/db/seeds/licencias.yml @@ -0,0 +1,290 @@ +--- +- name_en: 'Peer Production License' + name_es: 'Licencia de Producción de Pares' + icons: "/public/ppl.png" + url_en: 'https://wiki.p2pfoundation.net/Peer_Production_License' + url_es: 'https://endefensadelsl.org/ppl_es.html' + description_en: 'The Peer Production License is a license that allows use for any purpose under the same terms, except for commercial purposes, which are only allowed for collectives, cooperatives and other worker-owned "enterprises". We recommend this license if you are inclined towards non-profit terms, since it allows the development of more ethical economies while fending off capitalistic for-profit uses. If you want to know more about it, we invite you to read [The Telekommunist Manifesto](http://networkcultures.org/blog/publication/no-03-the-telekommunist-manifesto-dmytri-kleiner/)' + description_es: 'La licencia de Producción de Pares permite el uso con cualquier propósito bajo la misma licencia, a excepción de los usos comerciales que solo están permitidos a colectivos, cooperativas y otras "empresas" en manos de sus trabajadorxs. Recomendamos esta licencia si te estabas inclinando hacia términos sin fines de lucro, ya que permite el desarrollo de economías más éticas al mismo tiempo que impide la explotación capitalista. Si te interesa saber más, te invitamos a leer [El manifiesto telecomunista](https://endefensadelsl.org/manifiesto_telecomunista.html).' + deed_en: | + # Peer Production License (human-readable version) + + This is a human-readable summary of the [full + license](https://wiki.p2pfoundation.net/Peer_Production_License). + + ## You are free to + + * **Share** -- copy and redistribute the material in any medium or + format + + * **Adapt** -- remix, transform, and build upon the material + + ## Under the following terms: + + * **Attribution** -- You must give appropriate credit, provide a link + to the license, and indicate if changes were made. You may do so in + any reasonable manner, but not in any way that suggests the licensor + endorses you or your use. + + * **ShareAlike** -- If you remix, transform, or build upon the + material, you must distribute your contributions under the same + license as the original. + + * **Non-Capitalist** -- Commercial exploitation of this work is only + allowed to cooperatives, non-profit organizations and collectives, + worker-owned organizations, and any organization without exploitation + relations. Any surplus value obtained by the exercise of the rights + given by this work's license must be distributed by and amongst + workers.' + + ## Notices: + + * You do not have to comply with the license for elements of the + material in the public domain or where your use is permitted by an + applicable exception or limitation. + + * No warranties are given. The license may not give you all of the + permissions necessary for your intended use. For example, other rights + such as publicity, privacy, or moral rights may limit how you use the + material. + deed_es: | + # Licencia de producción de pares (versión legible por humanas) + + Esto es un resumen legible por humanas del [texto legal (la licencia + completa)](http://endefensadelsl.org/ppl_es.html) + + ## Ud. es libre de + + * Compartir - copiar, distribuir, ejecutar y comunicar públicamente la obra + * Hacer obras derivadas + + ## Bajo las condiciones siguientes: + + * Atribución - Debe reconocer los créditos de la obra de la manera + especificada por el autor o el licenciante (pero no de una manera que + sugiera que tiene su apoyo o que apoyan el uso que hace de su obra). + + * Compartir bajo la Misma Licencia - Si altera o transforma esta obra, + o genera una obra derivada, sólo puede distribuir la obra generada + bajo una licencia idéntica a ésta. + + * No Capitalista - La explotación comercial de esta obra sólo está + permitida a cooperativas, organizaciones y colectivos sin fines de + lucro, a organizaciones de trabajadores autogestionados, y donde no + existan relaciones de explotación. Todo excedente o plusvalía + obtenidos por el ejercicio de los derechos concedidos por esta + Licencia sobre la Obra deben ser distribuidos por y entre los + trabajadores. + + ## Entendiendo que + + * Renuncia - Alguna de estas condiciones puede no aplicarse si se + obtiene el permiso del titular de los derechos de autor. + + * Dominio Público - Cuando la obra o alguno de sus elementos se halle + en el dominio público según la ley vigente aplicable, esta situación + no quedará afectada por la licencia. + + * Otros derechos - Los derechos siguientes no quedan afectados por la + licencia de ninguna manera: + + * Los derechos derivados de usos legítimos u otras limitaciones + reconocidas por ley no se ven afectados por lo anterior; + + * Los derechos morales del autor; + + * Derechos que pueden ostentar otras personas sobre la propia obra o + su uso, como por ejemplo derechos de imagen o de privacidad. + + * Aviso - Al reutilizar o distribuir la obra, tiene que dejar muy en + claro los términos de la licencia de esta obra. La mejor forma de + hacerlo es enlazar a esta página. + +- icons: "/public/by.png" + name_en: 'Attribution 4.0 International (CC BY 4.0)' + description_en: "This license gives everyone the freedom to use, + adapt, and redistribute the contents of your site by requiring + attribution only. We recommend this license if you're publishing + articles that require maximum diffusion, even in commercial media, but + you want to be attributed. Users of the site will have to mention the + source and indicate if they made changes to it." + url_en: 'https://creativecommons.org/licenses/by/4.0/' + name_es: 'Atribución 4.0 Internacional (CC BY 4.0)' + description_es: "Esta licencia permite a todes la libertad de usar, + adaptar/modificar y redistribuir los contenidos de tu sitio con la + única condición de atribuirte la autoría original. Recomendamos esta + licencia si vas a publicar artículos que requieran máxima difusión, + incluso en medios comerciales, pero querés que te atribuyan el + trabajo. Les usuaries de este sitio deberán mencionar la fuente e + indicar si hicieron cambios." + url_es: 'https://creativecommons.org/licenses/by/4.0/deed.es' + deed_en: | + This is a human-readable summary of (and not a substitute for) the + [license](https://creativecommons.org/licenses/by/4.0/legalcode). + + # You are free to: + + * **Share** -- copy and redistribute the material in any medium or + format + + * **Adapt** -- remix, transform, and build upon the material for any + purpose, even commercially. + + The licensor cannot revoke these freedoms as long as you follow the + license terms. + + # Under the following terms: + + * **Attribution** -- You must give appropriate credit, provide a link to + the license, and indicate if changes were made. You may do so in any + reasonable manner, but not in any way that suggests the licensor + endorses you or your use. + + * **No additional restrictions** -- You may not apply legal terms or + technological measures that legally restrict others from doing + anything the license permits. + + # Notices: + + You do not have to comply with the license for elements of the material + in the public domain or where your use is permitted by an applicable + exception or limitation. + + No warranties are given. The license may not give you all of the + permissions necessary for your intended use. For example, other rights + such as publicity, privacy, or moral rights may limit how you use the + material. + deed_es: | + Este es un resumen legible por humanes (y no un sustituto) de la + [licencia](https://creativecommons.org/licenses/by/4.0/legalcode). + + # Usted es libre de: + + * **Compartir** -- copiar y redistribuir el material en cualquier medio o + formato. + + * **Adaptar** -- remezclar, transformar y construir a partir del material + para cualquier propósito, incluso comercialmente. + + La licenciante no puede revocar estas libertades en tanto usted siga los + términos de la licencia. + + # Bajo los siguientes términos: + + * **Atribución** -- Usted debe dar crédito de manera adecuada, brindar + un enlace a la licencia, e indicar si se han realizado cambios. Puede + hacerlo en cualquier forma razonable, pero no de forma tal que sugiera + que usted o su uso tienen el apoyo de la licenciante. + + * **No hay restricciones adicionales** -- No puede aplicar términos + legales ni medidas tecnológicas que restrinjan legalmente a otras a + hacer cualquier uso permitido por la licencia. + + # Avisos: + + No tiene que cumplir con la licencia para elementos del materiale en el + dominio público o cuando su uso esté permitido por una excepción o + limitación aplicable. + + No se dan garantías. La licencia podría no darle todos los permisos que + necesita para el uso que tenga previsto. Por ejemplo, otros derechos como + publicidad, privacidad, o derechos morales pueden limitar la forma en que + utilice el material. +- icons: "/public/bysa.png" + name_en: "Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)" + name_es: "Atribución-CompartirIgual 4.0 Internacional (CC BY-SA 4.0)" + url_en: 'https://creativecommons.org/licenses/by-sa/4.0/' + url_es: 'https://creativecommons.org/licenses/by-sa/4.0/deed.es' + description_en: "This license is the same as the CC-BY 4.0 but it adds + a requirement of sharing the work and its derivatives under the same + license. This is a reciprocitary, _copyleft_, license that keeps + culture free. Though commercial uses are allowed, they must be shared + under the same license, so any modifications done for profit are free + as well." + description_es: "Esta licencia es igual que la CC-BY 4.0 con el + requisito agregado de compartir la obra y sus obras derivadas con la + misma licencia. Esta es una licencia reciprocitaria, _copyleft_, que + mantiene y profundiza la cultura libre. Aunque los usos comerciales + están permitidos, las mejoras hechas con fines de lucro deben ser + compartidas bajo la misma licencia." + deed_en: | + This is a human-readable summary of (and not a substitute for) the + [license](https://creativecommons.org/licenses/by-sa/4.0/legalcode). + + # You are free to: + + * **Share** -- copy and redistribute the material in any medium or + format + + * **Adapt** -- remix, transform, and build upon the material for any + purpose, even commercially. + + The licensor cannot revoke these freedoms as long as you follow the + license terms. + + # Under the following terms: + + * **Attribution** -- You must give appropriate credit, provide a link to + the license, and indicate if changes were made. You may do so in any + reasonable manner, but not in any way that suggests the licensor + endorses you or your use. + + * **ShareAlike** -- If you remix, transform, or build upon the + material, you must distribute your contributions under the same + license as the original. + + + * **No additional restrictions** -- You may not apply legal terms or + technological measures that legally restrict others from doing + anything the license permits. + + # Notices: + + You do not have to comply with the license for elements of the material + in the public domain or where your use is permitted by an applicable + exception or limitation. + + No warranties are given. The license may not give you all of the + permissions necessary for your intended use. For example, other rights + such as publicity, privacy, or moral rights may limit how you use the + material. + deed_es: | + Este es un resumen legible por humanes (y no un sustituto) de la + [licencia](https://creativecommons.org/licenses/by/4.0/legalcode). + + # Usted es libre de: + + * **Compartir** -- copiar y redistribuir el material en cualquier medio o + formato. + + * **Adaptar** -- remezclar, transformar y construir a partir del material + para cualquier propósito, incluso comercialmente. + + La licenciante no puede revocar estas libertades en tanto usted siga los + términos de la licencia. + + # Bajo los siguientes términos: + + * **Atribución** -- Usted debe dar crédito de manera adecuada, brindar + un enlace a la licencia, e indicar si se han realizado cambios. Puede + hacerlo en cualquier forma razonable, pero no de forma tal que sugiera + que usted o su uso tienen el apoyo de la licenciante. + + * **CompartirIgual** -- Si remezcla, transforma o crea a partir del + material, debe distribuir su contribución bajo la lamisma licencia del + original. + + * **No hay restricciones adicionales** -- No puede aplicar términos + legales ni medidas tecnológicas que restrinjan legalmente a otras a + hacer cualquier uso permitido por la licencia. + + # Avisos: + + No tiene que cumplir con la licencia para elementos del materiale en el + dominio público o cuando su uso esté permitido por una excepción o + limitación aplicable. + + No se dan garantías. La licencia podría no darle todos los permisos que + necesita para el uso que tenga previsto. Por ejemplo, otros derechos como + publicidad, privacidad, o derechos morales pueden limitar la forma en que + utilice el material. diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index 058d20b1..5796ea9c 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -34,7 +34,8 @@ class SitesControllerTest < ActionDispatch::IntegrationTest post sites_url, headers: @authorization, params: { site: { name: name, - design_id: create(:design).id + design_id: create(:design).id, + licencia_id: create(:licencia).id } } diff --git a/test/factories/licencia.rb b/test/factories/licencia.rb new file mode 100644 index 00000000..e221e61c --- /dev/null +++ b/test/factories/licencia.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :licencia do + name { SecureRandom.hex } + description { SecureRandom.hex } + url { SecureRandom.hex } + deed { SecureRandom.hex } + icons { SecureRandom.hex } + end +end diff --git a/test/factories/site.rb b/test/factories/site.rb index 3593c9fc..95cce4e8 100644 --- a/test/factories/site.rb +++ b/test/factories/site.rb @@ -4,5 +4,6 @@ FactoryBot.define do factory :site do name { "test-#{SecureRandom.hex}" } design + licencia end end diff --git a/test/models/licencia_test.rb b/test/models/licencia_test.rb new file mode 100644 index 00000000..949606a9 --- /dev/null +++ b/test/models/licencia_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class LicenciaTest < ActiveSupport::TestCase + test 'se pueden crear' do + licencia = build :licencia + + assert licencia.valid? + end +end From 0b7531ffa8f1e5871ff255ad798b06856f9dd984 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 19 Jul 2019 21:33:10 -0300 Subject: [PATCH 15/44] elegir licencia --- app/views/sites/_form.haml | 21 +++++++++++++++++++++ config/locales/en.yml | 7 +++++++ config/locales/es.yml | 7 +++++++ db/seeds/licencias.yml | 6 +++--- public/images/by.png | Bin 0 -> 1377 bytes public/images/ppl.png | Bin 0 -> 1933 bytes public/images/sa.png | Bin 0 -> 1704 bytes test/factories/licencia.rb | 2 +- 8 files changed, 39 insertions(+), 4 deletions(-) create mode 100644 public/images/by.png create mode 100644 public/images/ppl.png create mode 100644 public/images/sa.png diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index 927cd2ed..db9808c5 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -25,5 +25,26 @@ - if design.license = link_to t('.design.license'), design.license, target: '_blank', class: 'btn btn-info' + .form-group + %h2= t('.licencia.title') + %p.lead= t('.help.licencia') + - Licencia.all.each do |licencia| + .row + .col + %h3 + = f.radio_button :licencia_id, licencia.id, + checked: licencia.id == site.licencia_id + = f.label "licencia_id_#{licencia.id}" do + = image_tag licencia.icons, alt: licencia.name + = licencia.name + = sanitize_markdown licencia.description, + tags: %w[p a strong em ul ol li h1 h2 h3 h4 h5 h6] + + .btn-group{ role: 'group', 'aria-label': t('.licencia.actions') } + = link_to t('.licencia.url'), licencia.url, + target: '_blank', class: 'btn btn-info' + + %hr/ + .form-group = f.submit submit, class: 'btn btn-success' diff --git a/config/locales/en.yml b/config/locales/en.yml index cdb5a457..f081e82c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -162,11 +162,18 @@ en: help: name: "Your site's name. It can only contain numbers and letters." design: 'Select the design for your site. You can change it later. We add more designs from time to time.' + licencia: 'Everything we publish has automatic copyright. This + means nobody can use our works without explicit permission. By + using licenses, we stablish conditions by which we want to share + them.' design: title: 'Design' actions: 'Information about this design' url: 'Preview' licencia: 'Read the license' + licencia: + title: 'License for the site and everything in it' + url: 'Read the license' fetch: title: 'Upgrade the site' help: diff --git a/config/locales/es.yml b/config/locales/es.yml index 5cf84bd1..fd5a8c28 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -167,11 +167,18 @@ es: help: name: 'El nombre de tu sitio. Solo puede contener letras y números.' design: 'Elegí el diseño que va a tener tu sitio aquí. Podés cambiarlo luego. De tanto en tanto vamos sumando diseños nuevos.' + licencia: 'Todo lo que publicamos posee automáticamente derechos + de autore. Esto significa que nadie puede hacer uso de nuestras + obras sin permiso explícito. Con las licencias establecemos + condiciones bajo las que queremos compartir.' design: title: 'Diseño' actions: 'Información sobre este diseño' url: 'Vista previa' licencia: 'Leer la licencia' + licencia: + title: 'Licencia del sitio y todo lo que se publique' + url: 'Leer la licencia' fetch: title: 'Actualizar el sitio' help: diff --git a/db/seeds/licencias.yml b/db/seeds/licencias.yml index 6995ab07..7e04067e 100644 --- a/db/seeds/licencias.yml +++ b/db/seeds/licencias.yml @@ -1,7 +1,7 @@ --- - name_en: 'Peer Production License' name_es: 'Licencia de Producción de Pares' - icons: "/public/ppl.png" + icons: "/images/ppl.png" url_en: 'https://wiki.p2pfoundation.net/Peer_Production_License' url_es: 'https://endefensadelsl.org/ppl_es.html' description_en: 'The Peer Production License is a license that allows use for any purpose under the same terms, except for commercial purposes, which are only allowed for collectives, cooperatives and other worker-owned "enterprises". We recommend this license if you are inclined towards non-profit terms, since it allows the development of more ethical economies while fending off capitalistic for-profit uses. If you want to know more about it, we invite you to read [The Telekommunist Manifesto](http://networkcultures.org/blog/publication/no-03-the-telekommunist-manifesto-dmytri-kleiner/)' @@ -100,7 +100,7 @@ claro los términos de la licencia de esta obra. La mejor forma de hacerlo es enlazar a esta página. -- icons: "/public/by.png" +- icons: "/images/by.png" name_en: 'Attribution 4.0 International (CC BY 4.0)' description_en: "This license gives everyone the freedom to use, adapt, and redistribute the contents of your site by requiring @@ -190,7 +190,7 @@ necesita para el uso que tenga previsto. Por ejemplo, otros derechos como publicidad, privacidad, o derechos morales pueden limitar la forma en que utilice el material. -- icons: "/public/bysa.png" +- icons: "/images/sa.png" name_en: "Attribution-ShareAlike 4.0 International (CC BY-SA 4.0)" name_es: "Atribución-CompartirIgual 4.0 Internacional (CC BY-SA 4.0)" url_en: 'https://creativecommons.org/licenses/by-sa/4.0/' diff --git a/public/images/by.png b/public/images/by.png new file mode 100644 index 0000000000000000000000000000000000000000..5ddc7400294d15080396b1b34e43c9a3079bf620 GIT binary patch literal 1377 zcmV-n1)lneP)cL{CFqybw9`tVJ@hc9{+&paIYfSP5bOURgz~p1t z7;HA*Vk<|fVy9TncypvOMA<=^f)9*Arn8v6v}Xe>;q7KXpJRq9Of>fE)LgSiJ6-RV3>cZz*blQiUDl7iS!@gi*(JBXj z#Ugn3t(MV-e77Z#u6-`}+KgjR{VT zrD|9|sJ%!v;_MWw%;YJB>Dy?6)2N{9(@*=)^f33zGwKVa)uhV=r%Q1i(k+a-0limz zxQwc{S_kv2vQjsKj)*X6;`SPlpsS`Ft9aZ~j-!(#*T_QO5#21^ww^A!Sl*~kKJ@aY9Sz!8Nw<#PLs?-RF(A0D+o z3{AaAAjisQ#(2IDa6_&pIj4x zY%#W7V zRT03+7Bz=}c6&=;uyI}$H66eut+*`$INLVtM4$-J1^H=$e`1+^`Yhu&jN_!G0&0 zI7PBH$-!oD{n!iD2PW$;!-$bJl6`ob^|ja0WS7 z)b(T91kMgITuzv9cF4MY+9@Z^@EQYlV%)aIv zBg}5q`@VOKUGNCMcZkx)r_4HapZ3bMwwLu?27*~iR|BGTb3LQYU`p*>&H_vEpE1l7 zuH$}QR{-&_h52$aX~M7Lc{s2noG_m|FiJVRp3`Je!X~95ws9h^|GxMG+PtOy)v13Q>VN96|@&@Sq26 j_y&7XfkI@%qc5ZYQ-jK8yTspcqa{f9rX4}@>Sp01g$8A^1&acT9yYSkPt zCOv9stx;m!r(b%tmMGQD2~&RG)V`KOh6)=l-i{8=|n{f@c$t}DGci}t=JYi>a znR!m`#u@985BsDLdNdLsjY-$L*o-qiRsKf7cm#Fw7>giZnSBako7n^tbWCd67Qdl( zavhyyKz@JQ0JAJc`X+smf!a$Bd}TnhPFaCjH{QiaO{Sp;+DIuvBsi~N$uutX zqXtnhLQB~J2jahxQ85k~90%r7m}SsjcF6*xLZn{!B^@{l<(h(&nEr-JfC8=vVDv5? z<@_Kow%7UwR? z+}{KF@RY3(M+D$FN(CSlNX7zGCE>=%Gp{nb?8l`^GqGvgv}Hc0Z0L1v8`06=FUXE%f=8Y<|%<9yB~ z8sAI~W_vtPTRiB1hcU#Kdp3v_2I?otCqi?3Y^|D zzmY`0ZX#ohv5-A{=MMs$mYn_}0APp;oJg79b3`9>j4{U8h)ll3dM?1>CtWH4z(WPj z*Mg|joYVKuV~put8gUV4vH<1pDjb6_oAKW{_z>fZ^wz=zc$GLkLZY-iZMe{T z8*aqRbEHh~D>OJuoPLX95-)6T^-(DB-iI48;WitZWqBe4$B5H-90r3QK12#0A_EyX ziya8Eu-4i^hs9V8Ju(H}C-C3z3FFUd7720o6X!b+C)`(5;smCUna~iUu+8cwaRF!1 zvyky8HU^===LBb~Fcj2G%~V`MA`anKlyM`|IMG002xOI8)5A+Q-~ig>w^b)ms%)f{ftolyLgexHJ8dHItD9 zXEXpHg%ssG0sO)B1OWIK&73tjTM-gtjz6REsW|HylBXuGJaNyF@?nmf{|r-@Kl$Bb;0`C3jdXdIB~jq)f-~&^ed4!cElNzVO$;H z3%>LEFbwJyo%FYr?jfyeE5~%rp9G4%zlX;!0%9L>y8ab5#FR1-~yu1O))xqm987h)Jbeh-%QJ) z-vvJKg%07+VFWrW-bR?WMcIl$&o&9$?p3Ny*77P1`-s(E^v=Nt?ESwTX=`0Ef1jZN z(@J99bjgj4>QI^)XP=C9Wm?O|u0e_Y@*V8}al+u7c?p|3u)32?okHVAK5Am+OSPyR zlODD`#+|(!(Kn2U2-%}MJ^6-jX0Jkz@+QU(Wqk@pKJvdd`{cs-$WS{XE|LEMZZXMM TGVYXO00000NkvXXu0mjfQ`&-r literal 0 HcmV?d00001 diff --git a/public/images/sa.png b/public/images/sa.png new file mode 100644 index 0000000000000000000000000000000000000000..bb23f8c624d9e064a8c62168f7f7f55e380c3ba3 GIT binary patch literal 1704 zcmV;Z23PrsP)j6nvqMLnTX3~ ztj(t72TG=^VN218WYbJ7L=;UFKVCqt{$TW;^PF>@=XpQuK7Tzg*SXKn=i|Qba~}c! zEEcJlgB2*mUK~d?>QRL<>_!1TL>4BX6aFWA91Bs3CeU`=Ln+?FAcWjMkil4szZ@{E zqY@i1GWaNc@U`O|F^`=X7(A3%e1-Wb2_t`&m7L=%N)Vp47Wo%ivj+^3`LdRX;-H6Th>u;9`0oZdzpZ4V2nR7lW<3M z7RiM?!X~=03g$9S{~o>V8%hY4sBYSDHf!jc-C_(X$EdJ9Hj!ybAm~*MwvujWpK>H*VLDKoE=Oj zoRcR^Wj%vwxeSqk>X2Z9X^TTL%&QE(t+3vq3#MWuOBN2{9hXa7X9cU+;8H!!3ba-S z6Nws8)QuM%+#cq~oX$jc`EO1X2k~A0#Cx{$>X|6xG@y@)vs9FX^H*D!+d14OGj-&0 z-nN;2m3`zH+pI8MaZ40zv2k{p$u9DDS8lLLCKYTWs+%xa##t}A8fgRb3u9fE6sf#x z^fOD=?h|49$>mk4(McJjeX4Or8lzSWu*5vEAHSZ8K%6~AFIKjK6~(PjEDmdUyyn($X* z#Cc9rjOFo`MYTkO6U7@^kL9v1rx~Zj`%|yJtCLc3A+XzfnQU;a1G0x9`zbTN)9O-0%^u*Gn-FLP za)Kd~T)xb2ikjoVCGX`T8}}lRGYpxg;H=c@7KxfmK)v@e-*$l(AU81NI||Nxtu9~G z)VsbrEkN#IEDwPoxWM`DRJ(k4yg=??$T2RXxP*Pe)tBXu4ODOnoxiMoF5-@V7Ny|q z(&|Qtnype)jt?5l6;0UGZXA>m9j2Tv(I{f=M0mVYO9_ICh>LVa0y?~#G2-Pgns9Co83iS zE!t>zp_2_b4NUN3(-*=W27k+CWszZz9>OCA2gj6(*?BBA_^M%~tdx53>%>2eHh#iC z>zmew5z2K&PqSt1A-SOvCwEMXjBZNV-z7|hAxn|uN$VzZxV7OVwf)=ksL@pe*D%Hb zr8U#}w@pT~M5gU(GcE#`MM*bS+Qi*|n5EhJwtW6mc*-9;nyc>XarEaUPvbjqYgt zE{n$*57GRwMr+En?YcK7jBE0Q1YQUd%ym9*g=s>v(;>;z;#LO)yYO%jU{3Q<^|fE% z<_Ia>IxUO^0mAHL2NmW^x2Js{=(6%bKT*o~tS%rFBh3Br<236d1HqSg(npl@OjGJe z5X2gU`gT0~6t3u^?PP9uh)2!l;a<*=n@QfS?+o~1@i?K6yI~x|^~MvOtK7l~=G Date: Fri, 19 Jul 2019 21:34:37 -0300 Subject: [PATCH 16/44] no usar lenguaje capacitista --- config/locales/en.yml | 2 +- config/locales/es.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/locales/en.yml b/config/locales/en.yml index f081e82c..cbc036a1 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -169,7 +169,7 @@ en: design: title: 'Design' actions: 'Information about this design' - url: 'Preview' + url: 'Demo' licencia: 'Read the license' licencia: title: 'License for the site and everything in it' diff --git a/config/locales/es.yml b/config/locales/es.yml index fd5a8c28..b3a248c2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -174,8 +174,8 @@ es: design: title: 'Diseño' actions: 'Información sobre este diseño' - url: 'Vista previa' - licencia: 'Leer la licencia' + url: 'Demostración' + license: 'Leer la licencia' licencia: title: 'Licencia del sitio y todo lo que se publique' url: 'Leer la licencia' From 11cf2cb08bc124336204e64ae3ec8dee1f2f663b Mon Sep 17 00:00:00 2001 From: f Date: Wed, 24 Jul 2019 20:51:29 -0300 Subject: [PATCH 17/44] deployear los sitios de distintas formas --- Gemfile | 1 + Gemfile.lock | 1 + app/models/deploy.rb | 33 +++++++++ app/models/deploy_local.rb | 77 +++++++++++++++++++++ app/models/deploy_zip.rb | 47 +++++++++++++ app/models/site.rb | 1 + db/migrate/20190723220002_create_deploys.rb | 13 ++++ db/schema.rb | 12 +++- doc/crear_sitios.md | 50 +++++++++++++ test/factories/deploy_local.rb | 7 ++ test/factories/deploy_zip.rb | 7 ++ test/models/deploy_local_test.rb | 13 ++++ test/models/deploy_zip_test.rb | 21 ++++++ 13 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 app/models/deploy.rb create mode 100644 app/models/deploy_local.rb create mode 100644 app/models/deploy_zip.rb create mode 100644 db/migrate/20190723220002_create_deploys.rb create mode 100644 test/factories/deploy_local.rb create mode 100644 test/factories/deploy_zip.rb create mode 100644 test/models/deploy_local_test.rb create mode 100644 test/models/deploy_zip_test.rb diff --git a/Gemfile b/Gemfile index 537cabc5..67a8c4d8 100644 --- a/Gemfile +++ b/Gemfile @@ -54,6 +54,7 @@ gem 'mobility' gem 'pundit' gem 'rails-i18n' gem 'rails_warden' +gem 'rubyzip' gem 'rugged' gem 'validates_hostname' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 7941b035..4a2c2ff6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -423,6 +423,7 @@ DEPENDENCIES rails_warden rbnacl (< 5.0) rubocop-rails + rubyzip rugged sass-rails (~> 5.0) selenium-webdriver diff --git a/app/models/deploy.rb b/app/models/deploy.rb new file mode 100644 index 00000000..2fe5396d --- /dev/null +++ b/app/models/deploy.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'open3' +# Este modelo implementa los distintos tipos de alojamiento que provee +# Sutty. +# +# Los datos se guardan en la tabla `deploys`. Para guardar los +# atributos, cada modelo tiene que definir su propio `store +# :attributes`. +class Deploy < ApplicationRecord + belongs_to :site + + def deploy + raise NotImplementedError + end + + def limit + raise NotImplementedError + end + + # Corre un comando y devuelve true si terminó correctamente + def run(cmd) + r = 1 + + Dir.chdir(site.path) do + Open3.popen2(env, cmd, unsetenv_others: true) do |_, _o, t| + r = t.value + end + end + + r.exited? + end +end diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb new file mode 100644 index 00000000..330bd023 --- /dev/null +++ b/app/models/deploy_local.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# Alojamiento local, solo genera el sitio, con lo que no necesita hacer +# nada más +class DeployLocal < Deploy + store :values, accessors: %i[fqdn destination], coder: YAML + + before_create :fqdn!, :destination! + before_destroy :remove_destination! + + # Realizamos la construcción del sitio usando Jekyll y un entorno + # limpio para no pasarle secretos + # + # Pasamos variables de entorno mínimas para no filtrar secretos de + # Sutty + # + # TODO: Recolectar estadísticas y enviarlas a la base de datos + def deploy + yarn && bundle && jekyll_build + end + + # Sólo permitimos un deploy local + def limit + 1 + end + + private + + # Un entorno que solo tiene lo que necesitamos + def env + paths = [File.dirname(`which bundle`), '/usr/bin'] + + { 'PATH' => paths.join(':'), 'JEKYLL_ENV' => 'production' } + end + + def yarn_lock + File.join(site.path, 'yarn.lock') + end + + def yarn_lock? + File.exist? yarn_lock + end + + # Corre yarn dentro del repositorio + def yarn + return unless yarn_lock? + + run 'yarn' + end + + def bundle + run 'bundle' + end + + def jekyll_build + run "bundle exec jekyll build --destination \"#{escaped_destination}\"" + end + + def fqdn! + self.fqdn ||= "#{site.name}.#{ENV.fetch('SUTTY', 'sutty.nl')}" + end + + def destination! + self.destination ||= File.join(Rails.root, '_deploy', fqdn) + end + + # no debería haber espacios ni caracteres especiales, pero por si + # acaso... + def escaped_destination + Shellwords.escape destination + end + + # Eliminar el destino si se elimina el deploy + def remove_destination! + FileUtils.rm_rf destination + end +end diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb new file mode 100644 index 00000000..15c81e1b --- /dev/null +++ b/app/models/deploy_zip.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +# Genera un ZIP a partir del sitio ya construido +# +# TODO: Firmar con minisign +class DeployZip < Deploy + store :values, accessors: %i[fqdn destination file path], coder: YAML + + before_create :fqdn!, :destination! + before_create :file!, :path! + + # Una vez que el sitio está generado, tomar todos los archivos y + # y generar un zip accesible públicamente. + # + # TODO: Recolectar estadísticas y enviarlas a la base de datos + def deploy + 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) + end + end + end + + File.exist? path + end + + private + + # Copiamos de DeployLocal para no cargar todos los métodos de + # compilación... + def fqdn! + self.fqdn ||= "#{site.name}.#{ENV.fetch('SUTTY', 'sutty.nl')}" + end + + def destination! + self.destination ||= File.join(Rails.root, '_deploy', fqdn) + end + + def file! + self.file ||= "#{fqdn}.zip" + end + + def path! + self.path = File.join(destination, file) + end +end diff --git a/app/models/site.rb b/app/models/site.rb index 607bbbf4..bd3f01ed 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -13,6 +13,7 @@ class Site < ApplicationRecord belongs_to :design belongs_to :licencia + has_many :deploys has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles diff --git a/db/migrate/20190723220002_create_deploys.rb b/db/migrate/20190723220002_create_deploys.rb new file mode 100644 index 00000000..85a20259 --- /dev/null +++ b/db/migrate/20190723220002_create_deploys.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Crea la tabla de deploys posibles para un sitio +class CreateDeploys < ActiveRecord::Migration[5.2] + def change + create_table :deploys do |t| + t.timestamps + t.belongs_to :site, index: true + t.string :type, index: true + t.text :values + end + end +end diff --git a/db/schema.rb b/db/schema.rb index edcf00e3..28ca10a5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,17 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_719_221_653) do +ActiveRecord::Schema.define(version: 20_190_723_220_002) do + create_table 'deploys', force: :cascade do |t| + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.integer 'site_id' + t.string 'type' + t.text 'values' + t.index ['site_id'], name: 'index_deploys_on_site_id' + t.index ['type'], name: 'index_deploys_on_type' + end + create_table 'designs', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index b89e192a..92a309c6 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -106,6 +106,9 @@ falta (algunas plantillas tienen requisitos extraños). Las plantillas se instalan como gemas en los sitios, de forma que podemos cambiarla desde las opciones del sitio luego. +Para poder cambiar el diseño, necesitamos poder editar la configuración +del sitio en _config.yml y colocar el nombre de la gema en `theme:` + ## Internamente Al crear un sitio, clonar el esqueleto en el lugar correcto. Al @@ -162,3 +165,50 @@ actualizaciones muestra el historial para que les usuaries vean cuales son los cambios que se van a aplicar. Este historial viene del repositorio git con lo que tenemos que tomarnos la costumbre de escribir commits completos con explicación. + +## Alojamiento + +Para elegir las opciones de alojamiento, agregamos clases que +implementan el nombre del alojamiento y sus opciones específicas: + +* Local (sitio.sutty.nl): Deploy local. Genera el sitio en + _deploy/name.sutty.nl y lo pone a disposición del servidor web (es + como veniamos trabajando hasta ahora). También admite dominios + propios. + + Solo se puede tener uno y no es opcional porque lo necesitamos para + poder hacer el resto de deploys + +* Neocities: Deploy a neocities.org, necesita API key. Se conecta con + neocities usando la API y envía los archivos. + + Solo se puede tener uno (pendiente hablar con neocities si podemos + hacer esto). + +* Zip: genera un zip con el contenido del sitio y provee una url de + descarga estilo https://sutty.nl/sites/site.zip + + Solo se puede tener uno + +* SSH: Deploy al servidor SSH que especifiquemos. Necesita que le + pasemos user@host, puerto y ruta (quizás la ruta no es necesaria y + vaya junto con user@host). Informar a les usuaries la llave pública + de Sutty para que la agreguen. + + No hay límite a los servidores SSH que se pueden agregar. + +La clase Deploy::* tiene que implementar estas interfaces: + + * `#limit` devuelve un entero que es la cantidad de deploys de este + tipo que se pueden agregar, `nil` significa sin límite + * `#deploy` el método que realiza el deploy. En realidad este método + hay que correrlo por fuera de Sutty... + * `#attributes` los valores serializados que acepta el Deploy + +Son modelos que pueden guardar atributos en la base y a través de los +que se pueden tomar los datos a través de la API (cuando tengamos un +método de deployment). + +El plan es migrar todo esto a de forma que la compilación se +haga por separado de sutty. Este es un plan intermedio hasta que +tengamos tiempo de hacerlo realmente. diff --git a/test/factories/deploy_local.rb b/test/factories/deploy_local.rb new file mode 100644 index 00000000..e7441c36 --- /dev/null +++ b/test/factories/deploy_local.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :deploy_local do + site + end +end diff --git a/test/factories/deploy_zip.rb b/test/factories/deploy_zip.rb new file mode 100644 index 00000000..756be34b --- /dev/null +++ b/test/factories/deploy_zip.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :deploy_zip do + site + end +end diff --git a/test/models/deploy_local_test.rb b/test/models/deploy_local_test.rb new file mode 100644 index 00000000..39ba7019 --- /dev/null +++ b/test/models/deploy_local_test.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DeployZipTest < ActiveSupport::TestCase + test 'se puede deployear' do + deploy_local = create :deploy_local + + assert deploy_local.deploy + assert File.directory?(deploy_local.destination) + + assert deploy_local.destroy + assert_not File.directory?(deploy_local.destination) + end +end diff --git a/test/models/deploy_zip_test.rb b/test/models/deploy_zip_test.rb new file mode 100644 index 00000000..cbe55591 --- /dev/null +++ b/test/models/deploy_zip_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DeployLocalTest < ActiveSupport::TestCase + test 'se puede deployear' do + site = create :site + local = create :deploy_local, site: site + deploy = create :deploy_zip, site: site + + # Primero tenemos que generar el sitio + local.deploy + + escaped_path = Shellwords.escape(deploy.path) + + assert deploy.deploy + assert File.file?(deploy.path) + assert_equal 'application/zip', + `file --mime-type "#{escaped_path}"`.split(' ').last + + local.destroy + end +end From 7570abaf1aaffba7ee3ae798519614239ed85635 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 25 Jul 2019 14:25:00 -0300 Subject: [PATCH 18/44] serializar con json porque yaml es mas verborragico --- app/models/deploy_local.rb | 2 +- app/models/deploy_zip.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb index 330bd023..8ff40db3 100644 --- a/app/models/deploy_local.rb +++ b/app/models/deploy_local.rb @@ -3,7 +3,7 @@ # Alojamiento local, solo genera el sitio, con lo que no necesita hacer # nada más class DeployLocal < Deploy - store :values, accessors: %i[fqdn destination], coder: YAML + store :values, accessors: %i[fqdn destination], coder: JSON before_create :fqdn!, :destination! before_destroy :remove_destination! diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb index 15c81e1b..6804bb28 100644 --- a/app/models/deploy_zip.rb +++ b/app/models/deploy_zip.rb @@ -4,7 +4,7 @@ # # TODO: Firmar con minisign class DeployZip < Deploy - store :values, accessors: %i[fqdn destination file path], coder: YAML + store :values, accessors: %i[fqdn destination file path], coder: JSON before_create :fqdn!, :destination! before_create :file!, :path! From b561cacda4338b08d599ad0cf7546b53bbfe672e Mon Sep 17 00:00:00 2001 From: f Date: Thu, 25 Jul 2019 15:51:32 -0300 Subject: [PATCH 19/44] agregar distintas formas de deploy a un sitio --- .rubocop.yml | 1 + app/controllers/sites_controller.rb | 9 +++++- app/models/deploy_local.rb | 2 +- app/models/deploy_zip.rb | 2 +- app/models/site.rb | 21 ++++++++++++++ app/views/deploys/_deploy_local.haml | 14 +++++++++ app/views/deploys/_deploy_zip.haml | 21 ++++++++++++++ app/views/sites/_form.haml | 9 ++++++ config/locales/es.yml | 30 +++++++++++++++++++ test/controllers/sites_controller_test.rb | 35 ++++++++++++++++++++--- test/factories/site.rb | 8 ++++++ 11 files changed, 145 insertions(+), 7 deletions(-) create mode 100644 app/views/deploys/_deploy_local.haml create mode 100644 app/views/deploys/_deploy_zip.haml diff --git a/.rubocop.yml b/.rubocop.yml index f9765ba7..8df03292 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -44,6 +44,7 @@ Metrics/ClassLength: Exclude: - 'app/models/site.rb' - 'app/controllers/posts_controller.rb' + - 'app/controllers/sites_controller.rb' Lint/HandleExceptions: Exclude: diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index d13d3030..c5008abd 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -23,6 +23,9 @@ class SitesController < ApplicationController def new @site = Site.new authorize @site + + @site.deploys.build type: 'DeployLocal' + @site.deploys.build type: 'DeployZip' end def create @@ -137,6 +140,10 @@ class SitesController < ApplicationController private def site_params - params.require(:site).permit(:name, :design_id, :licencia_id) + params.require(:site) + .permit(:name, :design_id, :licencia_id, + deploys_attributes: %i[ + type id _destroy + ]) end end diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb index 8ff40db3..10c21bd3 100644 --- a/app/models/deploy_local.rb +++ b/app/models/deploy_local.rb @@ -57,7 +57,7 @@ class DeployLocal < Deploy end def fqdn! - self.fqdn ||= "#{site.name}.#{ENV.fetch('SUTTY', 'sutty.nl')}" + self.fqdn ||= "#{site.name}.#{Site.domain}" end def destination! diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb index 6804bb28..0ec8afcc 100644 --- a/app/models/deploy_zip.rb +++ b/app/models/deploy_zip.rb @@ -30,7 +30,7 @@ class DeployZip < Deploy # Copiamos de DeployLocal para no cargar todos los métodos de # compilación... def fqdn! - self.fqdn ||= "#{site.name}.#{ENV.fetch('SUTTY', 'sutty.nl')}" + self.fqdn ||= "#{site.name}.#{Site.domain}" end def destination! diff --git a/app/models/site.rb b/app/models/site.rb index bd3f01ed..3235f3a7 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -8,6 +8,8 @@ class Site < ApplicationRecord validates :name, uniqueness: true, hostname: true validates :design_id, presence: true + validate :deploy_local_presence + friendly_id :name, use: %i[finders] belongs_to :design @@ -33,6 +35,8 @@ class Site < ApplicationRecord attr_accessor :jekyll, :collections + accepts_nested_attributes_for :deploys, allow_destroy: true + # El repositorio git para este sitio def repository @repository ||= Site::Repository.new path @@ -370,6 +374,10 @@ class Site < ApplicationRecord Jekyll::Site.new(config) end + def self.domain + ENV.fetch('SUTTY', 'sutty.nl') + end + private # Clona el esqueleto de Sutty para crear el sitio nuevo, no pasa nada @@ -399,4 +407,17 @@ class Site < ApplicationRecord FileUtils.mv old_path, path end + + # Valida si el sitio tiene al menos una forma de alojamiento asociada + # y es la local + # + # TODO: Volver opcional el alojamiento local, pero ahora mismo está + # atado a la generación del sitio así que no puede faltar + def deploy_local_presence + # Usamos size porque queremos saber la cantidad de deploys sin + # guardar también + return if deploys.size.positive? && deploys.map(&:type).include?('DeployLocal') + + errors.add(:deploys, I18n.t('activerecord.errors.models.site.attributes.deploys.deploy_local_presence')) + end end diff --git a/app/views/deploys/_deploy_local.haml b/app/views/deploys/_deploy_local.haml new file mode 100644 index 00000000..8c5f34e8 --- /dev/null +++ b/app/views/deploys/_deploy_local.haml @@ -0,0 +1,14 @@ +-# + Formulario para alojamiento local. Como el alojamiento local no es + opcional aun, solo enviamos el tipo con el formulario, no necesitamos + nada más. + +.row + .col + %h3= t('.title') + - name = site.name || t('.ejemplo') + = sanitize_markdown t('.help', + fqdn: deploy.object.fqdn || "#{name}.#{Site.domain}"), + tags: %w[p strong em a] + + = deploy.hidden_field :type diff --git a/app/views/deploys/_deploy_zip.haml b/app/views/deploys/_deploy_zip.haml new file mode 100644 index 00000000..11996b0e --- /dev/null +++ b/app/views/deploys/_deploy_zip.haml @@ -0,0 +1,21 @@ +-# Formulario para "alojar" en un zip + +.row + .col + = deploy.hidden_field :id + = deploy.hidden_field :type + %h3 + -# + 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? }, + '0', '1' + = deploy.label :_destroy, t('.title') + -# TODO: secar la generación de URLs + - name = site.name || t('.ejemplo') + = sanitize_markdown t('.help', + fqdn: deploy.object.fqdn || "#{name}.#{Site.domain}", + file: deploy.object.file || "#{name}.zip"), + tags: %w[p strong em a] diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index db9808c5..734e1e2c 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -46,5 +46,14 @@ %hr/ + .form-group + %h2= t('.deploys.title') + %p.lead= t('.help.deploys') + + = f.fields_for :deploys do |deploy| + = render "deploys/#{deploy.object.type.underscore}", + deploy: deploy, site: site + %hr/ + .form-group = f.submit submit, class: 'btn btn-success' diff --git a/config/locales/es.yml b/config/locales/es.yml index b3a248c2..5878d288 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -11,6 +11,10 @@ es: name: 'Nombre' errors: models: + site: + attributes: + deploys: + deploy_local_presence: '¡Necesitamos poder generar el sitio!' invitadx: attributes: email: @@ -145,6 +149,25 @@ es: lang: 'Idioma' logout: 'Salir' error: 'Hubo un error al iniciar la sesión. ¿Escribiste bien tus credenciales?' + deploys: + deploy_local: + title: 'Alojar en Sutty' + help: | + El sitio estará disponible en . + + Estamos desarrollando la posibilidad de agregar tus propios + dominios, ¡ayudanos! + ejemplo: 'ejemplo' + deploy_zip: + title: 'Generar un archivo ZIP' + help: | + Los archivos ZIP contienen y comprimen todos los archivos de tu + sitio. Con esta opción podrás descargar y compartir tu sitio + entero a través de la dirección y + guardarla como copia de seguridad o una estrategia de + alojamiento solidario, donde muchas personas comparten una copia + de tu sitio. + ejemplo: 'ejemplo' sites: actions: 'Acciones' posts: 'Ver y editar artículos' @@ -171,6 +194,11 @@ es: de autore. Esto significa que nadie puede hacer uso de nuestras obras sin permiso explícito. Con las licencias establecemos condiciones bajo las que queremos compartir.' + deploys: | + Sutty te permite alojar tu sitio en distintos lugares al mismo + tiempo. Esta estrategia facilita que el sitio esté disponible + aun cuando algunos de los alojamientos no funcionen. + design: title: 'Diseño' actions: 'Información sobre este diseño' @@ -179,6 +207,8 @@ es: licencia: title: 'Licencia del sitio y todo lo que se publique' url: 'Leer la licencia' + deploys: + title: '¿Dónde querés alojar tu sitio?' fetch: title: 'Actualizar el sitio' help: diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index 5796ea9c..72d3ebc9 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -35,13 +35,40 @@ class SitesControllerTest < ActionDispatch::IntegrationTest site: { name: name, design_id: create(:design).id, - licencia_id: create(:licencia).id + licencia_id: create(:licencia).id, + deploys_attributes: { + '0' => { + type: 'DeployLocal' + } + } } } - assert_nothing_raised do - site = Site.find_by_name(name) - site.destroy + site = Site.find_by_name(name) + + assert site + assert_equal @usuarie.email, site.roles.first.usuarie.email + assert_equal 'usuarie', site.roles.first.rol + + site.destroy + end + + test 'no se pueden crear con cualquier deploy' do + name = SecureRandom.hex + + assert_raise ActiveRecord::SubclassNotFound do + post sites_url, headers: @authorization, params: { + site: { + name: name, + design_id: create(:design).id, + licencia_id: create(:licencia).id, + deploys_attributes: { + '0' => { + type: 'DeployNoExiste' + } + } + } + } end end end diff --git a/test/factories/site.rb b/test/factories/site.rb index 95cce4e8..f59221e3 100644 --- a/test/factories/site.rb +++ b/test/factories/site.rb @@ -5,5 +5,13 @@ FactoryBot.define do name { "test-#{SecureRandom.hex}" } design licencia + + after :build do |site| + site.deploys << build(:deploy_local, site: site) + end + + after :create do |site| + site.deploys << create(:deploy_local, site: site) + end end end From c282870db61784d5482e3f1e8b64ef86e457aa6c Mon Sep 17 00:00:00 2001 From: f Date: Thu, 25 Jul 2019 20:57:21 -0300 Subject: [PATCH 20/44] traducciones faltantes --- config/locales/en.yml | 32 ++++++++++++++++++++++++++++++++ config/locales/es.yml | 2 ++ 2 files changed, 34 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index cbc036a1..957ac212 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2,6 +2,10 @@ en: activerecord: errors: models: + site: + attributes: + deploys: + deploy_local_presence: 'We need to be build the site!' invitadx: attributes: email: @@ -75,6 +79,27 @@ en: sesiones: 'Sessions' anexo: 'Appendix' simple: 'Simple' + deploys: + deploy_local: + title: 'Host at Sutty' + help: | + The site will be available at . + + We're working out the details for allowing your own site + domains, you can help us! + ejemplo: 'example' + deploy_zip: + title: 'Generate a ZIP file' + help: | + ZIP files contain and compress all the files of your site. With + this option you can download and also share your whole site + through the address, keep it as backup + or have an strategy of solidarity hosting, were many people + shares a copy of your site. + + It also helps with site archival for historical purposes :) + + ejemplo: 'ejemplo' sites: index: 'This is the list of sites you can edit.' edit_translations: "You can edit texts from your site other than @@ -166,6 +191,11 @@ en: means nobody can use our works without explicit permission. By using licenses, we stablish conditions by which we want to share them.' + deploys: | + Sutty allows you to host your site in different places at the + same time. This strategy makes your site available even when + some of them become unavailable. + design: title: 'Design' actions: 'Information about this design' @@ -174,6 +204,8 @@ en: licencia: title: 'License for the site and everything in it' url: 'Read the license' + deploys: + title: 'Where do you want your site to be hosted?' fetch: title: 'Upgrade the site' help: diff --git a/config/locales/es.yml b/config/locales/es.yml index 5878d288..4ddd36e0 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -167,6 +167,8 @@ es: guardarla como copia de seguridad o una estrategia de alojamiento solidario, donde muchas personas comparten una copia de tu sitio. + + También sirve para archivo histórico :) ejemplo: 'ejemplo' sites: actions: 'Acciones' From 25160186d851b78f36f1e3196f8c457ef5b063d3 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 25 Jul 2019 21:07:53 -0300 Subject: [PATCH 21/44] =?UTF-8?q?recopilar=20estadisticas=20de=20generaci?= =?UTF-8?q?=C3=B3n=20de=20sitios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/build_stat.rb | 6 +++ app/models/deploy.rb | 42 ++++++++++++++++--- app/models/deploy_local.rb | 11 +++++ app/models/deploy_zip.rb | 19 ++++++++- .../20190725185427_create_build_stats.rb | 14 +++++++ db/schema.rb | 13 +++++- test/models/deploy_local_test.rb | 4 ++ test/models/deploy_zip_test.rb | 3 ++ 8 files changed, 105 insertions(+), 7 deletions(-) create mode 100644 app/models/build_stat.rb create mode 100644 db/migrate/20190725185427_create_build_stats.rb diff --git a/app/models/build_stat.rb b/app/models/build_stat.rb new file mode 100644 index 00000000..c99bf2c3 --- /dev/null +++ b/app/models/build_stat.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +# Recolecta estadísticas durante la generación del sitio +class BuildStat < ApplicationRecord + belongs_to :deploy +end diff --git a/app/models/deploy.rb b/app/models/deploy.rb index 2fe5396d..e91a9d14 100644 --- a/app/models/deploy.rb +++ b/app/models/deploy.rb @@ -9,6 +9,7 @@ require 'open3' # :attributes`. class Deploy < ApplicationRecord belongs_to :site + has_many :build_stats def deploy raise NotImplementedError @@ -18,16 +19,47 @@ class Deploy < ApplicationRecord raise NotImplementedError end - # Corre un comando y devuelve true si terminó correctamente - def run(cmd) - r = 1 + def size + raise NotImplementedError + end + def time_start + @start = Time.now + end + + def time_stop + @stop = Time.now + end + + def time_spent_in_seconds + (@stop - @start).round(3) + end + + # Corre un comando y devuelve true si terminó correctamente + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize + def run(cmd) + # XXX: prestar atención a la concurrencia de sqlite3, se podría + # enviar los datos directamente a una API para que se manejen desde + # el proceso principal de rails y evitar problemas. + stat = build_stats.build action: cmd.split('-', 2).first.tr(' ', '_') + r = nil + + time_start Dir.chdir(site.path) do - Open3.popen2(env, cmd, unsetenv_others: true) do |_, _o, t| + Open3.popen2e(env, cmd, unsetenv_others: true) do |_, o, t| r = t.value + stat.log = o.read end end + time_stop - r.exited? + stat.seconds = time_spent_in_seconds + stat.bytes = size + stat.save + + r.try :exited? end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize end diff --git a/app/models/deploy_local.rb b/app/models/deploy_local.rb index 10c21bd3..86229736 100644 --- a/app/models/deploy_local.rb +++ b/app/models/deploy_local.rb @@ -24,10 +24,21 @@ class DeployLocal < Deploy 1 end + # Obtener el tamaño de todos los archivos y directorios (los + # directorios son archivos :) + def size + paths = [destination, File.join(destination, '**', '**')] + + Dir.glob(paths).map do |file| + File.size file + end.inject(:+) + end + private # Un entorno que solo tiene lo que necesitamos def env + # XXX: This doesn't support Windows paths :B paths = [File.dirname(`which bundle`), '/usr/bin'] { 'PATH' => paths.join(':'), 'JEKYLL_ENV' => 'production' } diff --git a/app/models/deploy_zip.rb b/app/models/deploy_zip.rb index 0ec8afcc..b5141529 100644 --- a/app/models/deploy_zip.rb +++ b/app/models/deploy_zip.rb @@ -12,8 +12,10 @@ class DeployZip < Deploy # Una vez que el sitio está generado, tomar todos los archivos y # y generar un zip accesible públicamente. # - # TODO: Recolectar estadísticas y enviarlas a la base de datos + # rubocop:disable Metrics/MethodLength + # rubocop:disable Metrics/AbcSize def deploy + time_start Dir.chdir(destination) do Zip::File.open(path, Zip::File::CREATE) do |z| Dir.glob('./**/**').each do |f| @@ -21,9 +23,24 @@ class DeployZip < Deploy end end end + time_stop + + build_stats.create action: 'zip', + seconds: time_spent_in_seconds, + bytes: size File.exist? path end + # rubocop:enable Metrics/MethodLength + # rubocop:enable Metrics/AbcSize + + def limit + 1 + end + + def size + File.size path + end private diff --git a/db/migrate/20190725185427_create_build_stats.rb b/db/migrate/20190725185427_create_build_stats.rb new file mode 100644 index 00000000..255ea285 --- /dev/null +++ b/db/migrate/20190725185427_create_build_stats.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateBuildStats < ActiveRecord::Migration[5.2] + def change + create_table :build_stats do |t| + t.timestamps + t.belongs_to :deploy, index: true + t.integer :bytes + t.float :seconds + t.string :action, null: false + t.text :log + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 28ca10a5..7e76955c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,18 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_723_220_002) do +ActiveRecord::Schema.define(version: 20_190_725_185_427) do + create_table 'build_stats', force: :cascade do |t| + t.datetime 'created_at', null: false + t.datetime 'updated_at', null: false + t.integer 'deploy_id' + t.integer 'bytes' + t.float 'seconds' + t.string 'action', null: false + t.text 'log' + t.index ['deploy_id'], name: 'index_build_stats_on_deploy_id' + end + create_table 'deploys', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false diff --git a/test/models/deploy_local_test.rb b/test/models/deploy_local_test.rb index 39ba7019..b992d8cc 100644 --- a/test/models/deploy_local_test.rb +++ b/test/models/deploy_local_test.rb @@ -6,6 +6,10 @@ class DeployZipTest < ActiveSupport::TestCase assert deploy_local.deploy assert File.directory?(deploy_local.destination) + assert_equal 3, deploy_local.build_stats.count + + assert deploy_local.build_stats.map(&:bytes).compact.inject(:+).positive? + assert deploy_local.build_stats.map(&:seconds).compact.inject(:+).positive? assert deploy_local.destroy assert_not File.directory?(deploy_local.destination) diff --git a/test/models/deploy_zip_test.rb b/test/models/deploy_zip_test.rb index cbe55591..7e8712d7 100644 --- a/test/models/deploy_zip_test.rb +++ b/test/models/deploy_zip_test.rb @@ -15,6 +15,9 @@ class DeployLocalTest < ActiveSupport::TestCase assert File.file?(deploy.path) assert_equal 'application/zip', `file --mime-type "#{escaped_path}"`.split(' ').last + assert_equal 1, deploy.build_stats.count + assert deploy.build_stats.map(&:bytes).inject(:+).positive? + assert deploy.build_stats.map(&:seconds).inject(:+).positive? local.destroy end From 7ed08f8a0dea9e2781fbf3f291397625914972f9 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 25 Jul 2019 21:08:37 -0300 Subject: [PATCH 22/44] de yapa ya no queremos esto --- app/assets/images/background.jpg | Bin 94225 -> 0 bytes app/assets/stylesheets/application.scss | 1 - app/views/layouts/application.html.haml | 26 +++++++++++++++++------- 3 files changed, 19 insertions(+), 8 deletions(-) delete mode 100644 app/assets/images/background.jpg diff --git a/app/assets/images/background.jpg b/app/assets/images/background.jpg deleted file mode 100644 index 0e6c2d0dba7b9b68f0306466a156f3dfb9db0fd8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 94225 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

HH=aNAsU{e{4csW58*`=s(M?xB+&&kfbox9|F*K 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 Date: Thu, 25 Jul 2019 21:36:33 -0300 Subject: [PATCH 23/44] generar el sitio utilizando sidekiq --- .env.example | 3 + Gemfile | 5 ++ Gemfile.lock | 33 ++++++++ app/assets/images/logo.png | Bin 0 -> 2001 bytes app/mailers/application_mailer.rb | 13 ++- app/mailers/deploy_mailer.rb | 28 +++++++ app/views/deploy_mailer/deployed.html.haml | 17 ++++ app/views/deploy_mailer/deployed.text.haml | 12 +++ app/views/layouts/mailer.haml | 8 -- app/views/layouts/mailer.html.haml | 9 ++ app/views/layouts/mailer.text.haml | 2 + app/workers/deploy_worker.rb | 39 +++++++++ config/initializers/sidekiq.rb | 13 +++ config/locales/en.yml | 91 ++++++++++++--------- config/locales/es.yml | 24 +++++- test/test_helper.rb | 3 + test/workers/deploy_worker_test.rb | 21 +++++ 17 files changed, 273 insertions(+), 48 deletions(-) create mode 100644 app/assets/images/logo.png create mode 100644 app/mailers/deploy_mailer.rb create mode 100644 app/views/deploy_mailer/deployed.html.haml create mode 100644 app/views/deploy_mailer/deployed.text.haml delete mode 100644 app/views/layouts/mailer.haml create mode 100644 app/workers/deploy_worker.rb create mode 100644 config/initializers/sidekiq.rb create mode 100644 test/workers/deploy_worker_test.rb diff --git a/.env.example b/.env.example index 2661f007..70475f65 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,6 @@ IMAP_SERVER= DEFAULT_FROM= DEVISE_PEPPER= SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl +SUTTY=sutty.nl +REDIS_SERVER= +REDIS_CLIENT= diff --git a/Gemfile b/Gemfile index 67a8c4d8..25c7a693 100644 --- a/Gemfile +++ b/Gemfile @@ -47,6 +47,7 @@ gem 'exception_notification' gem 'font-awesome-rails' gem 'friendly_id' gem 'hamlit-rails' +gem 'hiredis' gem 'jekyll' gem 'jquery-rails' gem 'mini_magick' @@ -54,8 +55,12 @@ gem 'mobility' gem 'pundit' gem 'rails-i18n' gem 'rails_warden' +gem 'redis', require: %w[redis redis/connection/hiredis] +gem 'redis-rails' gem 'rubyzip' gem 'rugged' +gem 'sidekiq' +gem 'terminal-table' gem 'validates_hostname' gem 'whenever', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 4a2c2ff6..b378733e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,6 +97,7 @@ GEM commonmarker (0.18.2) ruby-enum (~> 0.5) concurrent-ruby (1.1.5) + connection_pool (2.2.2) crass (1.0.4) database_cleaner (1.7.0) devise (4.6.2) @@ -161,6 +162,7 @@ GEM activesupport (>= 4.0.1) hamlit (>= 1.2.0) railties (>= 4.0.1) + hiredis (0.6.3) http_parser.rb (0.6.0) i18n (0.9.5) concurrent-ruby (~> 1.0) @@ -242,6 +244,8 @@ GEM pundit (2.0.1) activesupport (>= 3.0.0) rack (2.0.6) + rack-protection (2.0.5) + rack rack-test (1.1.0) rack (>= 1.0, < 3) rails (5.2.3) @@ -280,6 +284,23 @@ GEM ffi (~> 1.0) rbnacl (4.0.2) ffi + redis (4.1.2) + redis-actionpack (5.0.2) + actionpack (>= 4.0, < 6) + redis-rack (>= 1, < 3) + redis-store (>= 1.1.0, < 2) + redis-activesupport (5.0.7) + activesupport (>= 3, < 6) + redis-store (>= 1.3, < 2) + redis-rack (2.0.5) + rack (>= 1.5, < 3) + redis-store (>= 1.2, < 2) + redis-rails (5.0.2) + redis-actionpack (>= 5.0, < 6) + redis-activesupport (>= 5.0, < 6) + redis-store (>= 1.2, < 2) + redis-store (1.6.0) + redis (>= 2.2, < 5) request_store (1.4.1) rack (>= 1.4) responders (3.0.0) @@ -326,6 +347,11 @@ GEM selenium-webdriver (3.141.0) childprocess (~> 0.5) rubyzip (~> 1.2, >= 1.2.2) + sidekiq (5.2.7) + connection_pool (~> 2.2, >= 2.2.2) + rack (>= 1.5.0) + rack-protection (>= 1.5.0) + redis (>= 3.3.5, < 5) simpleidn (0.1.1) unf (~> 0.1.4) spring (2.0.2) @@ -346,6 +372,8 @@ GEM net-ssh (>= 2.8.0) sysexits (1.2.0) temple (0.8.1) + terminal-table (1.8.0) + unicode-display_width (~> 1.1, >= 1.1.1) thor (0.20.3) thread_safe (0.3.6) tilt (2.0.9) @@ -408,6 +436,7 @@ DEPENDENCIES friendly_id haml-lint hamlit-rails + hiredis jbuilder (~> 2.5) jekyll jquery-rails @@ -422,14 +451,18 @@ DEPENDENCIES rails-i18n rails_warden rbnacl (< 5.0) + redis + redis-rails rubocop-rails rubyzip rugged sass-rails (~> 5.0) selenium-webdriver + sidekiq spring spring-watcher-listen (~> 2.0.0) sqlite3 (~> 1.3.6) + terminal-table turbolinks (~> 5) uglifier (>= 1.3.0) validates_hostname diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..234d6d248ec5a652ce1b660f2a29448900bfe4de GIT binary patch literal 2001 zcmV;?2QK)DP)LIoe~A}j!cbF5A~>T0-xq~wFTJ@&dpjip)&j-c zSIVvzAWm=_BL=av=%H*WsP|1O`#P^B(VCAC-BF4aa&j0>)Ij4owb0ntDN4O?A~R78 zHHlJ!j*lXl%KVd*qMDSP8-o$atW`MUVo$q+=;$?19U-E)jbqo)!jAKd29X9sGI>ZU zO-+>{0?Tl=}Sp=AI>-fSXlCcc+j5s9fa*%Oe8^OOn<;yTa+<@}9 zLV9WvsY2na>LVadNxPvI%<+l|{p>J&C$FP!TOtyH4B`~KBU0w%<+AJ1CPn`B&T<|} zh*7wd?UITRo?DT_5bNWzN&;&4lkTpPr z=2_@XUd##L#lL8jM*hST+wDxGHbf+fKFQ=piLAk>)V{TU%vlP**HgVsYdk8#;SGB< zkG(4ofH;wf)CPzo#0VtQ0;vdbteXR~m0fX;`xSG4)7O}(7x#=!dKqo=N6ev|$wbO= zBI)ZiRh3V~NOit{0;dvnv}dZ(Ak$Vq`z9vRaUzlsWy;O~OkPr&hqf&WAHNf7Y&$9S zoFt0h-UDWp zLZ44EszVe+FU%!<>CyqPn2FQ|h?GojlP+&d;~&cPiC>j@JwS9oV}7StNB8I%&?-~h zza@Y^om_fl&SfG=$uyuGulU>(drZvqPp9_me*4QoT{^rPIS8C+70E6F6o4w^q!lu7mA3F4ww=-_SDFG ziK{`J#6xziR3y~gWlHGn!T-4X1O34S;qd6pZU?O>- zuQ33|S&Lw7x=aJ}wlI@g7{Y;#c>j$-#>M0NV3$rjzw!qqO*7-sh(FP3GILJOUn1G9LJis47T6VkW0?=< zMZ%trj)Ims{LVFgmsmO}QZx;dd@M8~!9;VGG#Zu1KllPf*9;obD5WRd#ep&W|5bS1 zd6|D&nlsfa?04r}yEJk9Mf7xK!`Asqq-~VvyT*7`wl{(v!Mk2vLBE4U1y(fb^Em0( zsRfgcyv>c!}MQ)k^3g%hVWDPNHA( zkjQ=6niuulD;|()Kfm9=C}ecQB=6p)-=%q@`>kofVCY~zMs!&gyEm9~a1H1Q42BoV za1*^5%paU}HIiMpA7l$L^f1|aOj_-aB@aNKhBMee3{OzKaxs>{j`ke2lt-{`$rfU0 z;T0^jQ}?4&mV^0um@ZOAK|!>G?M+*x%i8ipIWQPH_zUxXvJE!M_ElgGVhb@eAd0$x zA#Gfn0p@JB4?_>vCvH~0Sv%Ak%m!j;!EOkY)$-P{br_mx7$PmqqP&)&A53Eu0_Mi& jK~-W?np7q#vN7DhAAy!sSiN@?00000NkvXXu0mjfBwMrq literal 0 HcmV?d00001 diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index d84cb6e7..94ffc995 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -1,6 +1,17 @@ # frozen_string_literal: true +# Configuración base del correo class ApplicationMailer < ActionMailer::Base - default from: 'from@example.com' + helper :application + before_action :inline_logo! + + default from: ENV.fetch('DEFAULT_FROM', "noreply@#{Site.domain}") layout 'mailer' + + private + + def inline_logo! + attachments.inline['logo.png'] ||= + File.read('app/assets/images/logo.png') + end end diff --git a/app/mailers/deploy_mailer.rb b/app/mailers/deploy_mailer.rb new file mode 100644 index 00000000..33831044 --- /dev/null +++ b/app/mailers/deploy_mailer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Notifica a les usuaries cuando un sitio se generó con éxito +# +# XXX: No será mejor enviarles un correo con copia? +# TODO: Agregar headers de desuscripción de notificaciones cuando +# tengamos opciones de usuarie +# TODO: Agregar firma GPG y header Autocrypt +# TODO: Cifrar con GPG si le usuarie nos dio su llave +class DeployMailer < ApplicationMailer + # 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') + + # 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('mailers.deploy_mailer.deployed.subject', + site: @site.name)) + end + end + # rubocop:enable Metrics/AbcSize +end diff --git a/app/views/deploy_mailer/deployed.html.haml b/app/views/deploy_mailer/deployed.html.haml new file mode 100644 index 00000000..66cba36b --- /dev/null +++ b/app/views/deploy_mailer/deployed.html.haml @@ -0,0 +1,17 @@ +%h1= t('.hi') + += sanitize_markdown t('.explanation', fqdn: @deploy_local.fqdn), + tags: %w[p a strong em] + +%table + %thead + %tr + %th= t('.th.type') + %th= t('.th.status') + %tbody + - @deploys.each do |deploy, value| + %tr + %td= t(".#{deploy}.title") + %td= value ? t(".#{deploy}.success") : t(".#{deploy}.error") + += sanitize_markdown t('.help'), tags: %w[p a strong em] diff --git a/app/views/deploy_mailer/deployed.text.haml b/app/views/deploy_mailer/deployed.text.haml new file mode 100644 index 00000000..619d83e1 --- /dev/null +++ b/app/views/deploy_mailer/deployed.text.haml @@ -0,0 +1,12 @@ += "# #{t('.hi')}" +\ += t('.explanation', fqdn: @deploy_local.fqdn) +\ += 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")] +\ += t('.help') diff --git a/app/views/layouts/mailer.haml b/app/views/layouts/mailer.haml deleted file mode 100644 index cbf6b8e2..00000000 --- a/app/views/layouts/mailer.haml +++ /dev/null @@ -1,8 +0,0 @@ -!!! -%html - %head - %meta{:content => "text/html; charset=utf-8", "http-equiv" => "Content-Type"}/ - :css - /* Email styles need to be inline */ - %body - = yield diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml index 5ef091a7..9aad3a0e 100644 --- a/app/views/layouts/mailer.html.haml +++ b/app/views/layouts/mailer.html.haml @@ -1,3 +1,12 @@ +!!! %html + %head + %meta{ content: 'text/html; charset=utf-8', + 'http-equiv': 'Content-Type' }/ + :css + /* Inline */ %body = yield + + = image_tag attachments['logo.png'].url, alt: 'Logo de Sutty' + = t('.signature') diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml index 0a90f092..36d87bd3 100644 --- a/app/views/layouts/mailer.text.haml +++ b/app/views/layouts/mailer.text.haml @@ -1 +1,3 @@ = yield + += t('.signature') diff --git a/app/workers/deploy_worker.rb b/app/workers/deploy_worker.rb new file mode 100644 index 00000000..1b4ef9c2 --- /dev/null +++ b/app/workers/deploy_worker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +# Realiza el deploy de un sitio +class DeployWorker + include Sidekiq::Worker + + def perform(site) + site = Site.find(site) + # Asegurarse que DeployLocal sea el primero! + deployed = { deploy_local: deploy_local(site) } + + # No es opcional + raise unless deployed[:deploy_local] + + deploy_others site, deployed + notify_usuaries site, deployed + end + + private + + def deploy_local(site) + site.deploys.find_by(type: 'DeployLocal').deploy + end + + def deploy_others(site, deployed) + site.deploys.where.not(type: 'DeployLocal').find_each do |d| + deployed[d.type.underscore.to_sym] = d.deploy + end + end + + def notify_usuaries(site, deployed) + # TODO: existe site.usuaries_ids? + site.usuaries.find_each do |usuarie| + DeployMailer.with(usuarie: usuarie.id, site: site.id) + .deployed(deployed) + .deliver_now + end + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 00000000..0ebf8b8c --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +Sidekiq.configure_server do |config| + config.redis = { + url: ENV.fetch('REDIS_SERVER', 'redis://localhost:6379/1') + } +end + +Sidekiq.configure_client do |config| + config.redis = { + url: ENV.fetch('REDIS_CLIENT', 'redis://localhost:6379/1') + } +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 957ac212..157eaf62 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,5 +1,35 @@ en: + deploy_mailer: + deployed: + subject: "[Sutty] The site %{site} has been built" + hi: "Hi!" + explanation: | + This e-mail is to notify you that Sutty has built your site and + it's available at . + + You'll find details bellow. + th: + type: Type + status: Status + deploy_local: + title: Build the site + success: Success! + error: Error + deploy_zip: + title: Build ZIP file + success: Available for download + error: Error + help: You can contact us by replying this e-mail activerecord: + models: + usuarie: User + attributes: + usuarie: + email: 'E-mail address' + password: 'Password' + password_confirmation: 'Password confirmation' + site: + name: 'Name' errors: models: site: @@ -22,6 +52,8 @@ en: disordered: "The posts are disordered, this will prevent you from reordering them!" disordered_button: 'Reorder!' layouts: + mailer: + signature: 'With love, Sutty' breadcrumb: help: Help collaborations: @@ -29,19 +61,6 @@ en: submit: Register password: incorrect: 'Wrong password, please try again.' - invitadxs: - index: - title: 'Guests' - new: - email: 'E-Mail' - password: 'Password' - password_confirmation: 'Repeat password' - submit: 'Register' - acepta_politicas_de_privacidad: 'I accept the Privacy policy.' - confirmation: - confirmed: 'Your account is confirmed, please log in to continue' - show: - confirmation_sent: "We've sent a confirmation link to your e-mail address. Please open that link to continue." info: posts: reorder: 'The articles have been reordered!' @@ -79,27 +98,6 @@ en: sesiones: 'Sessions' anexo: 'Appendix' simple: 'Simple' - deploys: - deploy_local: - title: 'Host at Sutty' - help: | - The site will be available at . - - We're working out the details for allowing your own site - domains, you can help us! - ejemplo: 'example' - deploy_zip: - title: 'Generate a ZIP file' - help: | - ZIP files contain and compress all the files of your site. With - this option you can download and also share your whole site - through the address, keep it as backup - or have an strategy of solidarity hosting, were many people - shares a copy of your site. - - It also helps with site archival for historical purposes :) - - ejemplo: 'ejemplo' sites: index: 'This is the list of sites you can edit.' edit_translations: "You can edit texts from your site other than @@ -165,6 +163,27 @@ en: logout: 'Log out' lang: 'Language' error: 'There was an error during log in. Did you type your credentials correctly?' + deploys: + deploy_local: + title: 'Host at Sutty' + help: | + The site will be available at . + + We're working out the details for allowing your own site + domains, you can help us! + ejemplo: 'example' + deploy_zip: + title: 'Generate a ZIP file' + help: | + ZIP files contain and compress all the files of your site. With + this option you can download and also share your whole site + through the address, keep it as backup + or have an strategy of solidarity hosting, were many people + shares a copy of your site. + + It also helps with site archival for historical purposes :) + + ejemplo: 'example' sites: actions: 'Actions' posts: 'View and edit posts' @@ -218,10 +237,6 @@ en: message: 'Skeleton upgrade' footer: powered_by: 'is developed by' - templates: - index: 'Templates' - edit: 'Edit' - save: 'Save' i18n: index: 'Translations' edit: 'Edit texts and translations' diff --git a/config/locales/es.yml b/config/locales/es.yml index 4ddd36e0..f2f43591 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,25 @@ es: + deploy_mailer: + deployed: + subject: "[Sutty] El sitio %{site} ha sido generado" + hi: "¡Hola!" + explanation: | + Este correo es para notificarte que Sutty ha generado tu sitio y + ya está disponible en la dirección . + + A continuación encontrarás el detalle de lo que hicimos. + th: + type: Tipo + status: Estado + deploy_local: + title: Generar el sitio + success: ¡Éxito! + error: Hubo un error + deploy_zip: + title: Generar archivo ZIP + success: Disponible para descargar + error: Hubo un error + help: Por cualquier duda, responde este correo para contactarte con nosotres. activerecord: models: usuarie: Usuarie @@ -31,6 +52,8 @@ es: disordered: 'Los artículos no tienen número de orden, esto impedirá que los puedas reordenar' disordered_button: '¡Reordenar!' layouts: + mailer: + signature: 'Con cariño, Sutty' breadcrumb: help: Ayuda collaborations: @@ -200,7 +223,6 @@ es: Sutty te permite alojar tu sitio en distintos lugares al mismo tiempo. Esta estrategia facilita que el sitio esté disponible aun cuando algunos de los alojamientos no funcionen. - design: title: 'Diseño' actions: 'Información sobre este diseño' diff --git a/test/test_helper.rb b/test/test_helper.rb index 49ac63e7..437b0dab 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,6 +4,9 @@ require File.expand_path('../config/environment', __dir__) require 'rails/test_help' require 'open3' +require 'sidekiq/testing' +Sidekiq::Testing.inline! + # rubocop:disable Style/ClassAndModuleChildren class ActiveSupport::TestCase include FactoryBot::Syntax::Methods diff --git a/test/workers/deploy_worker_test.rb b/test/workers/deploy_worker_test.rb new file mode 100644 index 00000000..53e44d94 --- /dev/null +++ b/test/workers/deploy_worker_test.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class DeployWorkerTest < ActiveSupport::TestCase + test 'se puede compilar' do + rol = create :rol + site = rol.site + site.deploys << create(:deploy_zip, site: site) + + site.save + + DeployWorker.perform_async(site.id) + + assert_not ActionMailer::Base.deliveries.empty? + + site.deploys.each do |d| + assert File.exist?(d.try(:path) || d.try(:destination)) + end + + site.destroy + end +end From 15b6493c089ac82b14a882207377da45572dba17 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 25 Jul 2019 22:11:01 -0300 Subject: [PATCH 24/44] poder compilar el sitio con sidekiq --- Capfile | 1 - Gemfile | 1 - Gemfile.lock | 4 - app/controllers/sites_controller.rb | 18 ++--- app/models/site.rb | 60 +------------- app/policies/site_policy.rb | 4 - app/views/sites/index.haml | 9 --- app/workers/deploy_worker.rb | 8 +- bin/jekyll_build_all | 80 ------------------- config/routes.rb | 3 - config/schedule.rb | 8 -- .../20190725185427_create_build_stats.rb | 1 + .../20190726003756_add_status_to_site.rb | 8 ++ db/schema.rb | 3 +- test/controllers/sites_controller_test.rb | 11 +++ 15 files changed, 38 insertions(+), 181 deletions(-) delete mode 100755 bin/jekyll_build_all delete mode 100644 config/schedule.rb create mode 100644 db/migrate/20190726003756_add_status_to_site.rb diff --git a/Capfile b/Capfile index 5abc2d86..1992cec9 100644 --- a/Capfile +++ b/Capfile @@ -11,7 +11,6 @@ require 'capistrano/passenger' require 'capistrano/bundler' require 'capistrano/rbenv' require 'capistrano/rails' -require 'whenever/capistrano' require 'capistrano/scm/git' install_plugin Capistrano::SCM::Git diff --git a/Gemfile b/Gemfile index 25c7a693..ffd3d77d 100644 --- a/Gemfile +++ b/Gemfile @@ -62,7 +62,6 @@ gem 'rugged' gem 'sidekiq' gem 'terminal-table' gem 'validates_hostname' -gem 'whenever', require: false group :development, :test do gem 'pry' diff --git a/Gemfile.lock b/Gemfile.lock index b378733e..7250ed95 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,7 +91,6 @@ GEM carrierwave-i18n (0.2.0) childprocess (0.9.0) ffi (~> 1.0, >= 1.0.11) - chronic (0.10.2) coderay (1.1.2) colorator (1.1.0) commonmarker (0.18.2) @@ -401,8 +400,6 @@ GEM websocket-driver (0.7.0) websocket-extensions (>= 0.1.0) websocket-extensions (0.1.3) - whenever (0.10.0) - chronic (>= 0.6.3) xpath (3.2.0) nokogiri (~> 1.8) @@ -467,7 +464,6 @@ DEPENDENCIES uglifier (>= 1.3.0) validates_hostname web-console (>= 3.3.0) - whenever BUNDLED WITH 1.17.3 diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index c5008abd..3b2f6722 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -79,23 +79,15 @@ class SitesController < ApplicationController end def enqueue - @site = find_site - authorize @site - @site.enqueue! + site = find_site + authorize site + + # XXX: Convertir en una máquina de estados? + DeployWorker.perform_async site.id if site.enqueue! redirect_to sites_path end - def build_log - @site = find_site - authorize @site - - # TODO: eliminar ANSI - render file: @site.build_log, - layout: false, - content_type: 'text/plain; charset=utf-8' - end - def reorder_posts @site = find_site authorize @site diff --git a/app/models/site.rb b/app/models/site.rb index 3235f3a7..7b26024a 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -7,8 +7,8 @@ class Site < ApplicationRecord validates :name, uniqueness: true, hostname: true validates :design_id, presence: true - validate :deploy_local_presence + validates_inclusion_of :status, in: %w[waiting enqueued building] friendly_id :name, use: %i[finders] @@ -217,65 +217,13 @@ class Site < ApplicationRecord end.flatten.uniq.compact end - def failed_file - File.join(path, '.failed') - end - - def failed? - File.exist? failed_file - end - - def defail - FileUtils.rm failed_file if failed? - end - alias defail! defail - - def build_log - File.join(path, 'build.log') - end - - def build_log? - File.exist? build_log - end - - def queue_file - File.join(path, '.generate') + def enqueue! + !enqueued? && update_attribute(:status, 'enqueued') end def enqueued? - File.exist? queue_file + status == 'enqueued' end - alias queued? enqueued? - - # El sitio se genera cuando se coloca en una cola de generación, para - # que luego lo construya un cronjob - def enqueue - defail! - # TODO: ya van tres métodos donde usamos esta idea, convertir en un - # helper o algo - File.open(queue_file, File::RDWR | File::CREAT, 0o640) do |f| - # Bloquear el archivo para que no sea accedido por otro - # proceso u otra editora - f.flock(File::LOCK_EX) - - # Empezar por el principio - f.rewind - - # Escribir la fecha de creación - f.write(Time.now.to_i.to_s) - - # Eliminar el resto - f.flush - f.truncate(f.pos) - end - end - alias enqueue! enqueue - - # Eliminar de la cola - def dequeue - FileUtils.rm(queue_file) if enqueued? - end - alias dequeue! dequeue # Verifica si los posts están ordenados def ordered?(collection = 'posts') diff --git a/app/policies/site_policy.rb b/app/policies/site_policy.rb index 702d41cd..8e4aceb1 100644 --- a/app/policies/site_policy.rb +++ b/app/policies/site_policy.rb @@ -55,10 +55,6 @@ class SitePolicy build? end - def build_log? - build? - end - def reorder_posts? build? end diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index d082408c..464a6168 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -77,15 +77,6 @@ = fa_icon 'building' = t('sites.enqueue') - - if policy(site).build_log? - - if site.failed? - %button.btn.btn-danger= t('sites.failed') - - if site.build_log? - = render 'layouts/btn_with_tooltip', - tooltip: t('help.sites.build_log'), - text: t('sites.build_log'), - type: 'warning', - link: site_build_log_path(site) - if policy(site).pull? && site.needs_pull? = render 'layouts/btn_with_tooltip', tooltip: t('help.sites.pull'), diff --git a/app/workers/deploy_worker.rb b/app/workers/deploy_worker.rb index 1b4ef9c2..15a31549 100644 --- a/app/workers/deploy_worker.rb +++ b/app/workers/deploy_worker.rb @@ -6,14 +6,20 @@ class DeployWorker def perform(site) site = Site.find(site) + site.update_attribute :status, 'building' # Asegurarse que DeployLocal sea el primero! deployed = { deploy_local: deploy_local(site) } # No es opcional - raise unless deployed[:deploy_local] + unless deployed[:deploy_local] + site.update_attribute :status, 'waiting' + raise + end deploy_others site, deployed notify_usuaries site, deployed + + site.update_attribute :status, 'waiting' end private diff --git a/bin/jekyll_build_all b/bin/jekyll_build_all deleted file mode 100755 index daba0388..00000000 --- a/bin/jekyll_build_all +++ /dev/null @@ -1,80 +0,0 @@ -#!/bin/bash -# TODO convertir a ruby! -set -e - -rails_root="${PWD}" - -# Encontrar todos los sitios únicos con el archivo `.generate`. Esto -# significa que la usuaria quiso generar el sitio. -find -L ./_sites -mindepth 2 -maxdepth 2 -name .generate \ -| sed "s/\/\.generate$//" \ -| while read _path ; do - # Como seguimos todos los symlinks y los sitios pueden estar - # vinculados entre sí, volvemos a chequear si existe el archivo para - # no generarlo dos veces - test -f "${_path}/.generate" || continue - test -f "${_path}/.generating" && continue - - # Obtenemos las direcciones de correo de las responsables - _mail=($(cat "${_path}/.usuarias")) - _site="$(echo "${_path}" | xargs basename)" - _deploy="${rails_root}/_deploy/${_site}" - - # Entrar al directorio del sitio - pushd "${_path}" &>/dev/null - - # Reiniciar el log con la fecha - date > build.log - - # Instalar las gemas si no están - test -f .bundle/config \ - || bundle install --path=/srv/http/gems.kefir.red \ - >> build.log - - # Actualizar las gemas - bundle >> build.log - # Instalar los assets - test -f yarn.lock \ - && yarn >> build.log - - # Crear el sitio con lujo de detalles y guardar un log, pero a la vez - # tenerlo en la salida estándar para poder enviar al MAILTO del - # cronjob. - # - # Ya que estamos, eliminamos la ruta donde estamos paradas para no dar - # información sobre la servidora. - touch .generating - # Correr en baja prioridad - nice -n 19 \ - bundle exec \ - jekyll build --trace --destination "${_deploy}" 2>&1 \ - | sed -re "s,${_path},,g" \ - >> "build.log" - - # Acciones posteriores - # TODO convertir en un plugin de cada sitio? - if test $? -eq 0; then - # Si funciona, enviar un mail - # TODO enviar un mail más completo y no hardcodear direcciones - echo "Everything was good! You can see your changes in https://${_site}" \ - | mail -b "sysadmin@kefir.red" \ - -s "${_site}: :)" \ - ${_mail[@]} - else - echo "There was an error, please check build log at https://sutty.kefir.red/" \ - | mail -b "sysadmin@kefir.red" \ - -s "${_site}: :(" \ - ${_mail[@]} - date +%s >.failed - fi - - # Eliminar el archivo para sacar el sitio de la cola de compilación - rm -f .generate .generating - # TODO descubrir el grupo según la distro? - chgrp -R http "${_deploy}" - find "${_deploy}" -type f -print0 | xargs -r -0 chmod 640 - find "${_deploy}" -type d -print0 | xargs -r -0 chmod 2750 - - # Volver al principio para continuar con el siguiente sitio - popd &>/dev/null -done diff --git a/config/routes.rb b/config/routes.rb index 3b701878..7e66c766 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -# rubocop:disable Metrics/BlockLength Rails.application.routes.draw do devise_for :usuaries @@ -42,8 +41,6 @@ Rails.application.routes.draw do # Compilar el sitio post 'enqueue', to: 'sites#enqueue' - get 'build_log', to: 'sites#build_log' post 'reorder_posts', to: 'sites#reorder_posts' end end -# rubocop:enable Metrics/BlockLength diff --git a/config/schedule.rb b/config/schedule.rb deleted file mode 100644 index c17dbfd8..00000000 --- a/config/schedule.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true - -env 'MAILTO', 'sysadmin@kefir.red' -job_type :bash, 'cd :path && ./bin/:task' - -every 3.minutes do - bash 'jekyll_build_all' -end diff --git a/db/migrate/20190725185427_create_build_stats.rb b/db/migrate/20190725185427_create_build_stats.rb index 255ea285..1f41e42b 100644 --- a/db/migrate/20190725185427_create_build_stats.rb +++ b/db/migrate/20190725185427_create_build_stats.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Crea la tabla de estadísticas de compilación class CreateBuildStats < ActiveRecord::Migration[5.2] def change create_table :build_stats do |t| diff --git a/db/migrate/20190726003756_add_status_to_site.rb b/db/migrate/20190726003756_add_status_to_site.rb new file mode 100644 index 00000000..36975428 --- /dev/null +++ b/db/migrate/20190726003756_add_status_to_site.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# El status de un sitio +class AddStatusToSite < ActiveRecord::Migration[5.2] + def change + add_column :sites, :status, :string, default: 'waiting' + end +end diff --git a/db/schema.rb b/db/schema.rb index 7e76955c..f7c0bd7b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_725_185_427) do +ActiveRecord::Schema.define(version: 20_190_726_003_756) do create_table 'build_stats', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false @@ -98,6 +98,7 @@ ActiveRecord::Schema.define(version: 20_190_725_185_427) do t.string 'name' t.integer 'design_id' t.integer 'licencia_id' + t.string 'status', default: 'waiting' t.index ['design_id'], name: 'index_sites_on_design_id' t.index ['licencia_id'], name: 'index_sites_on_licencia_id' t.index ['name'], name: 'index_sites_on_name', unique: true diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index 72d3ebc9..1c5d30ef 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -71,4 +71,15 @@ class SitesControllerTest < ActionDispatch::IntegrationTest } end end + + test 'se pueden encolar' do + Sidekiq::Testing.fake! + + post site_enqueue_url(@site), headers: @authorization + + assert DeployWorker.jobs.count.positive? + assert @site.reload.enqueued? + + Sidekiq::Testing.inline! + end end From 199d86688cc1c71299d58f703da5b549ada3a321 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 26 Jul 2019 20:57:11 -0300 Subject: [PATCH 25/44] WIP de deploy --- Dockerfile | 100 +++++++++++++++++++++++++++++++++++++++++++++++++++ Gemfile.lock | 2 +- Procfile | 3 ++ monit.conf | 8 ++--- 4 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 Dockerfile create mode 100644 Procfile diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..faa56722 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,100 @@ +# Este Dockerfile está armado pensando en una compilación lanzada desde +# el mismo repositorio de trabajo. Cuando tengamos CI/CD algunas cosas +# como el tarball van a tener que cambiar porque ya vamos a haber hecho +# un clone/pull limpio. +FROM sutty/sdk-ruby:latest as build +MAINTAINER "f " + +# Un entorno base +ENV NOKOGIRI_USE_SYSTEM_LIBRARIES=1 +ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake +ENV RAILS_ENV production + +# Para compilar los assets en brotli +RUN apk add --no-cache brotli + +# Empezamos con la usuaria app creada por sdk-ruby +USER app +# Vamos a trabajar dentro de este directorio +WORKDIR /home/app/sutty + +# Copiamos solo el Gemfile para poder instalar las gemas necesarias +COPY --chown=app:www-data ./Gemfile . +COPY --chown=app:www-data ./Gemfile.lock . +# Instalar las gemas de producción +# XXX: No usamos la flag --production porque luego no nos deja +# desinstalar las gemas de los assets +RUN bundle install --path=./vendor --without test development +# Vaciar la caché +RUN rm vendor/ruby/2.5.0/cache/*.gem +# Limpiar las librerías nativas, esto ahorra más espacio y uso de +# memoria ya que no hay que cargar símbolos que no se van a usar. +RUN find vendor -name "*.so" | xargs -rn 1 strip --strip-unneeded + +# Copiar el repositorio git +COPY --chown=app:www-data ./.git/ ./.git/ +# Hacer un tarball de los archivos desde el repositorio +RUN git archive -o ../sutty.tar.gz HEAD + +# Extraer archivos necesarios para compilar los assets +RUN tar xvf ../sutty.tar.gz Rakefile config app yarn.lock package.json +# Instalar los paquetes JS +RUN yarn +# Pre-compilar los assets +RUN bundle exec rake assets:precompile +# Comprimirlos usando brotli +RUN find public/assets -type f | grep -v ".gz$" | xargs -r brotli -k -9 + +# Eliminar la necesidad de un runtime JS en producción, porque los +# assets ya están pre-compilados. +RUN sed -re "/(uglifier|bootstrap|coffee-rails)/d" -i Gemfile +RUN bundle clean + +# Contenedor final +FROM sutty/monit:latest + +# Instalar las dependencias, separamos la librería de base de datos para +# poder reutilizar este primer paso desde otros contenedores +RUN apk add --no-cache libxslt libxml2 tzdata ruby ruby-bundler ruby-json ruby-bigdecimal ruby-rake +RUN apk add --no-cache sqlite-libs +# Necesitamos yarn para que Jekyll pueda generar los sitios +# XXX: Eliminarlo cuando extraigamos la generación de sitios del proceso +# principal +RUN apk add --no-cache yarn +# Instalar foreman para poder correr los servicios +RUN gem install --no-rdoc --no-ri --no-user-install foreman + +# Agregar el grupo del servidor web +RUN addgroup -g 82 -S www-data +# Agregar la usuaria +RUN adduser -s /bin/sh -G www-data -h /srv/http -D app + +# Convertirse en app para instalar +USER app +WORKDIR /srv/http + +# Traer los archivos y colocarlos donde van definitivamente +COPY --from=build --chown=app:www-data /home/app/sutty.tar.gz /tmp/ +# XXX: No vale la pena borrarlo porque sigue ocupando espacio en la capa +# anterior +RUN tar xf /tmp/sutty.tar.gz && rm /tmp/sutty.tar.gz + +# Traer los assets compilados y las gemas +COPY --from=build --chown=app:www-data /home/app/web/public/assets public/assets +COPY --from=build --chown=app:www-data /home/app/web/vendor vendor +COPY --from=build --chown=app:www-data /home/app/web/.bundle .bundle + +# Volver a root para cerrar la compilación +USER root + +# Instalar la configuración de monit y comprobarla +RUN install -m 640 -o root -g root ./monit.conf /etc/monit.d/sutty.conf +RUN monit -t + +# Mantener estos directorios! +VOLUME "/srv/http/_deploy" +VOLUME "/srv/http/_sites" +VOLUME "/srv/http/public" + +# El puerto de puma +EXPOSE 3000 diff --git a/Gemfile.lock b/Gemfile.lock index 7250ed95..9d611326 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -466,4 +466,4 @@ DEPENDENCIES web-console (>= 3.3.0) BUNDLED WITH - 1.17.3 + 2.0.2 diff --git a/Procfile b/Procfile new file mode 100644 index 00000000..367b027f --- /dev/null +++ b/Procfile @@ -0,0 +1,3 @@ +migrate: bundle exec rake db:migrate db:seed +sutty: bundle exec puma -d config.ru +sidekiq: bundle exec sidekiq -t 1 diff --git a/monit.conf b/monit.conf index 3d686349..211b03a6 100644 --- a/monit.conf +++ b/monit.conf @@ -1,7 +1,3 @@ -check process rails with pidfile /srv/http/tmp/puma.pid - start program = "/usr/bin/entrypoint rails" as uid app +check process sutty with pidfile /srv/http/tmp/puma.pid + start program = "/bin/sh -c 'cd /srv/http && foreman start migrate && foreman start sutty'" as uid app stop program = "/bin/sh -c 'cat /srv/http/tmp/puma.pid | xargs kill'" - -check process static with pidfile /tmp/darkhttpd.pid - start program = "/usr/bin/entrypoint darkhttpd" - stop program = "/bin/sh -c 'cat /tmp/darkhttpd.pid | xargs kill'" From 59ab20b887e34d99e04b10a57e7531cb767144bd Mon Sep 17 00:00:00 2001 From: f Date: Sat, 27 Jul 2019 12:25:05 -0300 Subject: [PATCH 26/44] el filtro de commonmark para haml arroja un error --- app/views/posts/show.haml | 8 ++++---- config/initializers/commonmarker.rb | 17 ----------------- 2 files changed, 4 insertions(+), 21 deletions(-) delete mode 100644 config/initializers/commonmarker.rb diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index 32f3951f..1c0324ad 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -25,8 +25,8 @@ .row .col .content{class: @post.get_front_matter(:dir)} - :markdown - #{@post.content} + = sanitized_markdown @post.content, + tags: %w[h1 h2 h3 h4 h5 h6 p a ul ol li table tr td th tbody thead tfoot em strong sup blockquote cite pre] -# Representar los datos en una tabla: -# Texto: tal cual en una celda @@ -73,8 +73,8 @@ %td= v - elsif data.respond_to? :content -# Contenido del artículo - :markdown - #{data.content} + = sanitized_markdown data.content, + tags: %w[h1 h2 h3 h4 h5 h6 p a ul ol li table tr td th tbody thead tfoot em strong sup blockquote cite pre] - elsif data.respond_to? :strftime -# Fecha = data.strftime('%F') diff --git a/config/initializers/commonmarker.rb b/config/initializers/commonmarker.rb deleted file mode 100644 index 4d6743fe..00000000 --- a/config/initializers/commonmarker.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -require 'commonmarker' - -module Haml::Filters - remove_filter('Markdown') # remove the existing Markdown filter - - module Markdown - include Haml::Filters::Base - - def render(text) - CommonMarker.render_html(text, - [:TABLE_PREFER_STYLE_ATTRIBUTES], - %i[table strikethrough autolink tagfilter]) - end - end -end From dc726fdbe04885f159850529d3f839537408c4b4 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 15:15:23 -0300 Subject: [PATCH 27/44] deploy en docker --- .env.example | 1 + .gitignore | 4 ++-- Dockerfile | 31 ++++++++++++++++++++++--------- bin/rails | 35 ++++++++++++++++++++++++++++------- bin/sidekiq | 32 ++++++++++++++++++++++++++++++++ bin/sidekiqctl | 32 ++++++++++++++++++++++++++++++++ config/database.yml | 2 +- monit.conf | 4 ++++ sync_assets.sh | 5 +++++ 9 files changed, 127 insertions(+), 19 deletions(-) create mode 100755 bin/sidekiq create mode 100755 bin/sidekiqctl create mode 100644 sync_assets.sh diff --git a/.env.example b/.env.example index 70475f65..0a8c05d6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +RAILS_ENV=production SECRET_KEY_BASE= IMAP_SERVER= DEFAULT_FROM= diff --git a/.gitignore b/.gitignore index 483c9162..5cf7616d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,6 @@ /_sites/* /_deploy/* -/_usuarias/* -/_invitadxs/* +/data/* + .env diff --git a/Dockerfile b/Dockerfile index faa56722..ddf957c2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ ENV SECRET_KEY_BASE solo_es_necesaria_para_correr_rake ENV RAILS_ENV production # Para compilar los assets en brotli -RUN apk add --no-cache brotli +RUN apk add --no-cache brotli libgit2-dev rsync cmake # Empezamos con la usuaria app creada por sdk-ruby USER app @@ -21,10 +21,16 @@ WORKDIR /home/app/sutty # Copiamos solo el Gemfile para poder instalar las gemas necesarias COPY --chown=app:www-data ./Gemfile . COPY --chown=app:www-data ./Gemfile.lock . -# Instalar las gemas de producción +# XXX: Esto va a tener permisos de 1000, idealmente el usuario que lanza +# la compilación +RUN rsync -a 172.17.0.1::ccache/ /home/app/.ccache/ +# Instalar las gemas de producción usando ccache para no recompilar +# gemas nativas # XXX: No usamos la flag --production porque luego no nos deja # desinstalar las gemas de los assets -RUN bundle install --path=./vendor --without test development +# RUN --mount=type=cache,target=/home/app/.ccache \ +RUN if ! bundle install --path=./vendor --without test development ; then rsync -a /home/app/.ccache/ 172.17.0.1::ccache/ ; exit 1 ; fi +RUN rsync -a /home/app/.ccache/ 172.17.0.1::ccache/ # Vaciar la caché RUN rm vendor/ruby/2.5.0/cache/*.gem # Limpiar las librerías nativas, esto ahorra más espacio y uso de @@ -37,7 +43,7 @@ COPY --chown=app:www-data ./.git/ ./.git/ RUN git archive -o ../sutty.tar.gz HEAD # Extraer archivos necesarios para compilar los assets -RUN tar xvf ../sutty.tar.gz Rakefile config app yarn.lock package.json +RUN tar xf ../sutty.tar.gz Rakefile config app yarn.lock package.json # Instalar los paquetes JS RUN yarn # Pre-compilar los assets @@ -50,8 +56,10 @@ RUN find public/assets -type f | grep -v ".gz$" | xargs -r brotli -k -9 RUN sed -re "/(uglifier|bootstrap|coffee-rails)/d" -i Gemfile RUN bundle clean + # Contenedor final FROM sutty/monit:latest +ENV RAILS_ENV production # Instalar las dependencias, separamos la librería de base de datos para # poder reutilizar este primer paso desde otros contenedores @@ -61,8 +69,9 @@ RUN apk add --no-cache sqlite-libs # XXX: Eliminarlo cuando extraigamos la generación de sitios del proceso # principal RUN apk add --no-cache yarn +RUN apk add --no-cache libgit2 # Instalar foreman para poder correr los servicios -RUN gem install --no-rdoc --no-ri --no-user-install foreman +RUN gem install --no-document --no-user-install foreman # Agregar el grupo del servidor web RUN addgroup -g 82 -S www-data @@ -80,9 +89,9 @@ COPY --from=build --chown=app:www-data /home/app/sutty.tar.gz /tmp/ RUN tar xf /tmp/sutty.tar.gz && rm /tmp/sutty.tar.gz # Traer los assets compilados y las gemas -COPY --from=build --chown=app:www-data /home/app/web/public/assets public/assets -COPY --from=build --chown=app:www-data /home/app/web/vendor vendor -COPY --from=build --chown=app:www-data /home/app/web/.bundle .bundle +COPY --from=build --chown=app:www-data /home/app/sutty/public/assets public/assets +COPY --from=build --chown=app:www-data /home/app/sutty/vendor vendor +COPY --from=build --chown=app:www-data /home/app/sutty/.bundle .bundle # Volver a root para cerrar la compilación USER root @@ -90,11 +99,15 @@ USER root # Instalar la configuración de monit y comprobarla RUN install -m 640 -o root -g root ./monit.conf /etc/monit.d/sutty.conf RUN monit -t +# Sincronizar los assets a un directorio compartido +RUN apk add --no-cache rsync +COPY ./sync_assets.sh /usr/local/bin/sync_assets +RUN chmod 755 /usr/local/bin/sync_assets # Mantener estos directorios! VOLUME "/srv/http/_deploy" VOLUME "/srv/http/_sites" -VOLUME "/srv/http/public" +VOLUME "/srv/http/_public" # El puerto de puma EXPOSE 3000 diff --git a/bin/rails b/bin/rails index 3504c3f9..088d75d6 100755 --- a/bin/rails +++ b/bin/rails @@ -1,11 +1,32 @@ #!/usr/bin/env ruby # frozen_string_literal: true -begin - load File.expand_path('spring', __dir__) -rescue LoadError => e - raise unless e.message.include?('spring') +# +# This file was generated by Bundler. +# +# The application 'rails' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path('bundle', __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort( + 'Your `bin/bundle` was not generated by Bundler, so this binstub + cannot run. Replace `bin/bundle` by running `bundle binstubs + bundler --force`, then run this command again.' + ) + end end -APP_PATH = File.expand_path('../config/application', __dir__) -require_relative '../config/boot' -require 'rails/commands' + +require 'rubygems' +require 'bundler/setup' + +load Gem.bin_path('railties', 'rails') diff --git a/bin/sidekiq b/bin/sidekiq new file mode 100755 index 00000000..5d97d883 --- /dev/null +++ b/bin/sidekiq @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'sidekiq' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path('bundle', __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort( + 'Your `bin/bundle` was not generated by Bundler, so this binstub + cannot run. Replace `bin/bundle` by running `bundle binstubs + bundler --force`, then run this command again.' + ) + end +end + +require 'rubygems' +require 'bundler/setup' + +load Gem.bin_path('sidekiq', 'sidekiq') diff --git a/bin/sidekiqctl b/bin/sidekiqctl new file mode 100755 index 00000000..10eb2f4f --- /dev/null +++ b/bin/sidekiqctl @@ -0,0 +1,32 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'sidekiqctl' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'pathname' +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path('bundle', __dir__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort( + 'Your `bin/bundle` was not generated by Bundler, so this binstub + cannot run. Replace `bin/bundle` by running `bundle binstubs + bundler --force`, then run this command again.' + ) + end +end + +require 'rubygems' +require 'bundler/setup' + +load Gem.bin_path('sidekiq', 'sidekiqctl') diff --git a/config/database.yml b/config/database.yml index 0d02f249..f7a32384 100644 --- a/config/database.yml +++ b/config/database.yml @@ -22,4 +22,4 @@ test: production: <<: *default - database: db/production.sqlite3 + database: data/production.sqlite3 diff --git a/monit.conf b/monit.conf index 211b03a6..4d34d69c 100644 --- a/monit.conf +++ b/monit.conf @@ -1,3 +1,7 @@ check process sutty with pidfile /srv/http/tmp/puma.pid start program = "/bin/sh -c 'cd /srv/http && foreman start migrate && foreman start sutty'" as uid app stop program = "/bin/sh -c 'cat /srv/http/tmp/puma.pid | xargs kill'" + +check program sync_assets + with path /usr/local/bin/sync_assets + if status = 0 then unmonitor diff --git a/sync_assets.sh b/sync_assets.sh new file mode 100644 index 00000000..167d6205 --- /dev/null +++ b/sync_assets.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Sincronizar assets desde public a _public para que estén disponibles +# en el contenedor web. + +rsync -a --delete-after public/ _public/ From bb5555126631d75985b905f2b69ced5ea1a058d8 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 15:20:19 -0300 Subject: [PATCH 28/44] bugs --- app/controllers/posts_controller.rb | 4 +-- app/views/posts/show.haml | 45 +++++++++++++++-------------- 2 files changed, 26 insertions(+), 23 deletions(-) diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 4205ecf8..b2ba6f8f 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -56,7 +56,7 @@ class PostsController < ApplicationController # Las usuarias pueden especificar una autora, de la contrario por # defecto es la usuaria actual - if current_user.is_a? Usuaria + if @site.usuarie? current_usuarie @post.update_attributes(author: params[:post][:author]) else # Todo lo que crean lxs invitadxs es borrador @@ -99,7 +99,7 @@ class PostsController < ApplicationController @post.update_attributes(repair_nested_params(post_params)) # Solo las usuarias pueden modificar la autoría - if current_user.is_a? Usuaria + if @site.usuarie? current_usuarie if params[:post][:author].present? @post.update_attributes(author: params[:post][:author]) end diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index 1c0324ad..fccf15e6 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -1,19 +1,22 @@ +- tags = %w[h1 h2 h3 h4 h5 h6 p a ul ol li table tr td th tbody thead tfoot em strong sup blockquote cite pre] + .row .col = render 'layouts/breadcrumb', - crumbs: [ link_to(t('sites.index'), sites_path), - @site.name, - link_to(t('posts.index'), - site_posts_path(@site)), - @post.title ] + crumbs: [link_to(t('sites.index'), sites_path), + @site.name, + link_to(t('posts.index'), + site_posts_path(@site)), + @post.title] .row .col - %h1{class: @post.get_front_matter(:dir)}= @post.title + %h1{ class: @post.get_front_matter(:dir) }= @post.title %p - translations = @post.translations.map do |translation| - - link_to translation.title, site_post_path(@site, translation, lang: translation.lang) + - link_to translation.title, + site_post_path(@site, translation, lang: translation.lang) = raw translations.join(' / ') .row @@ -24,16 +27,17 @@ .row .col - .content{class: @post.get_front_matter(:dir)} - = sanitized_markdown @post.content, - tags: %w[h1 h2 h3 h4 h5 h6 p a ul ol li table tr td th tbody thead tfoot em strong sup blockquote cite pre] + .content{ class: @post.get_front_matter(:dir) } + = sanitize_markdown @post.content, + tags: tags - -# Representar los datos en una tabla: - -# Texto: tal cual en una celda - -# Array: píldoras - -# Array de Hashes: Tabla - -# Hash: Tabla - -# TODO DRY + -# + Representar los datos en una tabla: + Texto: tal cual en una celda + Array: píldoras + Array de Hashes: Tabla + Hash: Tabla + TODO DRY %table.table.table-condensed.table-striped.table-responsive %tbody - @post.front_matter.each do |key, data| @@ -51,7 +55,7 @@ %tbody - data.each do |r| %tr - - r.each do |_,v| + - r.each do |_, v| %td - if v.is_a? Array - v.each do |s| @@ -73,16 +77,15 @@ %td= v - elsif data.respond_to? :content -# Contenido del artículo - = sanitized_markdown data.content, - tags: %w[h1 h2 h3 h4 h5 h6 p a ul ol li table tr td th tbody thead tfoot em strong sup blockquote cite pre] + = sanitize_markdown data.content, tags: tags - elsif data.respond_to? :strftime -# Fecha = data.strftime('%F') - else -# Texto - if @post.image? key - %img.img-fluid{src: @site.get_url_for_sutty(data)} + %img.img-fluid{ src: @site.get_url_for_sutty(data) } - elsif @post.url? key - %a{href: @site.get_url_for_sutty(data)}= data + %a{ href: @site.get_url_for_sutty(data) }= data - else = data From 5045ec81739c80fd2619d46d0cf1493660c05b5d Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 15:28:09 -0300 Subject: [PATCH 29/44] no vamos a testear esto por ahora --- test/models/post_test.rb | 44 ------------------------------------- test/models/usuaria_test.rb | 20 ----------------- 2 files changed, 64 deletions(-) delete mode 100644 test/models/post_test.rb delete mode 100644 test/models/usuaria_test.rb diff --git a/test/models/post_test.rb b/test/models/post_test.rb deleted file mode 100644 index de1a7aeb..00000000 --- a/test/models/post_test.rb +++ /dev/null @@ -1,44 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class PostTest < ActiveSupport::TestCase - setup do - @user = Usuaria.find('f@kefir.red') - @site = @user.sites.select { |s| s.name == 'cyber-women.com' }.first - @post = @site.posts.sample - end - - test 'El post no es nuevo si ya existe' do - assert_not @post.new? - end - - test 'El post está traducido' do - assert @post.translated? - end - - test 'El post tiene un título' do - assert String, @post.title.class - end - - test 'El post tiene una fecha' do - assert DateTime, @post.date.class - end - - test 'Es obvio que un post recién cargado es válido' do - assert @post.valid? - end - - test 'El post se puede borrar' do - path = @post.path - - assert @post.destroy - assert_not File.exist?(path) - - post = @site.posts_for(@post.collection).find do |p| - p.path == @post.path - end - - assert_not post - end -end diff --git a/test/models/usuaria_test.rb b/test/models/usuaria_test.rb deleted file mode 100644 index 1bff620c..00000000 --- a/test/models/usuaria_test.rb +++ /dev/null @@ -1,20 +0,0 @@ -# frozen_string_literal: true - -require 'test_helper' - -class UsuariaTest < ActiveSupport::TestCase - setup do - @mail = 'f@kefir.red' - @usuaria = Usuaria.find(@mail) - end - - test 'La usuaria puede encontrarse por su mail' do - assert_equal @mail, @usuaria.username - end - - test 'La usuaria tiene sitios' do - @usuaria.sites.each do |s| - assert_equal Site, s.class - end - end -end From 45568bbb1214084f5be6304d0b49afe4a8722c3a Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 15:51:09 -0300 Subject: [PATCH 30/44] usar redis como cache --- .rubocop.yml | 1 + config/environments/development.rb | 24 ++++++++++++++---------- config/environments/production.rb | 6 +++--- 3 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 8df03292..8b614a39 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -36,6 +36,7 @@ Metrics/MethodLength: Metrics/BlockLength: Exclude: + - 'config/environments/development.rb' - 'config/environments/production.rb' - 'config/initializers/devise.rb' - 'db/schema.rb' diff --git a/config/environments/development.rb b/config/environments/development.rb index ad1c4d9d..68c06796 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true Rails.application.configure do - # Settings specified here will take precedence over those in config/application.rb. + # Settings specified here will take precedence over those in + # config/application.rb. - # In the development environment your application's code is reloaded on - # every request. This slows down response time but is perfect for development - # since you don't have to restart the web server when you make code changes. + # In the development environment your application's code is reloaded + # on every request. This slows down response time but is perfect for + # development since you don't have to restart the web server when you + # make code changes. config.cache_classes = false # Do not eager load code on boot. @@ -18,7 +20,7 @@ Rails.application.configure do if Rails.root.join('tmp/caching-dev.txt').exist? config.action_controller.perform_caching = true - config.cache_store = :memory_store + config.cache_store = :redis_cache_store config.public_file_server.headers = { 'Cache-Control' => "public, max-age=#{2.days.seconds.to_i}" } @@ -39,8 +41,8 @@ Rails.application.configure do # Raise an error on page load if there are pending migrations. config.active_record.migration_error = :page_load - # Debug mode disables concatenation and preprocessing of assets. - # This option may cause significant delays in view rendering with a large + # Debug mode disables concatenation and preprocessing of assets. This + # option may cause significant delays in view rendering with a large # number of complex assets. config.assets.debug = true @@ -50,8 +52,9 @@ Rails.application.configure do # Raises error for missing translations # config.action_view.raise_on_missing_translations = true - # Use an evented file watcher to asynchronously detect changes in source code, - # routes, locales, etc. This feature depends on the listen gem. + # Use an evented file watcher to asynchronously detect changes in + # source code, routes, locales, etc. This feature depends on the + # listen gem. config.file_watcher = ActiveSupport::EventedFileUpdateChecker CarrierWave.configure do |config| @@ -63,5 +66,6 @@ Rails.application.configure do config.action_mailer.perform_caching = false config.action_mailer.delivery_method = :letter_opener config.action_mailer.perform_deliveries = true - config.action_mailer.default_url_options = { host: 'localhost', port: 3000 } + config.action_mailer.default_url_options = { host: 'localhost', + port: 3000 } end diff --git a/config/environments/production.rb b/config/environments/production.rb index 12687e07..dcdd555b 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -56,12 +56,12 @@ Rails.application.configure do config.log_tags = [:request_id] # Use a different cache store in production. - # config.cache_store = :mem_cache_store + config.cache_store = :redis_cache_store # Use a real queuing backend for Active Job (and separate queues per # environment) - # config.active_job.queue_adapter = :resque - # config.active_job.queue_name_prefix = "sutty_#{Rails.env}" + config.active_job.queue_adapter = :sidekiq + config.active_job.queue_name_prefix = "sutty_#{Rails.env}" config.action_mailer.perform_caching = false # Ignore bad email addresses and do not raise email delivery errors. From 957eb3cc318a6016cc8ab11912b51b0897068d4e Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 16:19:46 -0300 Subject: [PATCH 31/44] binstubs de rails --- bin/bundle | 110 +------------------------------------------------- bin/haml-lint | 29 +++++++++++++ bin/rails | 34 ++-------------- bin/rake | 7 ---- bin/setup | 5 +-- bin/update | 8 ++-- bin/yarn | 11 +++++ 7 files changed, 50 insertions(+), 154 deletions(-) create mode 100755 bin/haml-lint create mode 100755 bin/yarn diff --git a/bin/bundle b/bin/bundle index 6965387f..f19acf5b 100755 --- a/bin/bundle +++ b/bin/bundle @@ -1,109 +1,3 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'bundle' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -require 'rubygems' - -m = Module.new do - module_function - - def invoked_as_script? - File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__) - end - - def env_var_version - ENV['BUNDLER_VERSION'] - end - - def cli_arg_version - return unless invoked_as_script? # don't want to hijack other binstubs - return unless 'update'.start_with?(ARGV.first || ' ') # must be running `bundle update` - - bundler_version = nil - update_index = nil - ARGV.each_with_index do |a, i| - if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN - bundler_version = a - end - next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ - - bundler_version = Regexp.last_match(1) || '>= 0.a' - update_index = i - end - bundler_version - end - - def gemfile - gemfile = ENV['BUNDLE_GEMFILE'] - return gemfile if gemfile && !gemfile.empty? - - File.expand_path('../Gemfile', __dir__) - end - - def lockfile - lockfile = - case File.basename(gemfile) - when 'gems.rb' then gemfile.sub(/\.rb$/, gemfile) - else "#{gemfile}.lock" - end - File.expand_path(lockfile) - end - - def lockfile_version - return unless File.file?(lockfile) - - lockfile_contents = File.read(lockfile) - return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ - - Regexp.last_match(1) - end - - def bundler_version - @bundler_version ||= begin - env_var_version || cli_arg_version || - lockfile_version || "#{Gem::Requirement.default}.a" - end - end - - def load_bundler! - ENV['BUNDLE_GEMFILE'] ||= gemfile - - # must dup string for RG < 1.8 compatibility - activate_bundler(bundler_version.dup) - end - - def activate_bundler(bundler_version) - if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new('2.0') - bundler_version = '< 2' - end - gem_error = activation_error_handling do - gem 'bundler', bundler_version - end - return if gem_error.nil? - - require_error = activation_error_handling do - require 'bundler/version' - end - return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) - - warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" - exit 42 - end - - def activation_error_handling - yield - nil - rescue StandardError, LoadError => e - e - end -end - -m.load_bundler! - -load Gem.bin_path('bundler', 'bundle') if m.invoked_as_script? +ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) +load Gem.bin_path('bundler', 'bundle') diff --git a/bin/haml-lint b/bin/haml-lint new file mode 100755 index 00000000..ae437530 --- /dev/null +++ b/bin/haml-lint @@ -0,0 +1,29 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'haml-lint' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "pathname" +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", + Pathname.new(__FILE__).realpath) + +bundle_binstub = File.expand_path("../bundle", __FILE__) + +if File.file?(bundle_binstub) + if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ + load(bundle_binstub) + else + abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. +Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") + end +end + +require "rubygems" +require "bundler/setup" + +load Gem.bin_path("haml_lint", "haml-lint") diff --git a/bin/rails b/bin/rails index 088d75d6..07396602 100755 --- a/bin/rails +++ b/bin/rails @@ -1,32 +1,4 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'rails' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -require 'pathname' -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', - Pathname.new(__FILE__).realpath) - -bundle_binstub = File.expand_path('bundle', __dir__) - -if File.file?(bundle_binstub) - if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/ - load(bundle_binstub) - else - abort( - 'Your `bin/bundle` was not generated by Bundler, so this binstub - cannot run. Replace `bin/bundle` by running `bundle binstubs - bundler --force`, then run this command again.' - ) - end -end - -require 'rubygems' -require 'bundler/setup' - -load Gem.bin_path('railties', 'rails') +APP_PATH = File.expand_path('../config/application', __dir__) +require_relative '../config/boot' +require 'rails/commands' diff --git a/bin/rake b/bin/rake index 1fe6cf06..17240489 100755 --- a/bin/rake +++ b/bin/rake @@ -1,11 +1,4 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -begin - load File.expand_path('spring', __dir__) -rescue LoadError => e - raise unless e.message.include?('spring') -end require_relative '../config/boot' require 'rake' Rake.application.run diff --git a/bin/setup b/bin/setup index 3b7333ed..94fd4d79 100755 --- a/bin/setup +++ b/bin/setup @@ -1,12 +1,9 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -require 'pathname' require 'fileutils' include FileUtils # path to your application root. -APP_ROOT = Pathname.new File.expand_path('..', __dir__) +APP_ROOT = File.expand_path('..', __dir__) def system!(*args) system(*args) || abort("\n== Command #{args} failed ==") diff --git a/bin/update b/bin/update index 1d6aa6a5..58bfaed5 100755 --- a/bin/update +++ b/bin/update @@ -1,12 +1,9 @@ #!/usr/bin/env ruby -# frozen_string_literal: true - -require 'pathname' require 'fileutils' include FileUtils # path to your application root. -APP_ROOT = Pathname.new File.expand_path('..', __dir__) +APP_ROOT = File.expand_path('..', __dir__) def system!(*args) system(*args) || abort("\n== Command #{args} failed ==") @@ -20,6 +17,9 @@ chdir APP_ROOT do system! 'gem install bundler --conservative' system('bundle check') || system!('bundle install') + # Install JavaScript dependencies if using Yarn + # system('bin/yarn') + puts "\n== Updating database ==" system! 'bin/rails db:migrate' diff --git a/bin/yarn b/bin/yarn new file mode 100755 index 00000000..460dd565 --- /dev/null +++ b/bin/yarn @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby +APP_ROOT = File.expand_path('..', __dir__) +Dir.chdir(APP_ROOT) do + begin + exec "yarnpkg", *ARGV + rescue Errno::ENOENT + $stderr.puts "Yarn executable was not detected in the system." + $stderr.puts "Download Yarn at https://yarnpkg.com/en/docs/install" + exit 1 + end +end From 5129e6d8d6dddf5e3ecd671409e8990c89814c95 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 16:21:16 -0300 Subject: [PATCH 32/44] usar secretos --- .env.example | 2 -- .gitignore | 4 ++++ config/initializers/devise.rb | 2 +- config/secrets.yml | 32 -------------------------------- 4 files changed, 5 insertions(+), 35 deletions(-) delete mode 100644 config/secrets.yml diff --git a/.env.example b/.env.example index 0a8c05d6..eea8055d 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,6 @@ RAILS_ENV=production -SECRET_KEY_BASE= IMAP_SERVER= DEFAULT_FROM= -DEVISE_PEPPER= SKEL_SUTTY=https://0xacab.org/sutty/skel.sutty.nl SUTTY=sutty.nl REDIS_SERVER= diff --git a/.gitignore b/.gitignore index 5cf7616d..b89536cd 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,7 @@ /data/* .env + +# Ignore master key for decrypting credentials and more. +/config/master.key +/config/credentials.yml.enc diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index e3dce7c2..fe8d487f 100644 --- a/config/initializers/devise.rb +++ b/config/initializers/devise.rb @@ -127,7 +127,7 @@ Devise.setup do |config| config.stretches = Rails.env.test? ? 1 : 11 # Set up a pepper to generate the hashed password. - config.pepper = ENV['DEVISE_PEPPER'] + config.pepper = Rails.application.credentials.devise_pepper # Send a notification to the original email when the user's email is # changed. diff --git a/config/secrets.yml b/config/secrets.yml deleted file mode 100644 index aead418f..00000000 --- a/config/secrets.yml +++ /dev/null @@ -1,32 +0,0 @@ -# Be sure to restart your server when you modify this file. - -# Your secret key is used for verifying the integrity of signed cookies. -# If you change this key, all old signed cookies will become invalid! - -# Make sure the secret is at least 30 characters and all random, -# no regular words or you'll be exposed to dictionary attacks. -# You can use `rails secret` to generate a secure secret key. - -# Make sure the secrets in this file are kept private -# if you're sharing your code publicly. - -# Shared secrets are available across all environments. - -# shared: -# api_key: a1B2c3D4e5F6 - -# Environmental secrets are only available for that specific environment. - -development: - secret_key_base: 18809d32b6661e906759535c3de06955d0eb551a83de5639f1ca4f0375bafd9653b818c4b881942e5cd5cc8da265617c9164fdb63b9f491d4481036c3d23e677 - -test: - secret_key_base: 95f26bd27ca88acb1f0d8d207fa5e60ae7dc56463774990c4acb938110af035690c929f844eaa97cc9a06b67f44631663f40c927b19c706dcccf629143550a2f - -# Do not keep production secrets in the unencrypted secrets file. -# Instead, either read values from the environment. -# Or, use `bin/rails secrets:setup` to configure encrypted secrets -# and move the `production:` environment over there. - -production: - secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> From 11b945721ed2b9e7e9c3122296052cf0bedd34eb Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 16:21:34 -0300 Subject: [PATCH 33/44] container: sincronizar antes de monit y copiar Gemfile modificado --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index ddf957c2..2d0c39ba 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,17 +92,19 @@ RUN tar xf /tmp/sutty.tar.gz && rm /tmp/sutty.tar.gz COPY --from=build --chown=app:www-data /home/app/sutty/public/assets public/assets COPY --from=build --chown=app:www-data /home/app/sutty/vendor vendor COPY --from=build --chown=app:www-data /home/app/sutty/.bundle .bundle +COPY --from=build --chown=app:www-data /home/app/sutty/Gemfile Gemfile +COPY --from=build --chown=app:www-data /home/app/sutty/Gemfile.lock Gemfile.lock # Volver a root para cerrar la compilación USER root -# Instalar la configuración de monit y comprobarla -RUN install -m 640 -o root -g root ./monit.conf /etc/monit.d/sutty.conf -RUN monit -t # Sincronizar los assets a un directorio compartido RUN apk add --no-cache rsync COPY ./sync_assets.sh /usr/local/bin/sync_assets RUN chmod 755 /usr/local/bin/sync_assets +# Instalar la configuración de monit y comprobarla +RUN install -m 640 -o root -g root ./monit.conf /etc/monit.d/sutty.conf +RUN monit -t # Mantener estos directorios! VOLUME "/srv/http/_deploy" From 282559acbc7b0691897b989e50df615160617bf6 Mon Sep 17 00:00:00 2001 From: f Date: Mon, 29 Jul 2019 16:42:06 -0300 Subject: [PATCH 34/44] deploy: copiar las credenciales --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index 2d0c39ba..4620f54d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -94,6 +94,7 @@ COPY --from=build --chown=app:www-data /home/app/sutty/vendor vendor COPY --from=build --chown=app:www-data /home/app/sutty/.bundle .bundle COPY --from=build --chown=app:www-data /home/app/sutty/Gemfile Gemfile COPY --from=build --chown=app:www-data /home/app/sutty/Gemfile.lock Gemfile.lock +COPY ./config/credentials.yml.enc ./config/credentials.yml.enc # Volver a root para cerrar la compilación USER root From 161d9eede2b903fc40262b1b8c42a6e7d6ddc87e Mon Sep 17 00:00:00 2001 From: f Date: Tue, 30 Jul 2019 17:05:46 -0300 Subject: [PATCH 35/44] =?UTF-8?q?manipular=20la=20configuraci=C3=B3n=20por?= =?UTF-8?q?=20separado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/site.rb | 37 +++-------- app/models/site/config.rb | 65 +++++++++++++++++++ app/models/site/repository.rb | 4 ++ bin/bundle | 110 +++++++++++++++++++++++++++++++- test/models/site/config_test.rb | 38 +++++++++++ 5 files changed, 223 insertions(+), 31 deletions(-) create mode 100644 app/models/site/config.rb create mode 100644 test/models/site/config_test.rb diff --git a/app/models/site.rb b/app/models/site.rb index 7b26024a..dd86a9f8 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -70,7 +70,7 @@ class Site < ApplicationRecord # Este sitio acepta invitadxs? def invitadxs? - jekyll.config.fetch('invitadxs', false) + config.fetch('invitadxs', false) end def cover @@ -84,12 +84,12 @@ class Site < ApplicationRecord # Define si el sitio tiene un glosario def glossary? - jekyll.config.fetch('glossary', false) + config.fetch('glossary', false) end # Obtiene la lista de traducciones actuales def translations - @jekyll.config.dig('i18n') || [] + config.fetch('i18n', []) end # Devuelve el idioma por defecto del sitio @@ -146,14 +146,10 @@ class Site < ApplicationRecord end def config - if @jekyll.config.empty? - read - Rails.logger.info 'Leyendo config' - end - - @jekyll.config + @config ||= Site::Config.new(self) end + # TODO: Cambiar a Site::Config apenas empecemos a testear esto def collections_names @jekyll.config['collections'].keys end @@ -270,31 +266,14 @@ class Site < ApplicationRecord File.join('/', 'sites', id, path.gsub('..', '')) end - def get_url_from_site(path) - "https://#{name}#{path}" - end - # El directorio donde se almacenan los sitios def self.site_path File.join(Rails.root, '_sites') end - # El directorio de los sitios de una usuaria - # - # Los sitios se organizan por usuaria, entonces los sitios que - # administra pueden encontrarse directamente en su directorio. - # - # Si comparten gestión con otras usuarias, se hacen links simbólicos - # entre sí. - def self.site_path_for(site) - File.join(Site.site_path, site) - end - - # Comprueba que el directorio parezca ser de jekyll - def self.jekyll?(dir) - File.directory?(dir) && File.exist?(File.join(dir, '_config.yml')) - end - + # TODO: En lugar de leer todo junto de una vez, extraer la carga de + # documentos de Jekyll hacia Sutty para que podamos leer los datos que + # necesitamos. def self.load_jekyll(path) # Pasamos destination porque configuration() toma el directorio # actual y se mezclan :/ diff --git a/app/models/site/config.rb b/app/models/site/config.rb new file mode 100644 index 00000000..34b64696 --- /dev/null +++ b/app/models/site/config.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class Site + # Representa la configuración del sitio de forma que podamos leer y + # escribir en el archivo _config.yml + class Config < OpenStruct + def initialize(site) + # Iniciar el OpenStruct con el sitio + super(site: site) + + read + end + + # Obtener un valor por defecto a partir de la configuración + def fetch(key, default) + send(:[], key) || default + end + + # Leer el archivo de configuración y setear los atributos en el + # objeto actual, creando los metodos de ostruct + def read + YAML.safe_load(File.read(path)).each do |key, value| + send("#{key}=".to_sym, value) + end + end + + # Escribe los cambios en el repositorio + # + # TODO: Convertir en una clase intermedia que también se encargue de + # guardar en git + def write + r = File.open(path, File::RDWR | File::CREAT, 0o640) do |f| + # Bloquear el archivo para que no sea accedido por otro + # proceso u otra editora + f.flock(File::LOCK_EX) + + # Empezar por el principio + f.rewind + + # Escribir el contenido en YAML + f.write(content.to_yaml) + + # Eliminar el resto + f.flush + f.truncate(f.pos) + end + + r.zero? + end + + # Obtener el contenido de la configuración como un hash, sin el + # sitio correspondiente. + def content + h = to_h.stringify_keys + h.delete 'site' + + h + end + + # Obtener la ruta donde se encuentra la configuración. + def path + File.join site.path, '_config.yml' + end + end +end diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb index e8d49c09..e182a2a9 100644 --- a/app/models/site/repository.rb +++ b/app/models/site/repository.rb @@ -18,6 +18,10 @@ class Site end # Trae los cambios del repositorio de origen sin aplicarlos y + # devuelve la cantidad de commits pendientes. + # + # XXX: Prestar atención a la velocidad de respuesta cuando tengamos + # repositorios remotos. def fetch if remote.check_connection :fetch @changes = rugged.fetch(remote)[:received_objects] diff --git a/bin/bundle b/bin/bundle index f19acf5b..6965387f 100755 --- a/bin/bundle +++ b/bin/bundle @@ -1,3 +1,109 @@ #!/usr/bin/env ruby -ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) -load Gem.bin_path('bundler', 'bundle') +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require 'rubygems' + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__) + end + + def env_var_version + ENV['BUNDLER_VERSION'] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless 'update'.start_with?(ARGV.first || ' ') # must be running `bundle update` + + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + + bundler_version = Regexp.last_match(1) || '>= 0.a' + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV['BUNDLE_GEMFILE'] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path('../Gemfile', __dir__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when 'gems.rb' then gemfile.sub(/\.rb$/, gemfile) + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + + Regexp.last_match(1) + end + + def bundler_version + @bundler_version ||= begin + env_var_version || cli_arg_version || + lockfile_version || "#{Gem::Requirement.default}.a" + end + end + + def load_bundler! + ENV['BUNDLE_GEMFILE'] ||= gemfile + + # must dup string for RG < 1.8 compatibility + activate_bundler(bundler_version.dup) + end + + def activate_bundler(bundler_version) + if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new('2.0') + bundler_version = '< 2' + end + gem_error = activation_error_handling do + gem 'bundler', bundler_version + end + return if gem_error.nil? + + require_error = activation_error_handling do + require 'bundler/version' + end + return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + + warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +load Gem.bin_path('bundler', 'bundle') if m.invoked_as_script? diff --git a/test/models/site/config_test.rb b/test/models/site/config_test.rb new file mode 100644 index 00000000..1ecbaba7 --- /dev/null +++ b/test/models/site/config_test.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ConfigText < ActiveSupport::TestCase + setup do + @rol = create :rol + @site = @rol.site + @usuarie = @rol.usuarie + end + + teardown do + @site.destroy + end + + test 'se puede leer' do + assert @site.config.is_a?(Site::Config) + assert_equal @site, @site.config.site + assert @site.config.plugins.count.positive? + end + + test 'se puede escribir' do + assert_nothing_raised do + @site.config.name = 'Test' + @site.config.lang = 'es' + end + + assert @site.config.write + + config = Site::Config.new(@site) + + assert_equal 'Test', config.name + assert_equal 'es', config.lang + end + + test 'se puede obtener información' do + assert @site.config.fetch('noexiste', true) + assert @site.config.fetch('sass', false) + end +end From 396b7a488180f314dc302d5d0309c78ff34ddd01 Mon Sep 17 00:00:00 2001 From: f Date: Tue, 30 Jul 2019 17:08:07 -0300 Subject: [PATCH 36/44] ruby 2.5.5 --- .ruby-version | 1 + 1 file changed, 1 insertion(+) create mode 100644 .ruby-version diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 00000000..0cadbc1e --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.5.5 From 4e1cfdd7262b67cb792825e823f8b78581ae9655 Mon Sep 17 00:00:00 2001 From: f Date: Tue, 30 Jul 2019 18:07:08 -0300 Subject: [PATCH 37/44] guardar los cambios en git! --- app/models/site/config.rb | 33 ++++++++++------------- app/models/site/repository.rb | 24 +++++++++++++++++ app/models/site/writer.rb | 46 +++++++++++++++++++++++++++++++++ config/locales/en.yml | 2 ++ config/locales/es.yml | 2 ++ test/models/site/config_test.rb | 5 +++- 6 files changed, 91 insertions(+), 21 deletions(-) create mode 100644 app/models/site/writer.rb diff --git a/app/models/site/config.rb b/app/models/site/config.rb index 34b64696..9660d9f1 100644 --- a/app/models/site/config.rb +++ b/app/models/site/config.rb @@ -19,33 +19,26 @@ class Site # Leer el archivo de configuración y setear los atributos en el # objeto actual, creando los metodos de ostruct def read - YAML.safe_load(File.read(path)).each do |key, value| + data = YAML.safe_load(File.read(path)) + @hash = data.hash + + data.each do |key, value| send("#{key}=".to_sym, value) end end # Escribe los cambios en el repositorio - # - # TODO: Convertir en una clase intermedia que también se encargue de - # guardar en git - def write - r = File.open(path, File::RDWR | File::CREAT, 0o640) do |f| - # Bloquear el archivo para que no sea accedido por otro - # proceso u otra editora - f.flock(File::LOCK_EX) + def write(usuarie) + return if persisted? - # Empezar por el principio - f.rewind + Site::Writer.new(site: site, file: path, + content: content.to_yaml, usuarie: usuarie, + message: I18n.t('sites.repository.config')).save + end - # Escribir el contenido en YAML - f.write(content.to_yaml) - - # Eliminar el resto - f.flush - f.truncate(f.pos) - end - - r.zero? + # Detecta si la configuración cambió comparando con el valor inicial + def persisted? + @hash == content.hash end # Obtener el contenido de la configuración como un hash, sin el diff --git a/app/models/site/repository.rb b/app/models/site/repository.rb index e182a2a9..f59ad217 100644 --- a/app/models/site/repository.rb +++ b/app/models/site/repository.rb @@ -78,5 +78,29 @@ class Site walker.each.to_a end + + # Guarda los cambios en git, de a un archivo por vez + # rubocop:disable Metrics/AbcSize + def commit(file:, usuarie:, message:) + rugged.index.add(file) + rugged.index.write + + Rugged::Commit.create(rugged, + update_ref: 'HEAD', + parents: [rugged.head.target], + tree: rugged.index.write_tree, + message: message, + author: author(usuarie), + committer: committer) + end + # rubocop:enable Metrics/AbcSize + + def author(author) + { name: author.name, email: author.email, time: Time.now } + end + + def committer + { name: 'Sutty', email: "sutty@#{Site.domain}", time: Time.now } + end end end diff --git a/app/models/site/writer.rb b/app/models/site/writer.rb new file mode 100644 index 00000000..f9350f12 --- /dev/null +++ b/app/models/site/writer.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Site + # Se encarga de guardar los cambios en los archivos y mantenerlos + # actualizados en git + class Writer + attr_reader :site, :file, :content, :usuarie, :message + + def initialize(site:, file:, content:, usuarie:, message:) + @site = site + @content = content + @file = file + @usuarie = usuarie + @message = message + end + + # rubocop:disable Metrics/AbcSize + def save + r = File.open(file, File::RDWR | File::CREAT, 0o640) do |f| + # Bloquear el archivo para que no sea accedido por otro + # proceso u otra editora + f.flock(File::LOCK_EX) + + # Empezar por el principio + f.rewind + + # Escribir el contenido + f.write(content) + + # Eliminar el resto + f.flush + f.truncate(f.pos) + end + + r.zero? && site.repository.commit(file: relative_file, + usuarie: usuarie, + message: message) + end + # rubocop:enable Metrics/AbcSize + + # Devuelve la ruta relativa a la raíz del sitio + def relative_file + Pathname.new(file).relative_path_from(Pathname.new(site.path)).to_s + end + end +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 157eaf62..9b03ef04 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -185,6 +185,8 @@ en: ejemplo: 'example' sites: + repository: + config: 'Changes in config' actions: 'Actions' posts: 'View and edit posts' title: 'Sites' diff --git a/config/locales/es.yml b/config/locales/es.yml index f2f43591..783933d7 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -194,6 +194,8 @@ es: También sirve para archivo histórico :) ejemplo: 'ejemplo' sites: + repository: + config: 'Cambios en la configuración' actions: 'Acciones' posts: 'Ver y editar artículos' title: 'Sitios' diff --git a/test/models/site/config_test.rb b/test/models/site/config_test.rb index 1ecbaba7..4ccaf370 100644 --- a/test/models/site/config_test.rb +++ b/test/models/site/config_test.rb @@ -23,12 +23,15 @@ class ConfigText < ActiveSupport::TestCase @site.config.lang = 'es' end - assert @site.config.write + assert @site.config.write(@usuarie) config = Site::Config.new(@site) assert_equal 'Test', config.name assert_equal 'es', config.lang + + assert_equal I18n.t('sites.repository.config'), + @site.repository.rugged.head.target.message end test 'se puede obtener información' do From 9a69edb05bb9d0b3cef0c46d510495c7d4e603dd Mon Sep 17 00:00:00 2001 From: f Date: Wed, 31 Jul 2019 17:55:34 -0300 Subject: [PATCH 38/44] agregar titulo y descripcion al sitio --- app/assets/stylesheets/application.scss | 22 ++++++++++++++ app/controllers/sites_controller.rb | 21 +++++++------ app/helpers/application_helper.rb | 17 +++++++++++ app/models/site.rb | 12 ++++++++ app/models/site/config.rb | 4 ++- app/views/sites/_form.haml | 30 +++++++++++++++++-- config/locales/es.yml | 8 +++-- .../20190730211624_add_description_to_site.rb | 8 +++++ .../20190730211756_add_title_to_sites.rb | 8 +++++ db/schema.rb | 4 ++- doc/crear_sitios.md | 10 +++++++ test/controllers/sites_controller_test.rb | 2 ++ test/factories/site.rb | 2 ++ 13 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 db/migrate/20190730211624_add_description_to_site.rb create mode 100644 db/migrate/20190730211756_add_title_to_sites.rb diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 83bffeac..097605d7 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -5,6 +5,28 @@ @import "select2-theme-bootstrap4/dist/select2-bootstrap"; @import "dragula-with-animation/dist/dragula"; +@font-face { + font-family: 'Saira'; + font-style: normal; + font-weight: 500; + font-display: optional; + src: local('Saira Medium'), local('Saira-Medium'), + font-url('saira/v3/SairaMedium.ttf') format('truetype'); +} + +@font-face { + font-family: 'Saira'; + font-style: normal; + font-weight: 700; + font-display: optional; + src: local('Saira Bold'), local('Saira-Bold'), + font-url('saira/v3/SairaBold.ttf') format('truetype'); +} + +body { + font-family: Saira, sans-serif; +} + $footer-height: 60px; /* Colores */ diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 3b2f6722..527762a0 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -30,11 +30,14 @@ class SitesController < ApplicationController def create @site = Site.new(site_params) - current_usuarie.roles << Rol.new(site: @site, - temporal: false, - rol: 'usuarie') + @site.roles << Rol.new(site: @site, + usuarie: current_usuarie, + temporal: false, + rol: 'usuarie') - if current_usuarie.save + # XXX: Necesitamos escribir la configuración después porque queremos + # registrar quién hizo los cambios en el repositorio + if @site.save && @site.config.write(current_usuarie) redirect_to site_path(@site) else render 'new' @@ -50,7 +53,9 @@ class SitesController < ApplicationController @site = find_site authorize @site - if @site.update(site_params) + # XXX: Necesitamos escribir la configuración después porque queremos + # registrar quién hizo los cambios en el repositorio + if @site.update(site_params) && @site.config.write(current_usuarie) redirect_to sites_path else render 'edit' @@ -133,9 +138,7 @@ class SitesController < ApplicationController def site_params params.require(:site) - .permit(:name, :design_id, :licencia_id, - deploys_attributes: %i[ - type id _destroy - ]) + .permit(:name, :design_id, :licencia_id, :description, :title, + deploys_attributes: %i[type id _destroy]) end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f8ca5520..449f9c5c 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +# Helpers module ApplicationHelper # Devuelve el atributo name de un campo posiblemente anidado def field_name_for_post(names) @@ -24,4 +25,20 @@ module ApplicationHelper def sanitize_markdown(text, options = {}) sanitize(CommonMarker.render_html(text), options) end + + def invalid?(model, field) + model.errors.messages[field].present? + end + + def form_control(model, field) + if invalid? model, field + 'form-control is-invalid' + else + 'form-control' + end + end + + def form_class(model) + model.errors.messages.empty? ? 'needs-validation' : 'was-validated' + end end diff --git a/app/models/site.rb b/app/models/site.rb index dd86a9f8..6c537e0b 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -9,6 +9,8 @@ class Site < ApplicationRecord validates :design_id, presence: true validate :deploy_local_presence validates_inclusion_of :status, in: %w[waiting enqueued building] + validates_presence_of :title + validates :description, length: { in: 50..160 } friendly_id :name, use: %i[finders] @@ -32,6 +34,8 @@ class Site < ApplicationRecord after_create :load_jekyll! # Cambiar el nombre del directorio before_update :update_name! + # Guardar la configuración si hubo cambios + after_save :sync_attributes_with_config! attr_accessor :jekyll, :collections @@ -335,6 +339,14 @@ class Site < ApplicationRecord FileUtils.mv old_path, path end + # Sincroniza algunos atributos del sitio con su configuración y + # guarda los cambios + def sync_attributes_with_config! + config.theme = design.gem unless design_id_changed? + config.description = description unless description_changed? + config.title = title unless title_changed? + end + # Valida si el sitio tiene al menos una forma de alojamiento asociada # y es la local # diff --git a/app/models/site/config.rb b/app/models/site/config.rb index 9660d9f1..1fc459cc 100644 --- a/app/models/site/config.rb +++ b/app/models/site/config.rb @@ -28,12 +28,14 @@ class Site end # Escribe los cambios en el repositorio - def write(usuarie) + def write(usuarie = nil) return if persisted? Site::Writer.new(site: site, file: path, content: content.to_yaml, usuarie: usuarie, message: I18n.t('sites.repository.config')).save + # Actualizar el hash para no escribir dos veces + @hash = content.hash end # Detecta si la configuración cambió comparando con el valor inicial diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index 734e1e2c..579d1a93 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -1,8 +1,34 @@ -= form_for site do |f| += form_for site, html: { class: form_class(site) } do |f| .form-group %h2= f.label :name %p.lead= t('.help.name') - = f.text_field :name, class: 'form-control' + -# + El dominio contiene letras y números + No puede empezar ni terminar con guiones + No puede estar compuesto solo de números + + = f.text_field :name, + class: form_control(site, :name), + required: true, + pattern: '^([a-z0-9][a-z0-9\-]*)?[a-z0-9]$', + minlength: 1, + maxlength: 63 + - if invalid? site, :name + .invalid-feedback= site.errors.messages[:name].join(', ') + .form-group + %h2= f.label :title + %p.lead= t('.help.title') + = f.text_field :title, class: form_control(site, :title), + required: true + - if invalid? site, :title + .invalid-feedback= site.errors.messages[:title].join(', ') + .form-group + %h2= f.label :description + %p.lead= t('.help.description') + = f.text_area :description, class: form_control(site, :description), + maxlength: 160, minlength: 50, required: true + - if invalid? site, :description + .invalid-feedback= site.errors.messages[:description].join(', ') .form-group %h2= t('.design.title') %p.lead= t('.help.design') diff --git a/config/locales/es.yml b/config/locales/es.yml index 783933d7..5f0e5438 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -30,6 +30,8 @@ es: password_confirmation: 'Confirmación de contraseña' site: name: 'Nombre' + title: 'Título' + description: 'Descripción' errors: models: site: @@ -215,7 +217,9 @@ es: submit: 'Guardar cambios' form: help: - name: 'El nombre de tu sitio. Solo puede contener letras y números.' + name: 'El nombre de tu sitio que formará parte de la dirección (ejemplo.sutty.nl). Solo puede contener letras minúsculas, números y guiones.' + title: 'El título de tu sitio puede ser lo que quieras.' + description: 'La descripción del sitio, que saldrá en buscadores. Entre 50 y 160 caracteres.' design: 'Elegí el diseño que va a tener tu sitio aquí. Podés cambiarlo luego. De tanto en tanto vamos sumando diseños nuevos.' licencia: 'Todo lo que publicamos posee automáticamente derechos de autore. Esto significa que nadie puede hacer uso de nuestras @@ -231,7 +235,7 @@ es: url: 'Demostración' license: 'Leer la licencia' licencia: - title: 'Licencia del sitio y todo lo que se publique' + title: 'Licencia del sitio y todo lo que publiques' url: 'Leer la licencia' deploys: title: '¿Dónde querés alojar tu sitio?' diff --git a/db/migrate/20190730211624_add_description_to_site.rb b/db/migrate/20190730211624_add_description_to_site.rb new file mode 100644 index 00000000..d907df47 --- /dev/null +++ b/db/migrate/20190730211624_add_description_to_site.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Agrega la descripción de un sitio +class AddDescriptionToSite < ActiveRecord::Migration[5.2] + def change + add_column :sites, :description, :text + end +end diff --git a/db/migrate/20190730211756_add_title_to_sites.rb b/db/migrate/20190730211756_add_title_to_sites.rb new file mode 100644 index 00000000..a48a2419 --- /dev/null +++ b/db/migrate/20190730211756_add_title_to_sites.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Agrega el título al sitio +class AddTitleToSites < ActiveRecord::Migration[5.2] + def change + add_column :sites, :title, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index f7c0bd7b..94557335 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_726_003_756) do +ActiveRecord::Schema.define(version: 20_190_730_211_756) do create_table 'build_stats', force: :cascade do |t| t.datetime 'created_at', null: false t.datetime 'updated_at', null: false @@ -99,6 +99,8 @@ ActiveRecord::Schema.define(version: 20_190_726_003_756) do t.integer 'design_id' t.integer 'licencia_id' t.string 'status', default: 'waiting' + t.text 'description' + t.string 'title' t.index ['design_id'], name: 'index_sites_on_design_id' t.index ['licencia_id'], name: 'index_sites_on_licencia_id' t.index ['name'], name: 'index_sites_on_name', unique: true diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index 92a309c6..a70173c4 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -212,3 +212,13 @@ método de deployment). El plan es migrar todo esto a de forma que la compilación se haga por separado de sutty. Este es un plan intermedio hasta que tengamos tiempo de hacerlo realmente. + + +# TODO + +* ver las estadisticas de compilación en lugar del log (el log también) +* comitear en git los articulos (igual no es de esta rama...) +* sanitizar titulo y descripcion, tambien escapar +* al crear el sitio incorporar politica de privacidad y codigo de + convivencia +* link a visitar sitio diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb index 1c5d30ef..35646f3b 100644 --- a/test/controllers/sites_controller_test.rb +++ b/test/controllers/sites_controller_test.rb @@ -34,6 +34,8 @@ class SitesControllerTest < ActionDispatch::IntegrationTest post sites_url, headers: @authorization, params: { site: { name: name, + title: name, + description: name * 2, design_id: create(:design).id, licencia_id: create(:licencia).id, deploys_attributes: { diff --git a/test/factories/site.rb b/test/factories/site.rb index f59221e3..d9d8720f 100644 --- a/test/factories/site.rb +++ b/test/factories/site.rb @@ -3,6 +3,8 @@ FactoryBot.define do factory :site do name { "test-#{SecureRandom.hex}" } + title { SecureRandom.hex } + description { SecureRandom.hex * 2 } design licencia From 8bf0bdc508ca1668e14dc4c5e36f89d8a1e3d9b4 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 1 Aug 2019 13:26:47 -0300 Subject: [PATCH 39/44] el mensaje de commit sale con el idioma de le usuarie --- app/models/site/config.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/models/site/config.rb b/app/models/site/config.rb index 1fc459cc..8ed79d96 100644 --- a/app/models/site/config.rb +++ b/app/models/site/config.rb @@ -31,9 +31,11 @@ class Site def write(usuarie = nil) return if persisted? - Site::Writer.new(site: site, file: path, - content: content.to_yaml, usuarie: usuarie, - message: I18n.t('sites.repository.config')).save + I18n.with_locale(usuarie.try(:lang) || I18n.default_locale) do + Site::Writer.new(site: site, file: path, + content: content.to_yaml, usuarie: usuarie, + message: I18n.t('sites.repository.config')).save + end # Actualizar el hash para no escribir dos veces @hash = content.hash end From 09346110b0d04f20caf6a0b9e63d30709a471d46 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 1 Aug 2019 15:15:31 -0300 Subject: [PATCH 40/44] privacidad y codigo de conducta --- app/assets/images/icon_external_link.png | Bin 0 -> 144 bytes app/assets/javascripts/external_links.js | 5 +++++ app/assets/stylesheets/application.scss | 9 +++++++++ app/views/sites/_form.haml | 12 ++++++++++++ config/locales/en.yml | 10 +++++++++- config/locales/es.yml | 11 +++++++++++ 6 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 app/assets/images/icon_external_link.png create mode 100644 app/assets/javascripts/external_links.js diff --git a/app/assets/images/icon_external_link.png b/app/assets/images/icon_external_link.png new file mode 100644 index 0000000000000000000000000000000000000000..16f9b92db47a1f1cd9d2320cc7d03122155a5200 GIT binary patch literal 144 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a)4a8DPr>mdKI;Vst028P**8l(j literal 0 HcmV?d00001 diff --git a/app/assets/javascripts/external_links.js b/app/assets/javascripts/external_links.js new file mode 100644 index 00000000..17e9cf2e --- /dev/null +++ b/app/assets/javascripts/external_links.js @@ -0,0 +1,5 @@ +$(document).on('turbolinks:load', function() { + $("a[href^='http://']").attr('target', '_blank'); + $("a[href^='https://']").attr('target', '_blank'); + $("a[href^='//']").attr('target', '_blank'); +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 097605d7..e4119a92 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -27,6 +27,15 @@ body { font-family: Saira, sans-serif; } +a { + &[target=_blank] { + /* TODO: Convertir a base64 para no hacer peticiones extra */ + &:after { + content: image-url('icon_external_link.png'); + } + } +} + $footer-height: 60px; /* Colores */ diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml index 579d1a93..e9a008aa 100644 --- a/app/views/sites/_form.haml +++ b/app/views/sites/_form.haml @@ -15,6 +15,7 @@ maxlength: 63 - if invalid? site, :name .invalid-feedback= site.errors.messages[:name].join(', ') + .form-group %h2= f.label :title %p.lead= t('.help.title') @@ -22,6 +23,7 @@ required: true - if invalid? site, :title .invalid-feedback= site.errors.messages[:title].join(', ') + .form-group %h2= f.label :description %p.lead= t('.help.description') @@ -29,6 +31,8 @@ maxlength: 160, minlength: 50, required: true - if invalid? site, :description .invalid-feedback= site.errors.messages[:description].join(', ') + %hr/ + .form-group %h2= t('.design.title') %p.lead= t('.help.design') @@ -51,6 +55,8 @@ - if design.license = link_to t('.design.license'), design.license, target: '_blank', class: 'btn btn-info' + %hr/ + .form-group %h2= t('.licencia.title') %p.lead= t('.help.licencia') @@ -72,6 +78,12 @@ %hr/ + .form-group + %h2= t('.privacidad.title') + %p.lead= sanitize_markdown t('.help.privacidad'), tags: %w[a] + + %hr/ + .form-group %h2= t('.deploys.title') %p.lead= t('.help.deploys') diff --git a/config/locales/en.yml b/config/locales/en.yml index 9b03ef04..602ec0f2 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -212,11 +212,17 @@ en: means nobody can use our works without explicit permission. By using licenses, we stablish conditions by which we want to share them.' + privacidad: | + The [privacy policy](https://sutty.nl/en/privacy-policy/) and + [code of conduct](https://sutty.nl/en/code-of-conduct/) inform + your visitors about their privacy and expected conduct of the + site's community. We suggest you use the same documents Sutty + uses. You can modify them as articles after creating the + site. deploys: | Sutty allows you to host your site in different places at the same time. This strategy makes your site available even when some of them become unavailable. - design: title: 'Design' actions: 'Information about this design' @@ -225,6 +231,8 @@ en: licencia: title: 'License for the site and everything in it' url: 'Read the license' + privacidad: + title: 'Privacy policy and code of conduct' deploys: title: 'Where do you want your site to be hosted?' fetch: diff --git a/config/locales/es.yml b/config/locales/es.yml index 5f0e5438..7be2b4d4 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -225,6 +225,15 @@ es: de autore. Esto significa que nadie puede hacer uso de nuestras obras sin permiso explícito. Con las licencias establecemos condiciones bajo las que queremos compartir.' + privacidad: | + Las [políticas de + privacidad](https://sutty.nl/es/politica-de-privacidad/) y + [código de + convivencia](https://sutty.nl/es/codigo-de-convivencia/) + informan a les visitantes qué garantías de privacidad vas a + darles y con qué códigos se va a autogestionar su comunidad. + Sugerimos las mismas que las de Sutty. Una vez creado el + sitio, podrás editarlas como artículos. deploys: | Sutty te permite alojar tu sitio en distintos lugares al mismo tiempo. Esta estrategia facilita que el sitio esté disponible @@ -237,6 +246,8 @@ es: licencia: title: 'Licencia del sitio y todo lo que publiques' url: 'Leer la licencia' + privacidad: + title: Políticas de privacidad y código de convivencia deploys: title: '¿Dónde querés alojar tu sitio?' fetch: From cc94a76567589fe8f97239aa430295bc3ea79da2 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 1 Aug 2019 20:13:38 -0300 Subject: [PATCH 41/44] no permitir html en description ni title --- app/lib/core_extensions/string/strip_tags.rb | 12 ++++++++++++ app/models/site.rb | 9 +++++++++ config/initializers/core_extensions.rb | 3 +++ doc/crear_sitios.md | 6 +++--- test/models/site_test.rb | 9 +++++++++ 5 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 app/lib/core_extensions/string/strip_tags.rb create mode 100644 config/initializers/core_extensions.rb diff --git a/app/lib/core_extensions/string/strip_tags.rb b/app/lib/core_extensions/string/strip_tags.rb new file mode 100644 index 00000000..76e35f8b --- /dev/null +++ b/app/lib/core_extensions/string/strip_tags.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module CoreExtensions + module String + # Elimina el HTML + module StripTags + def strip_tags + ActionController::Base.helpers.strip_tags(self) + end + end + end +end diff --git a/app/models/site.rb b/app/models/site.rb index 6c537e0b..354e376a 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -41,6 +41,15 @@ class Site < ApplicationRecord accepts_nested_attributes_for :deploys, allow_destroy: true + # No permitir HTML en estos atributos + def title=(title) + super(title.strip_tags) + end + + def description=(description) + super(description.strip_tags) + end + # El repositorio git para este sitio def repository @repository ||= Site::Repository.new path diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb new file mode 100644 index 00000000..2207fe85 --- /dev/null +++ b/config/initializers/core_extensions.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +String.include CoreExtensions::String::StripTags diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index a70173c4..7f6bd8af 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -218,7 +218,7 @@ tengamos tiempo de hacerlo realmente. * ver las estadisticas de compilación en lugar del log (el log también) * comitear en git los articulos (igual no es de esta rama...) -* sanitizar titulo y descripcion, tambien escapar -* al crear el sitio incorporar politica de privacidad y codigo de - convivencia * link a visitar sitio +* editor de opciones +* forkear gemas +* que les usuaries elijan su propio idioma diff --git a/test/models/site_test.rb b/test/models/site_test.rb index b74daaf2..438ef820 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -76,4 +76,13 @@ class SiteTest < ActiveSupport::TestCase assert File.directory?(@site.path) assert_not File.directory?(path) end + + test 'no se puede guardar html en title y description' do + site = build :site + site.description = "hola" + site.title = "hola" + + assert_equal 'hola', site.description + assert_equal 'hola', site.title + end end From aebe5b67640ed737a6eca447ee55d4110ff2cf00 Mon Sep 17 00:00:00 2001 From: f Date: Thu, 1 Aug 2019 21:20:42 -0300 Subject: [PATCH 42/44] =?UTF-8?q?estad=C3=ADsticas=20b=C3=A1sicas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .rubocop.yml | 1 + app/controllers/stats_controller.rb | 18 ++++++++++++++++++ app/helpers/application_helper.rb | 8 ++++++++ app/models/build_stat.rb | 2 ++ app/models/deploy.rb | 2 +- app/models/site.rb | 1 + app/models/site_stat.rb | 3 +++ app/policies/site_stat_policy.rb | 15 +++++++++++++++ app/views/stats/index.haml | 18 ++++++++++++++++++ config/locales/en.yml | 10 ++++++++++ config/locales/es.yml | 10 ++++++++++ config/routes.rb | 2 ++ doc/crear_sitios.md | 3 +++ 13 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 app/controllers/stats_controller.rb create mode 100644 app/models/site_stat.rb create mode 100644 app/policies/site_stat_policy.rb create mode 100644 app/views/stats/index.haml diff --git a/.rubocop.yml b/.rubocop.yml index 8b614a39..fad6db48 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -40,6 +40,7 @@ Metrics/BlockLength: - 'config/environments/production.rb' - 'config/initializers/devise.rb' - 'db/schema.rb' + - 'config/routes.rb' Metrics/ClassLength: Exclude: diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb new file mode 100644 index 00000000..07baaf1a --- /dev/null +++ b/app/controllers/stats_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Estadísticas del sitio +class StatsController < ApplicationController + include Pundit + before_action :authenticate_usuarie! + + def index + @site = find_site + authorize SiteStat.new(@site) + + # Solo queremos el promedio de tiempo de compilación, no de + # instalación de dependencias. + stats = @site.build_stats.jekyll + @build_avg = stats.average(:seconds).to_f.round(2) + @build_max = stats.maximum(:seconds).to_f.round(2) + end +end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 449f9c5c..8be0d41e 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -22,6 +22,14 @@ module ApplicationHelper "#{f.first}[#{f.last}]" end + def distance_of_time_in_words_if_more_than_a_minute(seconds) + if seconds > 60 + distance_of_time_in_words seconds + else + I18n.t('seconds', seconds: seconds) + end + end + def sanitize_markdown(text, options = {}) sanitize(CommonMarker.render_html(text), options) end diff --git a/app/models/build_stat.rb b/app/models/build_stat.rb index c99bf2c3..5a6a9365 100644 --- a/app/models/build_stat.rb +++ b/app/models/build_stat.rb @@ -3,4 +3,6 @@ # Recolecta estadísticas durante la generación del sitio class BuildStat < ApplicationRecord belongs_to :deploy + + scope :jekyll, -> { where(action: 'bundle_exec_jekyll_build') } end diff --git a/app/models/deploy.rb b/app/models/deploy.rb index e91a9d14..4d0ce450 100644 --- a/app/models/deploy.rb +++ b/app/models/deploy.rb @@ -42,7 +42,7 @@ class Deploy < ApplicationRecord # XXX: prestar atención a la concurrencia de sqlite3, se podría # enviar los datos directamente a una API para que se manejen desde # el proceso principal de rails y evitar problemas. - stat = build_stats.build action: cmd.split('-', 2).first.tr(' ', '_') + stat = build_stats.build action: cmd.split(' -', 2).first.tr(' ', '_') r = nil time_start diff --git a/app/models/site.rb b/app/models/site.rb index 354e376a..d566cd3d 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -18,6 +18,7 @@ class Site < ApplicationRecord belongs_to :licencia has_many :deploys + has_many :build_stats, through: :deploys has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles diff --git a/app/models/site_stat.rb b/app/models/site_stat.rb new file mode 100644 index 00000000..73503aca --- /dev/null +++ b/app/models/site_stat.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +SiteStat = Struct.new(:site) diff --git a/app/policies/site_stat_policy.rb b/app/policies/site_stat_policy.rb new file mode 100644 index 00000000..a797034c --- /dev/null +++ b/app/policies/site_stat_policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Política de acceso a las estadísticas +class SiteStatPolicy + attr_reader :site_stat, :usuarie + + def initialize(usuarie, site_stat) + @usuarie = usuarie + @site_stat = site_stat + end + + def index? + site_stat.site.usuarie? usuarie + end +end diff --git a/app/views/stats/index.haml b/app/views/stats/index.haml new file mode 100644 index 00000000..eb55c5d9 --- /dev/null +++ b/app/views/stats/index.haml @@ -0,0 +1,18 @@ +.row + .col + = render 'layouts/breadcrumb', + crumbs: [link_to(t('sites.index'), sites_path), + link_to(@site.name, site_path(@site)), t('.title')] +.row + .col + %h1= t('.title') + %p.lead= t('.help') + + %table.table.table-striped.table-condensed + %tbody + %tr + %td= t('.build.average') + %td= distance_of_time_in_words_if_more_than_a_minute @build_avg + %tr + %td= t('.build.maximum') + %td= distance_of_time_in_words_if_more_than_a_minute @build_max diff --git a/config/locales/en.yml b/config/locales/en.yml index 602ec0f2..6ad6eb9a 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,5 @@ en: + seconds: '%{seconds} seconds' deploy_mailer: deployed: subject: "[Sutty] The site %{site} has been built" @@ -184,6 +185,15 @@ en: It also helps with site archival for historical purposes :) ejemplo: 'example' + stats: + index: + title: Statistics + help: | + Statistics show information about how your site is generated and + how many resources it uses. + build: + average: 'Average building time' + maximum: 'Maximum building time' sites: repository: config: 'Changes in config' diff --git a/config/locales/es.yml b/config/locales/es.yml index 7be2b4d4..0a65b29f 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,5 @@ es: + seconds: '%{seconds} segundos' deploy_mailer: deployed: subject: "[Sutty] El sitio %{site} ha sido generado" @@ -195,6 +196,15 @@ es: También sirve para archivo histórico :) ejemplo: 'ejemplo' + stats: + index: + title: Estadísticas + help: | + Las estadísticas visibilizan información sobre cómo se genera y + cuántos recursos utiliza tu sitio. + build: + average: 'Tiempo promedio de generación' + maximum: 'Tiempo máximo de generación' sites: repository: config: 'Cambios en la configuración' diff --git a/config/routes.rb b/config/routes.rb index 7e66c766..a492ba90 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,5 +42,7 @@ Rails.application.routes.draw do # Compilar el sitio post 'enqueue', to: 'sites#enqueue' post 'reorder_posts', to: 'sites#reorder_posts' + + resources :stats, only: [:index] end end diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index 7f6bd8af..9155b002 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -217,6 +217,9 @@ tengamos tiempo de hacerlo realmente. # TODO * ver las estadisticas de compilación en lugar del log (el log también) + + agrupar los build stats para poder ver todos los pasos de una + compilación juntos * comitear en git los articulos (igual no es de esta rama...) * link a visitar sitio * editor de opciones From 48b85acaced33923b68cb4f03107803bff5e95c2 Mon Sep 17 00:00:00 2001 From: f Date: Fri, 2 Aug 2019 15:58:15 -0300 Subject: [PATCH 43/44] permitir que les usuaries se cambien el idioma --- app/controllers/application_controller.rb | 20 ++++++++- app/views/devise/registrations/edit.haml | 51 ++++++++++++++++------- app/views/layouts/_breadcrumb.haml | 13 +++--- config/initializers/locale.rb | 2 +- config/locales/devise.views.en.yml | 12 +++--- config/locales/devise.views.es.yml | 19 ++++----- config/locales/en.yml | 6 +++ config/locales/es.yml | 6 +++ doc/crear_sitios.md | 2 +- 9 files changed, 93 insertions(+), 38 deletions(-) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d27b7197..b7e9338f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -5,8 +5,12 @@ class ApplicationController < ActionController::Base include ExceptionHandler protect_from_forgery with: :exception + + before_action :configure_permitted_parameters, if: :devise_controller? before_action :set_locale + layout :layout_by_usuarie + # No tenemos índice de sutty, vamos directamente a ver el listado de # sitios def index @@ -17,6 +21,14 @@ class ApplicationController < ActionController::Base private + def layout_by_usuarie + if current_usuarie + 'application' + else + 'devise' + end + end + # Encontrar un sitio por su nombre def find_site id = params[:site_id] || params[:id] @@ -50,6 +62,12 @@ class ApplicationController < ActionController::Base end def set_locale - I18n.locale = session[:lang] if session[:lang].present? + I18n.locale = current_usuarie.lang + end + + protected + + def configure_permitted_parameters + devise_parameter_sanitizer.permit(:account_update, keys: %i[lang]) end end diff --git a/app/views/devise/registrations/edit.haml b/app/views/devise/registrations/edit.haml index d81fff97..31fcb347 100644 --- a/app/views/devise/registrations/edit.haml +++ b/app/views/devise/registrations/edit.haml @@ -1,42 +1,65 @@ +.row + .col + = render 'layouts/breadcrumb', + crumbs: [link_to(t('.index'), sites_path), t('.title')] + .row.align-items-center.justify-content-center.full-height .col-md-6.align-self-center - %h2= t('.title', resource: resource.model_name.human) - = form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put }) do |f| + %h2= t('.title') + = form_for(resource, + as: resource_name, + 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', class: 'form-control' - if devise_mapping.confirmable? && resource.pending_reconfirmation? - %div= t('.currently_waiting_confirmation_for_email', email: resource.unconfirmed_email) + %div + = t('.currently_waiting_confirmation_for_email', + email: resource.unconfirmed_email) + + .form-group + = f.label :lang + = f.select :lang, + I18n.available_locales.map { |lang| [t(lang), lang] }, {}, + class: 'form-control' + .form-group = f.label :password - %i - (#{t('.leave_blank_if_you_don_t_want_to_change_it')}) = f.password_field :password, autocomplete: 'new-password', - class: 'form-control' - - if @minimum_password_length - %em= t('devise.shared.minimum_password_length', count: @minimum_password_length) + class: 'form-control', 'aria-describedby': 'password-help' + %small.text-muted.form-text#password-help + = t('.leave_blank_if_you_don_t_want_to_change_it') + - if @minimum_password_length + = t('devise.shared.minimum_password_length', + count: @minimum_password_length) + .form-group = f.label :password_confirmation = f.password_field :password_confirmation, autocomplete: 'new-password', class: 'form-control' + .form-group = f.label :current_password - %i - (#{t('.we_need_your_current_password_to_confirm_your_changes')}) = f.password_field :current_password, autocomplete: 'current-password', - class: 'form-control' + required: true, + class: 'form-control', + 'aria-describedby': 'current-password-help' + %small.text-muted.form-text#current-password-help + = t('.we_need_your_current_password_to_confirm_your_changes') .actions = f.submit t('.update'), class: 'btn btn-lg btn-primary btn-block' + %hr/ %h3= t('.cancel_my_account') %p - = t('.unhappy') = button_to t('.cancel_my_account'), registration_path(resource_name), data: { confirm: t('.are_you_sure') }, - method: :delete - = link_to t('devise.shared.links.back'), :back + method: :delete, class: 'btn btn-danger btn-block' diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml index 01484ac6..322f4164 100644 --- a/app/views/layouts/_breadcrumb.haml +++ b/app/views/layouts/_breadcrumb.haml @@ -1,17 +1,20 @@ -%nav{'aria-label': 'breadcrumb', role: 'navigation'} +%nav{ 'aria-label': 'breadcrumb', role: 'navigation' } %ol.breadcrumb %li.breadcrumb-item = link_to destroy_usuarie_session_path, method: :delete, data: { toggle: 'tooltip' }, title: t('help.logout'), role: 'button', class: 'btn-text' do = fa_icon 'sign-out', title: t('help.logout') + %li.breadcrumb-item + = link_to edit_usuarie_registration_path, + data: { toggle: 'tooltip' }, title: t('help.usuarie.edit') do + = current_usuarie.email + - if @site.try(:persisted?) && (help = @site.try(:config).try(:dig, 'help')) %li.breadcrumb-item= link_to t('.help'), help, target: '_blank' + - crumbs.compact.each do |crumb| - - if current_user.is_a? Invitadx - - if /\/sites/ =~ crumb - - next - if crumb == crumbs.last - %li.breadcrumb-item.active{'aria-current': 'page'}= crumb + %li.breadcrumb-item.active{ 'aria-current': 'page' }= crumb - else %li.breadcrumb-item= crumb diff --git a/config/initializers/locale.rb b/config/initializers/locale.rb index 056f48ab..e9753dca 100644 --- a/config/initializers/locale.rb +++ b/config/initializers/locale.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true Rails.application.configure do - config.i18n.available_locales = %i[es en ar] + config.i18n.available_locales = %i[es en] config.i18n.default_locale = :es end diff --git a/config/locales/devise.views.en.yml b/config/locales/devise.views.en.yml index aa0697c2..d4c60db0 100644 --- a/config/locales/devise.views.en.yml +++ b/config/locales/devise.views.en.yml @@ -93,14 +93,14 @@ en: registrations: destroyed: Bye! Your account has been successfully cancelled. We hope to see you again soon. edit: + index: 'Back to sites' are_you_sure: Are you sure? cancel_my_account: Cancel my account currently_waiting_confirmation_for_email: 'Currently waiting confirmation for: %{email}' - leave_blank_if_you_don_t_want_to_change_it: leave blank if you don't want to change it - title: Edit %{resource} - unhappy: Unhappy? + leave_blank_if_you_don_t_want_to_change_it: Leave empty if you don't want to change it. + title: Edit my account update: Update - we_need_your_current_password_to_confirm_your_changes: we need your current password to confirm your changes + we_need_your_current_password_to_confirm_your_changes: We need your current password to confirm your changes new: sign_up: Sign up signed_up: Welcome! You have signed up successfully. @@ -126,8 +126,8 @@ en: sign_in_with_provider: Sign in with %{provider} sign_up: Sign up minimum_password_length: - one: "(%{count} character minimum)" - other: "(%{count} characters minimum)" + one: "%{count} character minimum." + other: "%{count} characters minimum." unlocks: new: resend_unlock_instructions: Resend unlock instructions diff --git a/config/locales/devise.views.es.yml b/config/locales/devise.views.es.yml index 0db4db7d..affad804 100644 --- a/config/locales/devise.views.es.yml +++ b/config/locales/devise.views.es.yml @@ -93,14 +93,14 @@ es: registrations: destroyed: "¡Adiós! Tu cuenta ha sido cancelada correctamente. Esperamos verte pronto." edit: - are_you_sure: "¿Estás segura?" - cancel_my_account: Anular mi cuenta + index: 'Volver a sitios' + are_you_sure: "¿Estás segure?" + cancel_my_account: Eliminar mi cuenta currently_waiting_confirmation_for_email: 'Actualmente esperando la confirmacion de: %{email} ' - leave_blank_if_you_don_t_want_to_change_it: dejar en blanco si no desea cambiarlo - title: Editar %{resource} - unhappy: "¿Disconforme?" - update: Actualizar - we_need_your_current_password_to_confirm_your_changes: necesitamos tu contraseña actual para confirmar los cambios + leave_blank_if_you_don_t_want_to_change_it: Deja este campo vacío si no deseas cambiarla. + title: Editar mi cuenta + update: Actualizar mi perfil + we_need_your_current_password_to_confirm_your_changes: Necesitamos tu contraseña actual para confirmar los cambios. new: sign_up: Registrarme por primera vez email: O simplemente continuar con tu dirección de correo y contraseña @@ -110,7 +110,6 @@ es: signed_up_but_unconfirmed: Para recibir actualizaciones, se ha enviado un mensaje con un enlace de confirmación a tu correo electrónico. Abre el enlace para activar tu cuenta. update_needs_confirmation: Has actualizado tu cuenta correctamente, pero es necesario confirmar tu nuevo correo electrónico. Por favor, comprueba tu correo y sigue el enlace de confirmación para finalizar la comprobación del nuevo correo electrónico. updated: Tu cuenta se ha actualizado. - updated_but_not_signed_in: sessions: already_signed_out: Sesión finalizada. new: @@ -129,8 +128,8 @@ es: i_dont_have_account: ¿Nunca te registraste en LUNAR? i_have_account: ¿Ya tenés cuenta? minimum_password_length: - one: "(%{count} caracter como mínimo)" - other: "(%{count} caracteres como mínimo)" + one: "%{count} caracter como mínimo." + other: "%{count} caracteres como mínimo." unlocks: new: resend_unlock_instructions: Reenviar instrucciones para desbloquear diff --git a/config/locales/en.yml b/config/locales/en.yml index 6ad6eb9a..8db358cf 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,6 @@ en: + es: Castillian Spanish + en: English seconds: '%{seconds} seconds' deploy_mailer: deployed: @@ -29,6 +31,8 @@ en: email: 'E-mail address' password: 'Password' password_confirmation: 'Password confirmation' + current_password: 'Current password' + lang: 'Main language' site: name: 'Name' errors: @@ -66,6 +70,8 @@ en: posts: reorder: 'The articles have been reordered!' help: + usuarie: + edit: Edit my profile category: 'Category' logout: 'Close the session' breadcrumbs: "What you see up here are the bread crumbs for this site. When you enter a new section, you will see the previous ones and also have a path for where you're standing." diff --git a/config/locales/es.yml b/config/locales/es.yml index 0a65b29f..56210ab6 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,6 @@ es: + es: Castellano + en: Inglés seconds: '%{seconds} segundos' deploy_mailer: deployed: @@ -29,6 +31,8 @@ es: email: 'Correo electrónico' password: 'Contraseña' password_confirmation: 'Confirmación de contraseña' + current_password: 'Contraseña actual' + lang: Idioma principal site: name: 'Nombre' title: 'Título' @@ -68,6 +72,8 @@ es: posts: reorder: "¡Los artículos fueron reordenados!" help: + usuarie: + edit: Editar mi perfil category: 'Categoría' logout: 'Cierra la sesión' breadcrumbs: 'Lo que ves arriba son las migas de pan de este sitio. diff --git a/doc/crear_sitios.md b/doc/crear_sitios.md index 9155b002..ed06e77f 100644 --- a/doc/crear_sitios.md +++ b/doc/crear_sitios.md @@ -216,6 +216,7 @@ tengamos tiempo de hacerlo realmente. # TODO +* aplicar la licencia al sitio! * ver las estadisticas de compilación en lugar del log (el log también) agrupar los build stats para poder ver todos los pasos de una @@ -224,4 +225,3 @@ tengamos tiempo de hacerlo realmente. * link a visitar sitio * editor de opciones * forkear gemas -* que les usuaries elijan su propio idioma From cfb0d71dbea4f894e388948f94e0c9e17f0946ff Mon Sep 17 00:00:00 2001 From: f Date: Fri, 2 Aug 2019 18:00:54 -0300 Subject: [PATCH 44/44] incluir front matter en las licencias --- db/seeds/licencias.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/db/seeds/licencias.yml b/db/seeds/licencias.yml index 7e04067e..27c2931f 100644 --- a/db/seeds/licencias.yml +++ b/db/seeds/licencias.yml @@ -7,6 +7,12 @@ description_en: 'The Peer Production License is a license that allows use for any purpose under the same terms, except for commercial purposes, which are only allowed for collectives, cooperatives and other worker-owned "enterprises". We recommend this license if you are inclined towards non-profit terms, since it allows the development of more ethical economies while fending off capitalistic for-profit uses. If you want to know more about it, we invite you to read [The Telekommunist Manifesto](http://networkcultures.org/blog/publication/no-03-the-telekommunist-manifesto-dmytri-kleiner/)' description_es: 'La licencia de Producción de Pares permite el uso con cualquier propósito bajo la misma licencia, a excepción de los usos comerciales que solo están permitidos a colectivos, cooperativas y otras "empresas" en manos de sus trabajadorxs. Recomendamos esta licencia si te estabas inclinando hacia términos sin fines de lucro, ya que permite el desarrollo de economías más éticas al mismo tiempo que impide la explotación capitalista. Si te interesa saber más, te invitamos a leer [El manifiesto telecomunista](https://endefensadelsl.org/manifiesto_telecomunista.html).' deed_en: | + --- + title: License + permalink: license/ + layout: post + --- + # Peer Production License (human-readable version) This is a human-readable summary of the [full @@ -48,6 +54,12 @@ such as publicity, privacy, or moral rights may limit how you use the material. deed_es: | + --- + title: Licencia + permalink: licencia/ + layout: post + --- + # Licencia de producción de pares (versión legible por humanas) Esto es un resumen legible por humanas del [texto legal (la licencia @@ -119,6 +131,12 @@ indicar si hicieron cambios." url_es: 'https://creativecommons.org/licenses/by/4.0/deed.es' deed_en: | + --- + title: License + permalink: license/ + layout: post + --- + This is a human-readable summary of (and not a substitute for) the [license](https://creativecommons.org/licenses/by/4.0/legalcode). @@ -155,6 +173,12 @@ such as publicity, privacy, or moral rights may limit how you use the material. deed_es: | + --- + title: Licencia + permalink: licencia/ + layout: post + --- + Este es un resumen legible por humanes (y no un sustituto) de la [licencia](https://creativecommons.org/licenses/by/4.0/legalcode). @@ -208,6 +232,12 @@ están permitidos, las mejoras hechas con fines de lucro deben ser compartidas bajo la misma licencia." deed_en: | + --- + title: License + permalink: license/ + layout: post + --- + This is a human-readable summary of (and not a substitute for) the [license](https://creativecommons.org/licenses/by-sa/4.0/legalcode). @@ -249,6 +279,12 @@ such as publicity, privacy, or moral rights may limit how you use the material. deed_es: | + --- + title: Licencia + permalink: licencia/ + layout: post + --- + Este es un resumen legible por humanes (y no un sustituto) de la [licencia](https://creativecommons.org/licenses/by/4.0/legalcode).