diff --git a/.env.example b/.env.example index 9d40394d..2b0507c7 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,7 @@ DEBUG_FORMS= COOKIE_DURATION=30 # Dominio de la tienda TIENDA=tienda.sutty.local +# Obtener esto con Site.find_by_name('panel').airbrake_api_key +PANEL_URL=https://panel.sutty.nl +AIRBRAKE_SITE_ID=1 +AIRBRAKE_API_KEY= diff --git a/Gemfile b/Gemfile index 64562fc7..00b556d0 100644 --- a/Gemfile +++ b/Gemfile @@ -86,6 +86,11 @@ gem 'rack-mini-profiler' gem 'stackprof' gem 'prometheus_exporter' +# debug +gem 'fast_jsonparser' +gem 'down' +gem 'sourcemap' + group :themes do gem 'adhesiones-jekyll-theme', require: false gem 'editorial-autogestiva-jekyll-theme', require: false diff --git a/Gemfile.lock b/Gemfile.lock index e6ed57f1..0ebf1842 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -161,6 +161,8 @@ GEM dotenv-rails (2.7.6) dotenv (= 2.7.6) railties (>= 3.2) + down (5.2.0) + addressable (~> 2.5) editorial-autogestiva-jekyll-theme (0.2.8) jekyll (~> 4.0) jekyll-data (~> 1.1) @@ -190,6 +192,7 @@ GEM factory_bot (~> 6.1.0) railties (>= 5.0.0) fast_blank (1.0.0) + fast_jsonparser (0.5.0) ffi (1.13.1) flamegraph (0.9.5) forwardable-extended (2.6.0) @@ -479,6 +482,7 @@ GEM jekyll-seo-tag (~> 2.1) simpleidn (0.1.1) unf (~> 0.1.4) + sourcemap (0.1.1) spring (2.1.1) spring-watcher-listen (2.0.1) listen (>= 2.7, < 4.0) @@ -578,11 +582,13 @@ DEPENDENCIES devise-i18n devise_invitable dotenv-rails + down editorial-autogestiva-jekyll-theme email_address exception_notification factory_bot_rails fast_blank + fast_jsonparser flamegraph friendly_id haml-lint @@ -624,6 +630,7 @@ DEPENDENCIES sassc-rails selenium-webdriver share-to-fediverse-jekyll-theme + sourcemap spring spring-watcher-listen (~> 2.0.0) sqlite3 diff --git a/app/controllers/api/v1/notices_controller.rb b/app/controllers/api/v1/notices_controller.rb new file mode 100644 index 00000000..c3e96593 --- /dev/null +++ b/app/controllers/api/v1/notices_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Api + module V1 + # Recibe notificaciones desde Airbrake + # + # TODO: Autenticación + class NoticesController < BaseController + skip_before_action :verify_authenticity_token + + # Generar un stacktrace en segundo plano y enviarlo por correo + # solo si la API key es verificable. Del otro lado siempre + # respondemos con lo mismo. + def create + if verify_api_key + BacktraceJob.perform_later site_id: params[:site_id], + errors: airbrake_params.map(&:permit!).map(&:to_h) + end + + render status: 201, json: { id: 1, url: root_url } + end + + private + + # XXX: Por alguna razón Airbrake envía los datos con Content-Type: + # text/plain. + def airbrake_params + @airbrake_params ||= params.merge!(JSON.parse(request.raw_post) || {}).require(:errors) + end + + def site + @site ||= Site.find(params[:site_id]) + end + + def verify_api_key + site.verifier.verify(airbrake_token, purpose: :airbrake) === Site::Api::AIRBRAKE_SECRET + rescue ActiveSupport::MessageVerifier::InvalidSignature + false + end + + def airbrake_token + @airbrake_token ||= params[:key] + end + end + end +end diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js index d3e001a8..b8a8c00a 100644 --- a/app/javascript/packs/application.js +++ b/app/javascript/packs/application.js @@ -15,6 +15,14 @@ // const images = require.context('../images', true) // const imagePath = (name) => images(name, true) +import { Notifier } from '@airbrake/browser' + +window.airbrake = new Notifier({ + projectId: process.env.AIRBRAKE_SITE_ID, + projectKey: process.env.AIRBRAKE_API_KEY, + host: process.env.PANEL_URL +}) + import 'core-js/stable' import 'regenerator-runtime/runtime' import 'controllers' diff --git a/app/jobs/backtrace_job.rb b/app/jobs/backtrace_job.rb new file mode 100644 index 00000000..559e0a1d --- /dev/null +++ b/app/jobs/backtrace_job.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +# Procesa los errores de JavaScript +class BacktraceJob < ApplicationJob + queue_as :low_priority + + attr_reader :errors + + def perform(site_id:, errors:) + @errors = errors + + errors.each do |error| + error['backtrace'].each do |backtrace| + offset = SourceMap::Offset.new(backtrace['line'], backtrace['column']) + mapping = sourcemap.bsearch(offset) + + next unless mapping + + backtrace['file'] = mapping.source + backtrace['line'] = mapping.original.line + backtrace['column'] = mapping.original.column + end + end + + begin + raise NoMethodError + rescue NoMethodError => e + ExceptionNotifier.notify_exception(e, data: { errors: errors }) + end + end + + private + + # Obtiene todos los archivos del backtrace + def files + @files ||= errors.map { |x| x['backtrace'] }.flatten.map { |x| x['file'].split('@').last }.uniq + end + + # Asume que todos los sourcemaps comparten la misma URL, lo + # correcto sería buscarlo en sourceMappingURL al final de cada + # archivo. + # + # Descarga los archivos y obtiene el backtrace original. + def sourcemap + @sourcemap ||= + begin + files.map { |x| "#{x}.map" }.map do |x| + data = FastJsonparser.parse(Rails.cache.fetch(x, expires_in: 12.hours) do + Down.open(x).read + end, symbolize_keys: false) + + SourceMap::Map.from_hash data + rescue Down::Error, FastJsonparser::Error + SourceMap::Map.new + end.reduce(&:+) + end + end +end diff --git a/app/models/site.rb b/app/models/site.rb index 5ff8d90b..ec299cd8 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -6,6 +6,7 @@ class Site < ApplicationRecord include FriendlyId include Site::Forms include Site::FindAndReplace + include Site::Api include Tienda # Cifrar la llave privada que cifra y decifra campos ocultos. Sutty diff --git a/app/models/site/api.rb b/app/models/site/api.rb new file mode 100644 index 00000000..0c374f66 --- /dev/null +++ b/app/models/site/api.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class Site + module Api + extend ActiveSupport::Concern + + AIRBRAKE_SECRET = 'an api key for airbrake' + + included do + encrypts :api_key + before_save :add_api_key_if_missing! + + # Genera mensajes secretos que podemos usar para la API de cada sitio. + def verifier + @verifier ||= ActiveSupport::MessageVerifier.new api_key + end + + def airbrake_api_key + @airbrake_api_key ||= verifier.generate(AIRBRAKE_SECRET, purpose: :airbrake) + end + + private + + # Asegurarse que el sitio tenga una llave para la API + def add_api_key_if_missing! + self.api_key ||= SecureRandom.hex(64) + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index cce10720..b65fc2ce 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -35,6 +35,8 @@ Rails.application.routes.draw do # Obtener archivos estáticos desde el directorio público get '/sites/:site_id/static_file/(*file)', to: 'sites#static_file', as: 'site_static_file', constraints: { site_id: %r{[^/]+} } + match '/api/v3/projects/:site_id/notices' => 'api/v1/notices#create', via: %i[post] + resources :sites, constraints: { site_id: %r{[^/]+}, id: %r{[^/]+} } do # Gestionar actualizaciones del sitio get 'pull', to: 'sites#fetch' diff --git a/package.json b/package.json index 5858243f..35958268 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "sutty", "private": true, "dependencies": { + "@airbrake/browser": "^1.4.1", "@rails/actiontext": "^6.0.0", "@rails/webpacker": "5.2.1", "commonmark": "^0.29.0", diff --git a/yarn.lock b/yarn.lock index 6b6dd790..dfab3fb9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,6 +2,18 @@ # yarn lockfile v1 +"@airbrake/browser@^1.4.1": + version "1.4.1" + resolved "https://registry.yarnpkg.com/@airbrake/browser/-/browser-1.4.1.tgz#c0832eed3096498e51ff947e1e35bda23021a7be" + integrity sha512-4KChn2eGDllwqYJ4c6MFqIiJ2RZgg49inYiIsdpAj5MLtOpZRlPF8MsOSLGHxkEDX6u5M9KRidUfktuQ/C9lKA== + dependencies: + "@types/promise-polyfill" "^6.0.3" + "@types/request" "2.48.5" + cross-fetch "^3.0.4" + error-stack-parser "^2.0.4" + promise-polyfill "^8.1.3" + tdigest "^0.1.1" + "@babel/code-frame@^7.0.0": version "7.5.5" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.5.5.tgz#bc0782f6d69f7b7d49531219699b988f669a8f9d" @@ -983,6 +995,11 @@ resolved "https://registry.yarnpkg.com/@stimulus/webpack-helpers/-/webpack-helpers-1.1.1.tgz#eff60cd4e58b921d1a2764dc5215f5141510f2c2" integrity sha512-XOkqSw53N9072FLHvpLM25PIwy+ndkSSbnTtjKuyzsv8K5yfkFB2rv68jU1pzqYa9FZLcvZWP4yazC0V38dx9A== +"@types/caseless@*": + version "0.12.2" + resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" + integrity sha512-6ckxMjBBD8URvjB6J3NcnuAn5Pkl7t3TizAg+xdlzzQGSPSmBcXf8KoIH0ua/i+tio+ZRUHEXp0HEmvaR4kt0w== + "@types/events@*": version "3.0.0" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.0.tgz#2862f3f58a9a7f7c3e78d79f130dd4d71c25c2a7" @@ -1017,11 +1034,31 @@ resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== +"@types/promise-polyfill@^6.0.3": + version "6.0.3" + resolved "https://registry.yarnpkg.com/@types/promise-polyfill/-/promise-polyfill-6.0.3.tgz#e2f38fcd244a9e0df2cc7528e0711abcbc707b5e" + integrity sha512-f/BFgF9a+cgsMseC7rpv9+9TAE3YNjhfYrtwCo/pIeCDDfQtE6PY0b5bao2eIIEpZCBUy8Y5ToXd4ObjPSJuFw== + "@types/q@^1.5.1": version "1.5.2" resolved "https://registry.yarnpkg.com/@types/q/-/q-1.5.2.tgz#690a1475b84f2a884fd07cd797c00f5f31356ea8" integrity sha512-ce5d3q03Ex0sy4R14722Rmt6MT07Ua+k4FwDfdcToYJcMKNtRVQvJ6JCAPdAmAnbRb6CsX6aYb9m96NGod9uTw== +"@types/request@2.48.5": + version "2.48.5" + resolved "https://registry.yarnpkg.com/@types/request/-/request-2.48.5.tgz#019b8536b402069f6d11bee1b2c03e7f232937a0" + integrity sha512-/LO7xRVnL3DxJ1WkPGDQrp4VTV1reX9RkC85mJ+Qzykj2Bdw+mG15aAfDahc76HtknjzE16SX/Yddn6MxVbmGQ== + dependencies: + "@types/caseless" "*" + "@types/node" "*" + "@types/tough-cookie" "*" + form-data "^2.5.0" + +"@types/tough-cookie@*": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/tough-cookie/-/tough-cookie-4.0.0.tgz#fef1904e4668b6e5ecee60c52cc6a078ffa6697d" + integrity sha512-I99sngh224D0M7XgW1s120zxCt3VYQ3IQsuw3P3jbq5GG4yc79+ZjyKznyOGIQrflfylLgcfekeZW/vk0yng6A== + "@webassemblyjs/ast@1.9.0": version "1.9.0" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.9.0.tgz#bd850604b4042459a5a41cd7d338cbed695ed964" @@ -1533,6 +1570,11 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.1.0.tgz#30fa40c9e7fe07dbc895678cd287024dea241dd9" integrity sha512-1Yj8h9Q+QDF5FzhMs/c9+6UntbD5MkRfRwac8DoEm9ZfUBZ7tZ55YcGVAzEe4bXsdQHEk+s9S5wsOKVdZrw0tQ== +bintrees@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524" + integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ= + block-stream@*: version "0.0.9" resolved "https://registry.yarnpkg.com/block-stream/-/block-stream-0.0.9.tgz#13ebfe778a03205cfe03751481ebb4b3300c126a" @@ -2261,6 +2303,13 @@ crelt@^1.0.0: resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.4.tgz#9a05d7829aedf79538f2b26f7de319cf45a25b47" integrity sha512-l1cwMUOssGLEj5zgbut4lxJq95ZabOXVZnDybNqQRUtXh1lvUK7e7kJNm8SfvTQzYpE3AVJhIVUJKf382lMA7A== +cross-fetch@^3.0.4: + version "3.0.6" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.0.6.tgz#3a4040bc8941e653e0e9cf17f29ebcd177d3365c" + integrity sha512-KBPUbqgFjzWlVcURG+Svp9TlhA5uliYtiNx/0r8nv0pdypeQCRJ9IaSIc3q/x3q8t3F75cHuwxVql1HFGHCNJQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" @@ -2832,6 +2881,13 @@ error-ex@^1.2.0, error-ex@^1.3.1: dependencies: is-arrayish "^0.2.1" +error-stack-parser@^2.0.4: + version "2.0.6" + resolved "https://registry.yarnpkg.com/error-stack-parser/-/error-stack-parser-2.0.6.tgz#5a99a707bd7a4c58a797902d48d82803ede6aad8" + integrity sha512-d51brTeqC+BHlwF0BhPtcYgF5nlzf9ZZ0ZIUQNZpc9ZB9qw5IJ2diTrBY9jlCJkTLITYPjmiX6OWCwH+fuyNgQ== + dependencies: + stackframe "^1.1.1" + es-abstract@^1.12.0, es-abstract@^1.5.1: version "1.13.0" resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.13.0.tgz#ac86145fdd5099d8dd49558ccba2eaf9b88e24e9" @@ -3199,6 +3255,15 @@ forever-agent@~0.6.1: resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" integrity sha1-+8cfDEGt6zf5bFd60e1C2P2sypE= +form-data@^2.5.0: + version "2.5.1" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" + integrity sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + form-data@~2.3.2: version "2.3.3" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" @@ -4840,6 +4905,11 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-fetch@2.6.1: + version "2.6.1" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.1.tgz#045bd323631f76ed2e2b55573394416b639a0052" + integrity sha512-V4aYg89jEoVRxRb2fJdAg8FHvI7cEyYdVAh94HH0UIK8oJxUfkjlDQN9RbMx+bEjP7+ggMiFRprSti032Oipxw== + node-forge@^0.10.0: version "0.10.0" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.10.0.tgz#32dea2afb3e9926f02ee5ce8794902691a676bf3" @@ -6167,6 +6237,11 @@ promise-inflight@^1.0.1: resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" integrity sha1-mEcocL8igTL8vdhoEputEsPAKeM= +promise-polyfill@^8.1.3: + version "8.2.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.2.0.tgz#367394726da7561457aba2133c9ceefbd6267da0" + integrity sha512-k/TC0mIcPVF6yHhUvwAp7cvL6I2fFV7TzF1DuGPI8mBh4QQazf36xCKEHKTZKRysEoTQoQdKyP25J8MPJp7j5g== + prosemirror-commands@^1.0.0: version "1.1.4" resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.1.4.tgz#991563e67623acab4f8c510fad1570f8b4693780" @@ -7241,6 +7316,11 @@ stable@^0.1.8: resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== +stackframe@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/stackframe/-/stackframe-1.2.0.tgz#52429492d63c62eb989804c11552e3d22e779303" + integrity sha512-GrdeshiRmS1YLMYgzF16olf2jJ/IzxXY9lhKOskuVziubpTYcYqyOwYeJKzQkwy7uN0fYSsbsC4RQaXf9LCrYA== + static-extend@^0.1.1: version "0.1.2" resolved "https://registry.yarnpkg.com/static-extend/-/static-extend-0.1.2.tgz#60809c39cbff55337226fd5e0b520f341f1fb5c6" @@ -7497,6 +7577,13 @@ tar@^6.0.2: mkdirp "^1.0.3" yallist "^4.0.0" +tdigest@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021" + integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE= + dependencies: + bintrees "1.0.1" + terser-webpack-plugin@^1.4.3: version "1.4.5" resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-1.4.5.tgz#a217aefaea330e734ffacb6120ec1fa312d6040b"