diff --git a/Gemfile b/Gemfile index 51a4b183..c698f747 100644 --- a/Gemfile +++ b/Gemfile @@ -25,7 +25,7 @@ gem 'turbolinks', '~> 5' # Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder gem 'jbuilder', '~> 2.5' # Use ActiveModel has_secure_password -# gem 'bcrypt', '~> 3.1.7' +gem 'bcrypt', '~> 3.1.7' # Use Capistrano for deployment # gem 'capistrano-rails', group: :development diff --git a/Gemfile.lock b/Gemfile.lock index b2bf8f7f..0c7f3e3d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -45,6 +45,7 @@ GEM arel (8.0.0) autoprefixer-rails (9.1.4) execjs + bcrypt (3.1.12) bcrypt_pbkdf (1.0.0) bindex (0.5.0) bootstrap (4.0.0) @@ -302,6 +303,7 @@ PLATFORMS ruby DEPENDENCIES + bcrypt (~> 3.1.7) bcrypt_pbkdf bootstrap (~> 4.0.0) capistrano diff --git a/app/controllers/invitadxs_controller.rb b/app/controllers/invitadxs_controller.rb new file mode 100644 index 00000000..3906c70d --- /dev/null +++ b/app/controllers/invitadxs_controller.rb @@ -0,0 +1,53 @@ +class InvitadxsController < ApplicationController + include Pundit + + def index + authenticate! + + @site = find_site + @invitadxs = @site.invitadxs + end + + def new + @site = Site.find(params[:site_id]) + @has_cover = true + @invitadx = Invitadx.new + end + + def create + @site = Site.find(params[:invitadx][:site]) + @invitadx = Invitadx.new(invitadx_params) + @invitadx.confirmation_token = SecureRandom.hex(16) + @invitadx.sites << @site + + if @invitadx.save + InvitadxMailer.with(site: @site, invitadx: @invitadx).confirmation_required.deliver + redirect_to invitadx_path(@invitadx) + else + render 'new' + end + end + + def show + @has_cover = true + @invitadx = Invitadx.find(params[:id]) + end + + def confirmation + @invitadx = Invitadx.find(params[:invitadx_id]) + @site = @invitadx.sites.find do |site| + site.id == params[:site_id] + end + + if @invitadx.confirmation_token = params[:confirmation_token] + @invitadx.update_attribute :confirmed, true + redirect_to site_ + end + end + + private + + def invitadx_params + params.require(:invitadx).permit(:email, :password, :password_confirmation) + end +end diff --git a/app/mailers/invitadx_mailer.rb b/app/mailers/invitadx_mailer.rb new file mode 100644 index 00000000..45adb104 --- /dev/null +++ b/app/mailers/invitadx_mailer.rb @@ -0,0 +1,7 @@ +class InvitadxMailer < ApplicationMailer + def confirmation_required + @invitadx = params[:invitadx] + @site = params[:site] + mail to: @invitadx.email, subject: t('.subject') + end +end diff --git a/app/models/invitadx.rb b/app/models/invitadx.rb new file mode 100644 index 00000000..714df737 --- /dev/null +++ b/app/models/invitadx.rb @@ -0,0 +1,76 @@ +class Invitadx < ApplicationRecord + has_secure_password + validates_uniqueness_of :email + + after_create :create_invitadx_directory! + after_save :add_sites! + + # Para facilitar la serialización de Warden + def username + email + end + + def path + File.join(Rails.root, '_invitadxs', email) + end + + # TODO convertir el Pathname en un helper + def site_dirs + return [] unless Dir.exists? path + Pathname.new(path).children.map(&:expand_path).map(&:to_s) + end + + def sites + @sites ||= site_dirs.map do |site| + Site.find(File.basename(site)) + end.compact + end + + private + + # Crea el directorio en _sites + def create_invitadx_directory! + FileUtils.mkdir_p(path) + end + + # Agrega el sitio a las invitadxs y viceversa + def add_sites! + sites.each do |site| + dir = File.join(path, site.name) + file = site.invitadxs_file + + unless File.exists? dir + FileUtils.ln_s File.join('..', '..', '_sites', site.name), dir + end + + if File.exists? file + invitadxs = File.read(file).split("\n") + invitadxs_orig = invitadxs.dup + invitadxs << email unless invitadxs.include? email + else + invitadxs_orig = [] + invitadxs = [email] + end + + # Solo escribir los cambios si hubo cambios en la lista + return if invitadxs_orig == invitadxs + + 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 la fecha de creación + f.write(invitadxs.join("\n")) + + # Eliminar el resto + f.flush + f.truncate(f.pos) + end + + end + end +end diff --git a/app/models/site.rb b/app/models/site.rb index 70abc621..ce0a7c43 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -11,6 +11,15 @@ class Site @collections = {} end + # Este sitio acepta invitadxs? + def invitadxs? + jekyll.config.fetch('invitadxs', false) + end + + def cover + "/covers/#{name}.png" + end + # Determina si el sitio está en varios idiomas def i18n? !translations.empty? @@ -173,6 +182,16 @@ class Site end end + def invitadxs_file + File.join(path, '.invitadxs') + end + + def invitadxs + @invitadxs ||= File.read(invitadxs_file).split("\n").map do |i| + Invitadx.find_by_email(i) + end + end + def failed_file File.join(path, '.failed') end diff --git a/app/views/invitadx_mailer/confirmation_required.html.haml b/app/views/invitadx_mailer/confirmation_required.html.haml new file mode 100644 index 00000000..78764d85 --- /dev/null +++ b/app/views/invitadx_mailer/confirmation_required.html.haml @@ -0,0 +1,5 @@ +%h1= t('.hi') + +%p= t('.body') + +%code= site_invitadx_confirmation_url(@site, @invitadx, confirmation_token: @invitadx.confirmation_token) diff --git a/app/views/invitadx_mailer/confirmation_required.txt.haml b/app/views/invitadx_mailer/confirmation_required.txt.haml new file mode 100644 index 00000000..c082b77a --- /dev/null +++ b/app/views/invitadx_mailer/confirmation_required.txt.haml @@ -0,0 +1,5 @@ += t('.hi') + += t('.body') + += site_invitadx_confirmation_url(@site, @invitadx, confirmation_token: @invitadx.confirmation_token) diff --git a/app/views/invitadxs/index.haml b/app/views/invitadxs/index.haml new file mode 100644 index 00000000..3fc782cc --- /dev/null +++ b/app/views/invitadxs/index.haml @@ -0,0 +1,13 @@ +.row + .col + = render 'layouts/breadcrumb', crumbs: [ t('sites.index'), t('.title') ] +.row + .col + %h1= t('.title') + + %table.table.table-striped.table-condensed + %tbody + - @invitadxs.each do |invitadx| + %tr + %td= invitadx.email + %td= invitadx.created_at diff --git a/app/views/invitadxs/new.haml b/app/views/invitadxs/new.haml new file mode 100644 index 00000000..4399f059 --- /dev/null +++ b/app/views/invitadxs/new.haml @@ -0,0 +1,31 @@ +.row.align-items-center.justify-content-center.full-height + .col-md-6.align-self-center + - if @invitadx.errors.full_messages.empty? + .alert.alert-dismissible.alert-info.fade.show{role: 'alert'} + = @site.config.dig('welcome', 'message') || t('.welcome') + %button.close{type: 'button', + data: { dismiss: 'alert' }, + 'aria-label': t('help.close') } + %span{'aria-hidden': true} × + - else + .alert.alert-dismissible.alert-info.fade.show{role: 'alert'} + %ul + - @invitadx.errors.full_messages.each do |message| + %li= message + %button.close{type: 'button', + data: { dismiss: 'alert' }, + 'aria-label': t('help.close') } + %span{'aria-hidden': true} × + + = form_for @invitadx do |f| + = f.hidden_field :site, value: @site.id + .form-group + = f.email_field :email, class: 'form-control', placeholder: t('.email') + .form-group + = f.password_field :password, class: 'form-control', placeholder: t('.password') + .form-group + = f.password_field :password_confirmation, class: 'form-control', placeholder: t('.password_confirmation') + + .form-group + - button = @site.config.dig('welcome', 'button') + = f.submit t('.submit'), class: 'btn btn-lg btn-primary btn-block', style: (button) ? "background-color: #{button};" : '' diff --git a/app/views/invitadxs/show.haml b/app/views/invitadxs/show.haml new file mode 100644 index 00000000..7a01aac8 --- /dev/null +++ b/app/views/invitadxs/show.haml @@ -0,0 +1,3 @@ +.row.align-items-center.justify-content-center.full-height + .col-md-6.align-self-center + = t('.confirmation_sent') diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 36982cf4..9d730b6a 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' - %body{class: @has_cover ? 'background-cover' : ''} + %body{class: @has_cover ? 'background-cover' : '', style: @has_cover ? "background-image: url(#{@site.try(:cover)})": ''} .container-fluid = yield %footer.footer diff --git a/app/views/layouts/mailer.html.haml b/app/views/layouts/mailer.html.haml new file mode 100644 index 00000000..5ef091a7 --- /dev/null +++ b/app/views/layouts/mailer.html.haml @@ -0,0 +1,3 @@ +%html + %body + = yield diff --git a/app/views/layouts/mailer.text.haml b/app/views/layouts/mailer.text.haml new file mode 100644 index 00000000..0a90f092 --- /dev/null +++ b/app/views/layouts/mailer.text.haml @@ -0,0 +1 @@ += yield diff --git a/config/initializers/inflections.rb b/config/initializers/inflections.rb index ac033bf9..d5cf5d86 100644 --- a/config/initializers/inflections.rb +++ b/config/initializers/inflections.rb @@ -1,16 +1,9 @@ -# Be sure to restart your server when you modify this file. +ActiveSupport::Inflector.inflections(:en) do |inflect| + inflect.plural 'invitadx', 'invitadxs' + inflect.singular 'invitadxs', 'invitadx' +end -# Add new inflection rules using the following format. Inflections -# are locale specific, and you may define rules for as many different -# locales as you wish. All of these examples are active by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.plural /^(ox)$/i, '\1en' -# inflect.singular /^(ox)en/i, '\1' -# inflect.irregular 'person', 'people' -# inflect.uncountable %w( fish sheep ) -# end - -# These inflection rules are supported but not enabled by default: -# ActiveSupport::Inflector.inflections(:en) do |inflect| -# inflect.acronym 'RESTful' -# end +ActiveSupport::Inflector.inflections(:es) do |inflect| + inflect.plural 'invitadx', 'invitadxs' + inflect.singular 'invitadxs', 'invitadx' +end diff --git a/config/initializers/warden.rb b/config/initializers/warden.rb index c750b089..aec9a8b0 100644 --- a/config/initializers/warden.rb +++ b/config/initializers/warden.rb @@ -1,7 +1,8 @@ require 'warden/imap' +require 'warden/email_and_password' Rails.configuration.middleware.use RailsWarden::Manager do |manager| - manager.default_strategies :imap + manager.default_strategies :email, :imap manager.failure_app = -> (env) { LoginController.action(:new).call(env) } end @@ -11,8 +12,9 @@ class Warden::SessionSerializer end def deserialize(keys) - Usuaria.find(keys.first) + Invitadx.find_by_email(keys.first) || Usuaria.find(keys.first) end end Warden::Strategies.add(:imap, Warden::IMAP::Strategy) +Warden::Strategies.add(:email, Warden::EmailAndPassword::Strategy) diff --git a/config/locales/en.yml b/config/locales/en.yml index 9eb0d3f5..9bcfec0c 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,4 +1,13 @@ en: + activerecord: + errors: + models: + invitadx: + attributes: + email: + taken: 'This e-mail address is already taken, please choose another' + password_confirmation: + confirmation: The passwords don't match errors: argument_error: 'Argument `%{argument}` must be an instance of %{class}' unknown_locale: 'Unknown %{locale} locale' @@ -6,6 +15,14 @@ en: reorder: "We're sorry, we couldn't reorder the articles" disordered: "The posts are disordered, this will prevent you from reordering them!" disordered_button: 'Reorder!' + invitadxs: + index: + title: Guests + new: + email: E-Mail + password: Password + password_confirmation: Repeat password + submit: Register info: posts: reorder: "The articles have been reordered!" diff --git a/config/locales/es.yml b/config/locales/es.yml index a31f0084..aba594a2 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,13 @@ es: + activerecord: + errors: + models: + invitadx: + attributes: + email: + taken: 'Esa cuenta ya está tomada, por favor elige otra.' + password_confirmation: + confirmation: Las contraseñas no coinciden errors: argument_error: 'El argumento `%{argument}` debe ser una instancia de %{class}' unknown_locale: 'El idioma %{locale} es desconocido' @@ -6,6 +15,14 @@ es: reorder: "Lo sentimos, no pudimos reordenar los artículos." disordered: 'Los artículos no tienen número de orden, esto impedirá que los puedas reordenar' disordered_button: '¡Reordenar!' + invitadxs: + index: + title: Invitadxs + new: + email: Correo electrónico + password: Contraseña + password_confirmation: Repite la contraseña + submit: Registrarme info: posts: reorder: "¡Los artículos fueron reordenados!" diff --git a/config/routes.rb b/config/routes.rb index 3792d851..af6d6e64 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -9,9 +9,15 @@ Rails.application.routes.draw do get '/sites/:site_id/public/:type/:basename', to: 'sites#send_public_file' + resources :invitadxs, only: [:create, :show] do + end + resources :sites, only: [:index, :show], constraints: { site_id: /[^\/]+/, id: /[^\/]+/ } do resources :posts resources :templates + resources :invitadxs, only: [:index, :new] do + get :confirmation, to: 'invitadxs#confirmation' + end get 'i18n', to: 'i18n#index' get 'i18n/edit', to: 'i18n#edit' diff --git a/db/migrate/20180925183241_create_invitadxs.rb b/db/migrate/20180925183241_create_invitadxs.rb new file mode 100644 index 00000000..7fafd54b --- /dev/null +++ b/db/migrate/20180925183241_create_invitadxs.rb @@ -0,0 +1,11 @@ +class CreateInvitadxs < ActiveRecord::Migration[5.1] + def change + create_table :invitadxs do |t| + t.timestamps + t.string :password_digest + t.string :email, unique: true, index: true + t.string :confirmation_token + t.boolean :confirmed + end + end +end diff --git a/doc/invitadxs.md b/doc/invitadxs.md new file mode 100644 index 00000000..11032a53 --- /dev/null +++ b/doc/invitadxs.md @@ -0,0 +1,53 @@ +# Gestión de usuarixs invitadxs + +Lxs usuarixs invitadxs solo tienen cuenta en Sutty, no comparten cuenta +con el resto del sistema (Sutty está integrada a otras cuentas vía +IMAP). + +Pueden cambiar su contraseña y recuperarla. + +Cuando se loguean, solo pueden ver sus artículos y editarlos. También +pueden crear nuevos. + +Cuando crean un artículo o cuando lo editan, los artículos pasan a +estado borrador. Solo las usuarias de Sutty pueden revisar el artículo +y publicarlo. + +Cada invitadx está asociadx a uno o más sitios. + + +## Idea + +Que cada sitio gestione sus propias cuentas usando un archivo en formato +`/etc/passwd` (ver `man 5 passwd`). + +Con esto habría portabilidad de cuentas junto con los sitios, pero sería +un problema para poder gestionar varios sitios con cuentas compartidas. + +## Temporal + +Usar `devise` con una base de datos SQLite. La idea es descartarla más +adelante y tener un `passwd` + `shadow` de Sutty. Sino vamos a empezar +a poner cosas en una base de datos y no es la idea... + +**No se puede usar devise porque toma el control de toda la gestión de +usuarias.** + +## Implementación + +Para poder separar la autenticación de usuarias de invitadxs, cada +controlador tiene su propio `namespace`, de forma que no se crucen +funcionalidades. + +Lxs invitadxs se almacenan con email y contraseña en una base de datos +SQLite3. + +La pertenencia a un sitio se almacena en el archivo `.invitadxs` de cada +sitio (como `.usuarias`). + +Además, se vincula el sitio al directorio de invitadx para poder tener +acceso a varios sitios. + +El directorio de lx invitadx es `_invitadxs/direccion@mail`. + +El registro de cuentas se hace en base al sitio. diff --git a/lib/warden/email_and_password.rb b/lib/warden/email_and_password.rb new file mode 100644 index 00000000..8f05f00a --- /dev/null +++ b/lib/warden/email_and_password.rb @@ -0,0 +1,32 @@ +require 'email_address' +module Warden + module EmailAndPassword + + class Strategy < Warden::Strategies::Base + def valid? + return false unless params.include? 'username' + return false unless params.include? 'password' + username = params['username'] + @email = EmailAddress.new(username, host_validation: :a) + + Rails.logger.error [username, @email.error].join(': ') unless @email.valid? + + @email.valid? + end + + # Autentica a una posible invitadx, no fallamos para que haya + # fallback con IMAP + def authenticate! + u = Invitadx.find_by_email(params['username']) + + if u.try(:authenticate, params['password']) + if u.confirmed? + success! u + else + fail! 'unconfirmed' + end + end + end + end + end +end diff --git a/public/covers/.keep b/public/covers/.keep new file mode 100644 index 00000000..e69de29b