diff --git a/.env.example b/.env.example index cf01e61..eea8055 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ -SECRET_KEY_BASE= +RAILS_ENV=production 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/.gitignore b/.gitignore index 483c916..b89536c 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,10 @@ /_sites/* /_deploy/* -/_usuarias/* -/_invitadxs/* +/data/* + .env + +# Ignore master key for decrypting credentials and more. +/config/master.key +/config/credentials.yml.enc diff --git a/.rubocop.yml b/.rubocop.yml index a42adca..fad6db4 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -36,18 +36,17 @@ Metrics/MethodLength: Metrics/BlockLength: Exclude: + - 'config/environments/development.rb' - 'config/environments/production.rb' - 'config/initializers/devise.rb' - 'db/schema.rb' + - 'config/routes.rb' Metrics/ClassLength: Exclude: - 'app/models/site.rb' - 'app/controllers/posts_controller.rb' - -Performance/TimesMap: - Exclude: - - 'app/models/site.rb' + - 'app/controllers/sites_controller.rb' Lint/HandleExceptions: Exclude: diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..0cadbc1 --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.5.5 diff --git a/Capfile b/Capfile index 5abc2d8..1992cec 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/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4620f54 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,116 @@ +# 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 libgit2-dev rsync cmake + +# 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 . +# 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 --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 +# 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 xf ../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 +ENV RAILS_ENV production + +# 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 +RUN apk add --no-cache libgit2 +# Instalar foreman para poder correr los servicios +RUN gem install --no-document --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/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 +COPY ./config/credentials.yml.enc ./config/credentials.yml.enc + +# Volver a root para cerrar la compilación +USER root + +# 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" +VOLUME "/srv/http/_sites" +VOLUME "/srv/http/_public" + +# El puerto de puma +EXPOSE 3000 diff --git a/Gemfile b/Gemfile index f9edac6..ffd3d77 100644 --- a/Gemfile +++ b/Gemfile @@ -46,14 +46,22 @@ gem 'email_address' gem 'exception_notification' gem 'font-awesome-rails' gem 'friendly_id' -gem 'haml-rails' +gem 'hamlit-rails' +gem 'hiredis' gem 'jekyll' gem 'jquery-rails' gem 'mini_magick' +gem 'mobility' gem 'pundit' gem 'rails-i18n' gem 'rails_warden' -gem 'whenever', require: false +gem 'redis', require: %w[redis redis/connection/hiredis] +gem 'redis-rails' +gem 'rubyzip' +gem 'rugged' +gem 'sidekiq' +gem 'terminal-table' +gem 'validates_hostname' group :development, :test do gem 'pry' @@ -76,9 +84,15 @@ 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' + gem 'rubocop-rails' 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 5be9949..9d61132 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -91,13 +91,14 @@ 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) 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) bcrypt (~> 3.0) orm_adapter (~> 0.1) @@ -121,12 +122,16 @@ 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) 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) @@ -139,21 +144,28 @@ 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) + hiredis (0.6.3) 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) @@ -201,10 +213,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) @@ -214,8 +229,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) @@ -223,12 +238,13 @@ 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) 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) @@ -267,25 +283,45 @@ 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) 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) rubyzip (1.2.2) + rugged (0.28.2) safe_yaml (1.0.5) sass (3.7.4) sass-listen (~> 4.0.0) @@ -310,7 +346,11 @@ GEM selenium-webdriver (3.141.0) childprocess (~> 0.5) rubyzip (~> 1.2, >= 1.2.2) - sexp_processor (4.12.0) + 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) @@ -329,7 +369,10 @@ GEM sshkit (1.18.2) net-scp (>= 1.1.2) 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) @@ -343,7 +386,10 @@ 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) warden (1.2.8) rack (>= 2.0.6) web-console (3.7.0) @@ -354,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) @@ -376,6 +420,7 @@ DEPENDENCIES carrierwave-bombshelter carrierwave-i18n commonmarker + database_cleaner devise devise-i18n devise_invitable @@ -383,15 +428,19 @@ DEPENDENCIES ed25519 email_address exception_notification + factory_bot_rails font-awesome-rails friendly_id - haml-rails + haml-lint + hamlit-rails + hiredis jbuilder (~> 2.5) jekyll jquery-rails letter_opener listen (>= 3.0.5, < 3.2) mini_magick + mobility pry puma (~> 3.7) pundit @@ -399,16 +448,22 @@ DEPENDENCIES rails-i18n rails_warden rbnacl (< 5.0) - rubocop + 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 web-console (>= 3.3.0) - whenever BUNDLED WITH - 1.17.3 + 2.0.2 diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..367b027 --- /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/app/assets/images/background.jpg b/app/assets/images/background.jpg deleted file mode 100644 index 0e6c2d0..0000000 Binary files a/app/assets/images/background.jpg and /dev/null differ diff --git a/app/assets/images/icon_external_link.png b/app/assets/images/icon_external_link.png new file mode 100644 index 0000000..16f9b92 Binary files /dev/null and b/app/assets/images/icon_external_link.png differ diff --git a/app/assets/images/logo.png b/app/assets/images/logo.png new file mode 100644 index 0000000..234d6d2 Binary files /dev/null and b/app/assets/images/logo.png differ diff --git a/app/assets/javascripts/external_links.js b/app/assets/javascripts/external_links.js new file mode 100644 index 0000000..17e9cf2 --- /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 c449310..e4119a9 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -5,6 +5,37 @@ @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; +} + +a { + &[target=_blank] { + /* TODO: Convertir a base64 para no hacer peticiones extra */ + &:after { + content: image-url('icon_external_link.png'); + } + } +} + $footer-height: 60px; /* Colores */ @@ -55,7 +86,6 @@ ol.breadcrumb { .background-cover { - background: image-url("background.jpg") no-repeat center center fixed; -webkit-background-size: cover; -moz-background-size: cover; -o-background-size: cover; diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index d27b719..b7e9338 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/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 4205ecf..b2ba6f8 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/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index 6ca91f1..527762a 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -14,12 +14,54 @@ 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 + + @site.deploys.build type: 'DeployLocal' + @site.deploys.build type: 'DeployZip' + end + + def create + @site = Site.new(site_params) + @site.roles << Rol.new(site: @site, + usuarie: current_usuarie, + temporal: false, + rol: 'usuarie') + + # 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' + end + end + + def edit + @site = find_site + authorize @site + end + + def update + @site = find_site + authorize @site + + # 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' + end + end + # Envía un archivo del directorio público de Jekyll def send_public_file authorize Site @@ -42,23 +84,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 @@ -79,4 +113,32 @@ 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 + params.require(:site) + .permit(:name, :design_id, :licencia_id, :description, :title, + deploys_attributes: %i[type id _destroy]) + end end diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb new file mode 100644 index 0000000..07baaf1 --- /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 f8ca552..8be0d41 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) @@ -21,7 +22,31 @@ 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 + + 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/lib/core_extensions/string/strip_tags.rb b/app/lib/core_extensions/string/strip_tags.rb new file mode 100644 index 0000000..76e35f8 --- /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/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index d84cb6e..94ffc99 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 0000000..3383104 --- /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/models/build_stat.rb b/app/models/build_stat.rb new file mode 100644 index 0000000..5a6a936 --- /dev/null +++ b/app/models/build_stat.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# 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 new file mode 100644 index 0000000..4d0ce45 --- /dev/null +++ b/app/models/deploy.rb @@ -0,0 +1,65 @@ +# 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 + has_many :build_stats + + def deploy + raise NotImplementedError + end + + def limit + raise NotImplementedError + end + + 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.popen2e(env, cmd, unsetenv_others: true) do |_, o, t| + r = t.value + stat.log = o.read + end + end + time_stop + + 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 new file mode 100644 index 0000000..8622973 --- /dev/null +++ b/app/models/deploy_local.rb @@ -0,0 +1,88 @@ +# 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: JSON + + 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 + + # 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' } + 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}.#{Site.domain}" + 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 0000000..b514152 --- /dev/null +++ b/app/models/deploy_zip.rb @@ -0,0 +1,64 @@ +# 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: JSON + + 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. + # + # 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| + File.directory?(f) ? z.mkdir(f) : z.add(f, f) + 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 + + # Copiamos de DeployLocal para no cargar todos los métodos de + # compilación... + def fqdn! + self.fqdn ||= "#{site.name}.#{Site.domain}" + 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/design.rb b/app/models/design.rb new file mode 100644 index 0000000..aeacb79 --- /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/licencia.rb b/app/models/licencia.rb new file mode 100644 index 0000000..c0eb1c8 --- /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 bb85d2a..d566cd3 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -5,19 +5,64 @@ class Site < ApplicationRecord include FriendlyId + validates :name, uniqueness: true, hostname: true + 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] + belongs_to :design + belongs_to :licencia + + has_many :deploys + has_many :build_stats, through: :deploys has_many :roles has_many :usuaries, -> { where('roles.rol = ?', 'usuarie') }, through: :roles 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! + # Elimina el directorio al destruir un sitio + before_destroy :remove_directories! + # Carga el sitio Jekyll una vez que se inicializa el modelo o después + # de crearlo after_initialize :load_jekyll! + after_create :load_jekyll! + # Cambiar el nombre del directorio + before_update :update_name! + # Guardar la configuración si hubo cambios + after_save :sync_attributes_with_config! attr_accessor :jekyll, :collections + 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 + 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 @@ -30,12 +75,16 @@ 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? def invitadxs? - jekyll.config.fetch('invitadxs', false) + config.fetch('invitadxs', false) end def cover @@ -49,12 +98,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 @@ -111,14 +160,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 @@ -182,65 +227,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') @@ -287,31 +280,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 :/ @@ -339,12 +315,58 @@ 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 + # 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.present? && File.directory?(path) + Dir.chdir(path) do @jekyll ||= Site.load_jekyll(Dir.pwd) end end + + # Elimina el directorio del sitio + def remove_directories! + FileUtils.rm_rf path + end + + def update_name! + return unless name_changed? + + 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 + # + # 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/models/site/config.rb b/app/models/site/config.rb new file mode 100644 index 0000000..8ed79d9 --- /dev/null +++ b/app/models/site/config.rb @@ -0,0 +1,62 @@ +# 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 + 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 + def write(usuarie = nil) + return if persisted? + + 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 + + # 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 + # 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 new file mode 100644 index 0000000..f59ad21 --- /dev/null +++ b/app/models/site/repository.rb @@ -0,0 +1,106 @@ +# 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 + # 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] + 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 + + # 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 0000000..f9350f1 --- /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/app/models/site_stat.rb b/app/models/site_stat.rb new file mode 100644 index 0000000..73503ac --- /dev/null +++ b/app/models/site_stat.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +SiteStat = Struct.new(:site) diff --git a/app/models/usuarie.rb b/app/models/usuarie.rb index a1a2be7..c2704a1 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 1042f52..8e4aceb 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? @@ -33,11 +55,33 @@ class SitePolicy build? end - def build_log? - build? - end - def reorder_posts? build? end + + def pull? + build? + end + + def fetch? + pull? + end + + def merge? + pull? + 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/policies/site_stat_policy.rb b/app/policies/site_stat_policy.rb new file mode 100644 index 0000000..a797034 --- /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/deploy_mailer/deployed.html.haml b/app/views/deploy_mailer/deployed.html.haml new file mode 100644 index 0000000..66cba36 --- /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 0000000..619d83e --- /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/deploys/_deploy_local.haml b/app/views/deploys/_deploy_local.haml new file mode 100644 index 0000000..8c5f34e --- /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 0000000..11996b0 --- /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/devise/mailer/invitation_instructions.haml b/app/views/devise/mailer/invitation_instructions.haml index 7455123..bd0f144 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) diff --git a/app/views/devise/registrations/edit.haml b/app/views/devise/registrations/edit.haml index d81fff9..31fcb34 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 c879376..322f416 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') - - if help = @site.try(:config).try(:dig, 'help') + %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/app/views/layouts/_time.haml b/app/views/layouts/_time.haml new file mode 100644 index 0000000..4fa3151 --- /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/layouts/application.html.haml b/app/views/layouts/application.html.haml index e5f5fc5..8c96f8c 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,14 +1,26 @@ !!! %html %head - %meta{content: "text/html; charset=UTF-8", 'http-equiv': "Content-Type"}/ + %meta{ content: 'text/html; charset=UTF-8', + 'http-equiv': 'Content-Type' }/ %title Sutty + = 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') - %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 : ''} - #sutty.container-fluid + = stylesheet_link_tag 'application', media: 'all', + 'data-turbolinks-track': 'reload' + = javascript_include_tag 'application', + 'data-turbolinks-track': 'reload' + + - 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)})" + -# haml-lint:disable InlineStyles + %body{ class: @has_cover ? 'background-cover' : '', + style: @has_cover ? style : '' } + + .container-fluid#sutty = yield + -# haml-lint:enable InlineStyles diff --git a/app/views/layouts/mailer.haml b/app/views/layouts/mailer.haml deleted file mode 100644 index cbf6b8e..0000000 --- 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 5ef091a..9aad3a0 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 0a90f09..36d87bd 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/views/posts/show.haml b/app/views/posts/show.haml index 32f3951..fccf15e 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)} - :markdown - #{@post.content} + .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 - :markdown - #{data.content} + = 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 diff --git a/app/views/sites/_form.haml b/app/views/sites/_form.haml new file mode 100644 index 0000000..e9a008a --- /dev/null +++ b/app/views/sites/_form.haml @@ -0,0 +1,97 @@ += form_for site, html: { class: form_class(site) } do |f| + .form-group + %h2= f.label :name + %p.lead= t('.help.name') + -# + 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(', ') + %hr/ + + .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' + %hr/ + + .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 + %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') + + = 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/app/views/sites/edit.haml b/app/views/sites/edit.haml new file mode 100644 index 0000000..a461bb5 --- /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', site: @site, submit: t('.submit') diff --git a/app/views/sites/fetch.haml b/app/views/sites/fetch.haml new file mode 100644 index 0000000..ac6c66b --- /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 d953b8c..464a616 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -1,10 +1,13 @@ - .row .col - = render 'layouts/breadcrumb', crumbs: [ t('sites.index') ] + = 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') @@ -16,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), @@ -64,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'), @@ -72,12 +77,9 @@ = 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'), + text: t('.pull'), + type: 'info', + link: site_pull_path(site) diff --git a/app/views/sites/new.haml b/app/views/sites/new.haml new file mode 100644 index 0000000..b5760f3 --- /dev/null +++ b/app/views/sites/new.haml @@ -0,0 +1,9 @@ +.row + .col + = render 'layouts/breadcrumb', + crumbs: [link_to(t('sites.index'), sites_path), t('.title')] +.row + .col + %h1= t('.title') + + = render 'form', site: @site, submit: t('.submit') diff --git a/app/views/stats/index.haml b/app/views/stats/index.haml new file mode 100644 index 0000000..eb55c5d --- /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/app/workers/deploy_worker.rb b/app/workers/deploy_worker.rb new file mode 100644 index 0000000..15a3154 --- /dev/null +++ b/app/workers/deploy_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# Realiza el deploy de un sitio +class DeployWorker + include Sidekiq::Worker + + 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 + 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 + + 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/bin/haml-lint b/bin/haml-lint new file mode 100755 index 0000000..ae43753 --- /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/jekyll_build_all b/bin/jekyll_build_all deleted file mode 100755 index daba038..0000000 --- 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/bin/rails b/bin/rails index 3504c3f..0739660 100755 --- a/bin/rails +++ b/bin/rails @@ -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 APP_PATH = File.expand_path('../config/application', __dir__) require_relative '../config/boot' require 'rails/commands' diff --git a/bin/rake b/bin/rake index 1fe6cf0..1724048 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 3b7333e..94fd4d7 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/sidekiq b/bin/sidekiq new file mode 100755 index 0000000..5d97d88 --- /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 0000000..10eb2f4 --- /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/bin/update b/bin/update index 1d6aa6a..58bfaed 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 0000000..460dd56 --- /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 diff --git a/config/application.rb b/config/application.rb index 2cc92e8..579177e 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/config/database.yml b/config/database.yml index 0d02f24..f7a3238 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/config/environments/development.rb b/config/environments/development.rb index ad1c4d9..68c0679 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 12687e0..dcdd555 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. diff --git a/config/initializers/commonmarker.rb b/config/initializers/commonmarker.rb deleted file mode 100644 index 4d6743f..0000000 --- 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 diff --git a/config/initializers/core_extensions.rb b/config/initializers/core_extensions.rb new file mode 100644 index 0000000..2207fe8 --- /dev/null +++ b/config/initializers/core_extensions.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +String.include CoreExtensions::String::StripTags diff --git a/config/initializers/devise.rb b/config/initializers/devise.rb index f7e7acd..fe8d487 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. @@ -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/initializers/inflections.rb b/config/initializers/inflections.rb index 62a4533..35b309e 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/config/initializers/locale.rb b/config/initializers/locale.rb index 056f48a..e9753dc 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/initializers/mobility.rb b/config/initializers/mobility.rb new file mode 100644 index 0000000..8176045 --- /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/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..0ebf8b8 --- /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/devise.views.en.yml b/config/locales/devise.views.en.yml index aa0697c..d4c60db 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 0db4db7..affad80 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 da4ddc1..10c1126 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,7 +1,46 @@ en: + es: Castillian Spanish + en: English + seconds: '%{seconds} seconds' + 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' + current_password: 'Current password' + lang: 'Main language' + site: + name: 'Name' errors: models: + site: + attributes: + deploys: + deploy_local_presence: 'We need to be build the site!' invitadx: attributes: email: @@ -18,6 +57,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: @@ -25,23 +66,12 @@ 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!' 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." @@ -140,7 +170,39 @@ 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' + 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' actions: 'Actions' posts: 'View and edit posts' title: 'Sites' @@ -152,12 +214,55 @@ en: invitations: accept: 'Accept invitation' reject: 'No, thanks' + new: + title: 'Create site' + submit: 'Create site' + 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.' + 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.' + 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' + url: 'Demo' + licencia: 'Read the license' + 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: + 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: - 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 f06c26e..9f6d46c 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -1,4 +1,28 @@ es: + es: Castellano + en: Inglés + seconds: '%{seconds} segundos' + 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 @@ -7,8 +31,18 @@ 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' + description: 'Descripción' errors: models: + site: + attributes: + deploys: + deploy_local_presence: '¡Necesitamos poder generar el sitio!' invitadx: attributes: email: @@ -25,6 +59,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: @@ -36,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. @@ -143,7 +181,39 @@ 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. + + 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' actions: 'Acciones' posts: 'Ver y editar artículos' title: 'Sitios' @@ -155,6 +225,57 @@ es: invitations: accept: 'Aceptar la invitación' reject: 'No, gracias' + new: + title: 'Crear un sitio' + submit: 'Crear sitio' + edit: + title: 'Editar %{site}' + submit: 'Guardar cambios' + form: + help: + 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 + 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 + aun cuando algunos de los alojamientos no funcionen. + design: + title: 'Diseño' + actions: 'Información sobre este diseño' + url: 'Demostración' + license: 'Leer la licencia' + 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: + 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 35bdd76..a492ba9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,11 +11,13 @@ 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 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' @@ -39,7 +41,8 @@ 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' + + resources :stats, only: [:index] end end diff --git a/config/schedule.rb b/config/schedule.rb deleted file mode 100644 index c17dbfd..0000000 --- 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/config/secrets.yml b/config/secrets.yml deleted file mode 100644 index aead418..0000000 --- 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"] %> diff --git a/db/migrate/20190703200455_create_sitios.rb b/db/migrate/20190703200455_create_sitios.rb index c8e45d3..7f8ae8f 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/20190711183726_add_unique_to_site_name.rb b/db/migrate/20190711183726_add_unique_to_site_name.rb new file mode 100644 index 0000000..8fd5a9f --- /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/migrate/20190712165059_sqlite_boolean.rb b/db/migrate/20190712165059_sqlite_boolean.rb new file mode 100644 index 0000000..7f41afd --- /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/migrate/20190716195155_create_designs.rb b/db/migrate/20190716195155_create_designs.rb new file mode 100644 index 0000000..b040749 --- /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 0000000..598de77 --- /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 0000000..caa129f --- /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 0000000..5389e02 --- /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 0000000..44958ea --- /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 0000000..6432e34 --- /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/migrate/20190718185817_create_licencias.rb b/db/migrate/20190718185817_create_licencias.rb new file mode 100644 index 0000000..11232d5 --- /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 0000000..650b148 --- /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/migrate/20190723220002_create_deploys.rb b/db/migrate/20190723220002_create_deploys.rb new file mode 100644 index 0000000..85a2025 --- /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/migrate/20190725185427_create_build_stats.rb b/db/migrate/20190725185427_create_build_stats.rb new file mode 100644 index 0000000..1f41e42 --- /dev/null +++ b/db/migrate/20190725185427_create_build_stats.rb @@ -0,0 +1,15 @@ +# 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| + 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/migrate/20190726003756_add_status_to_site.rb b/db/migrate/20190726003756_add_status_to_site.rb new file mode 100644 index 0000000..3697542 --- /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/migrate/20190730211624_add_description_to_site.rb b/db/migrate/20190730211624_add_description_to_site.rb new file mode 100644 index 0000000..d907df4 --- /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 0000000..a48a241 --- /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 6e3c298..9455733 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,7 +12,74 @@ # # 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_730_211_756) 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 + 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 + t.string 'name' + t.text 'description' + t.string 'gem' + t.string 'url' + t.string 'license' + 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 + 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,7 +96,14 @@ 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.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 end create_table 'usuaries', force: :cascade do |t| @@ -56,6 +130,7 @@ ActiveRecord::Schema.define(version: 20_190_706_002_615) 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 ebd1889..2e91b9d 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,9 +1,17 @@ # 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 + +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/designs.yml b/db/seeds/designs.yml new file mode 100644 index 0000000..26f2e9a --- /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/db/seeds/licencias.yml b/db/seeds/licencias.yml new file mode 100644 index 0000000..27c2931 --- /dev/null +++ b/db/seeds/licencias.yml @@ -0,0 +1,326 @@ +--- +- name_en: 'Peer Production License' + name_es: 'Licencia de Producción de Pares' + 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/)' + 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 + 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: | + --- + 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 + 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: "/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 + 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: | + --- + 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). + + # 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: | + --- + 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). + + # 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: "/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/' + 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: | + --- + 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). + + # 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: | + --- + 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). + + # 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/doc/crear_sitios.md b/doc/crear_sitios.md new file mode 100644 index 0000000..ed06e77 --- /dev/null +++ b/doc/crear_sitios.md @@ -0,0 +1,227 @@ +# 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! + +## Diseño + +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). + +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 +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. + +## 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. + + +# 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 + compilación juntos +* comitear en git los articulos (igual no es de esta rama...) +* link a visitar sitio +* editor de opciones +* forkear gemas diff --git a/monit.conf b/monit.conf index 3d68634..4d34d69 100644 --- a/monit.conf +++ b/monit.conf @@ -1,7 +1,7 @@ -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'" +check program sync_assets + with path /usr/local/bin/sync_assets + if status = 0 then unmonitor diff --git a/public/images/by.png b/public/images/by.png new file mode 100644 index 0000000..5ddc740 Binary files /dev/null and b/public/images/by.png differ diff --git a/public/images/ppl.png b/public/images/ppl.png new file mode 100644 index 0000000..d2e949c Binary files /dev/null and b/public/images/ppl.png differ diff --git a/public/images/sa.png b/public/images/sa.png new file mode 100644 index 0000000..bb23f8c Binary files /dev/null and b/public/images/sa.png differ diff --git a/sync_assets.sh b/sync_assets.sh new file mode 100644 index 0000000..167d620 --- /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/ diff --git a/test/controllers/sites_controller_test.rb b/test/controllers/sites_controller_test.rb new file mode 100644 index 0000000..35646f3 --- /dev/null +++ b/test/controllers/sites_controller_test.rb @@ -0,0 +1,87 @@ +# 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(/ { + type: 'DeployLocal' + } + } + } + } + + 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 + + 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 diff --git a/test/factories/deploy_local.rb b/test/factories/deploy_local.rb new file mode 100644 index 0000000..e7441c3 --- /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 0000000..756be34 --- /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/factories/design.rb b/test/factories/design.rb new file mode 100644 index 0000000..b210b3a --- /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/licencia.rb b/test/factories/licencia.rb new file mode 100644 index 0000000..67434bb --- /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 { '/images/by.png' } + end +end diff --git a/test/factories/rol.rb b/test/factories/rol.rb new file mode 100644 index 0000000..103f12a --- /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 0000000..d9d8720 --- /dev/null +++ b/test/factories/site.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :site do + name { "test-#{SecureRandom.hex}" } + title { SecureRandom.hex } + description { SecureRandom.hex * 2 } + 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 diff --git a/test/factories/usuarie.rb b/test/factories/usuarie.rb new file mode 100644 index 0000000..18a4042 --- /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/models/deploy_local_test.rb b/test/models/deploy_local_test.rb new file mode 100644 index 0000000..b992d8c --- /dev/null +++ b/test/models/deploy_local_test.rb @@ -0,0 +1,17 @@ +# 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_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) + end +end diff --git a/test/models/deploy_zip_test.rb b/test/models/deploy_zip_test.rb new file mode 100644 index 0000000..7e8712d --- /dev/null +++ b/test/models/deploy_zip_test.rb @@ -0,0 +1,24 @@ +# 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 + 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 +end diff --git a/test/models/design_test.rb b/test/models/design_test.rb new file mode 100644 index 0000000..000be74 --- /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/licencia_test.rb b/test/models/licencia_test.rb new file mode 100644 index 0000000..949606a --- /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 diff --git a/test/models/post_test.rb b/test/models/post_test.rb deleted file mode 100644 index de1a7ae..0000000 --- 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/site/config_test.rb b/test/models/site/config_test.rb new file mode 100644 index 0000000..4ccaf37 --- /dev/null +++ b/test/models/site/config_test.rb @@ -0,0 +1,41 @@ +# 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(@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 + assert @site.config.fetch('noexiste', true) + assert @site.config.fetch('sass', false) + end +end diff --git a/test/models/site/repository_test.rb b/test/models/site/repository_test.rb new file mode 100644 index 0000000..f23925a --- /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 diff --git a/test/models/site_test.rb b/test/models/site_test.rb index ce6e475..438ef82 100644 --- a/test/models/site_test.rb +++ b/test/models/site_test.rb @@ -3,81 +3,86 @@ 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 + # Asegurarse que el sitio se destruye al terminar de usarlo + teardown do + @site&.destroy end - test 'El directorio es un sitio jekyll' do - assert Site.jekyll?(@path) + 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')) + assert site.destroy end - test 'Un directorio se puede cargar como un sitio Jekyll' do - jekyll = Site.load_jekyll @path + test 'el nombre tiene que ser único' do + @site = create :site + site2 = build :site, name: @site.name - assert_equal Jekyll::Site, jekyll.class + assert_not site2.valid? 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? + test 'el nombre del sitio puede contener subdominios' do + site = build :site, name: 'hola.chau' + site.validate + + assert_not site.errors.messages[:name].present? end - test 'No podemos poner órdenes arbitrarios' do - total = @site.posts.count - new_order = Hash[total.times.map { |i| [i.to_s, rand(total)] }] + test 'el nombre del sitio no puede terminar con punto' do + site = build :site, name: 'hola.chau.' + site.validate - assert_not @site.reorder_collection('posts', new_order) + assert site.errors.messages[:name].present? 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)] + test 'el nombre del sitio no puede contener wildcard' do + site = build :site, name: '*.chau' + site.validate - 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 + assert site.errors.messages[:name].present? 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)] + test 'el nombre del sitio solo tiene letras, numeros y guiones' do + site = build :site, name: 'A_Z!' + site.validate - 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 + assert site.errors.messages[:name].present? end - test 'Un sitio tiene traducciones' do - assert_equal %w[ar es en], @site.translations - assert @site.i18n? + test 'al destruir un sitio se eliminan los archivos' do + site = create :site + assert site.destroy + assert !File.directory?(site.path) end - test 'El idioma por defecto es el idioma actual de la plataforma' do - assert_equal 'es', @site.default_lang + test 'se puede leer un sitio' do + site = create :site, name: 'sutty.nl' + + assert site.valid? + assert !site.posts.empty? 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 + 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 + + 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 diff --git a/test/models/usuaria_test.rb b/test/models/usuaria_test.rb deleted file mode 100644 index 1bff620..0000000 --- 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 diff --git a/test/test_helper.rb b/test/test_helper.rb index 8a5709e..437b0da 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -4,8 +4,15 @@ 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 # 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 +22,4 @@ class ActiveSupport::TestCase end end end +# rubocop:enable Style/ClassAndModuleChildren diff --git a/test/workers/deploy_worker_test.rb b/test/workers/deploy_worker_test.rb new file mode 100644 index 0000000..53e44d9 --- /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