diff --git a/.env b/.env index 480175f8..fe503b11 100644 --- a/.env +++ b/.env @@ -39,3 +39,5 @@ GITLAB_PROJECT= GITLAB_TOKEN= PGVER=15 PGPID=/run/postgresql.pid +PANEL_ACTOR_MENTION=@sutty@sutty.nl +PANEL_ACTOR_SITE_ID=1 diff --git a/Gemfile b/Gemfile index 466ec079..e5e6ed10 100644 --- a/Gemfile +++ b/Gemfile @@ -37,7 +37,9 @@ gem 'commonmarker' gem 'devise' gem 'devise-i18n' gem 'devise_invitable' -gem 'distributed-press-api-client', '~> 0.3.0rc0' +gem 'redis-client' +gem 'hiredis-client' +gem 'distributed-press-api-client', '~> 0.4.1' gem 'email_address', git: 'https://github.com/fauno/email_address', branch: 'i18n' gem 'exception_notification' gem 'fast_blank' @@ -65,6 +67,7 @@ gem 'redis', '~> 4.0', require: %w[redis redis/connection/hiredis] gem 'redis-rails' gem 'rollups', git: 'https://github.com/fauno/rollup.git', branch: 'update' gem 'rubyzip' +gem 'ruby-brs' gem 'rugged', '1.5.0.1' gem 'git_clone_url' gem 'concurrent-ruby-ext' @@ -76,6 +79,11 @@ gem 'webpacker' gem 'yaml_db', git: 'https://0xacab.org/sutty/yaml_db.git' gem 'kaminari' gem 'device_detector' +gem 'rubanok' + +gem 'after_commit_everywhere', '~> 1.0' +gem 'aasm' +gem 'que-web' # database gem 'hairtrigger' diff --git a/Gemfile.lock b/Gemfile.lock index 78563c84..7087e013 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -27,73 +27,81 @@ GIT GEM remote: https://17.3.alpine.gems.sutty.nl/ specs: - actioncable (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + aasm (5.5.0) + concurrent-ruby (~> 1.0) + actioncable (6.1.7.4) + actionpack (= 6.1.7.4) + activesupport (= 6.1.7.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) - actionmailbox (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailbox (6.1.7.4) + actionpack (= 6.1.7.4) + activejob (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) mail (>= 2.7.1) - actionmailer (6.1.7.3) - actionpack (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionmailer (6.1.7.4) + actionpack (= 6.1.7.4) + actionview (= 6.1.7.4) + activejob (= 6.1.7.4) + activesupport (= 6.1.7.4) mail (~> 2.5, >= 2.5.4) rails-dom-testing (~> 2.0) - actionpack (6.1.7.3) - actionview (= 6.1.7.3) - activesupport (= 6.1.7.3) + actionpack (6.1.7.4) + actionview (= 6.1.7.4) + activesupport (= 6.1.7.4) rack (~> 2.0, >= 2.0.9) rack-test (>= 0.6.3) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0) - actiontext (6.1.7.3) - actionpack (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + actiontext (6.1.7.4) + actionpack (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) nokogiri (>= 1.8.5) - actionview (6.1.7.3) - activesupport (= 6.1.7.3) + actionview (6.1.7.4) + activesupport (= 6.1.7.4) builder (~> 3.1) erubi (~> 1.4) rails-dom-testing (~> 2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0) - activejob (6.1.7.3) - activesupport (= 6.1.7.3) + activejob (6.1.7.4) + activesupport (= 6.1.7.4) globalid (>= 0.3.6) - activemodel (6.1.7.3) - activesupport (= 6.1.7.3) - activerecord (6.1.7.3) - activemodel (= 6.1.7.3) - activesupport (= 6.1.7.3) - activestorage (6.1.7.3) - actionpack (= 6.1.7.3) - activejob (= 6.1.7.3) - activerecord (= 6.1.7.3) - activesupport (= 6.1.7.3) + activemodel (6.1.7.4) + activesupport (= 6.1.7.4) + activerecord (6.1.7.4) + activemodel (= 6.1.7.4) + activesupport (= 6.1.7.4) + activestorage (6.1.7.4) + actionpack (= 6.1.7.4) + activejob (= 6.1.7.4) + activerecord (= 6.1.7.4) + activesupport (= 6.1.7.4) marcel (~> 1.0) mini_mime (>= 1.1.0) - activesupport (6.1.7.3) + activesupport (6.1.7.4) concurrent-ruby (~> 1.0, >= 1.0.2) i18n (>= 1.6, < 2) minitest (>= 5.1) tzinfo (~> 2.0) zeitwerk (~> 2.3) - addressable (2.8.4) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) + adsp (1.0.10) + after_commit_everywhere (1.4.0) + activerecord (>= 4.2) + activesupport ast (2.4.2) autoprefixer-rails (10.4.13.0) execjs (~> 2) - bcrypt (3.1.19-x86_64-linux-musl) + base64 (0.2.0) + bcrypt (3.1.20-x86_64-linux-musl) bcrypt_pbkdf (1.1.0-x86_64-linux-musl) benchmark-ips (2.12.0) + bigdecimal (3.1.1) bindex (0.8.1-x86_64-linux-musl) blazer (2.6.5) activerecord (>= 5) @@ -104,7 +112,8 @@ GEM autoprefixer-rails (>= 9.1.0) popper_js (>= 1.16.1, < 2) sassc-rails (>= 2.0.0) - brakeman (5.4.1) + brakeman (6.1.1) + racc builder (3.2.4) bundler-audit (0.9.1) bundler (>= 1.2.0, < 3) @@ -124,6 +133,7 @@ GEM concurrent-ruby (1.2.2) concurrent-ruby-ext (1.2.2-x86_64-linux-musl) concurrent-ruby (= 1.2.2) + connection_pool (2.4.1) crass (1.0.6) database_cleaner (2.0.2) database_cleaner-active_record (>= 2, < 3) @@ -131,7 +141,7 @@ GEM activerecord (>= 5.a) database_cleaner-core (~> 2.0.0) database_cleaner-core (2.0.1) - date (3.3.3-x86_64-linux-musl) + date (3.3.4-x86_64-linux-musl) dead_end (4.0.0) derailed_benchmarks (2.1.2) benchmark-ips (~> 2) @@ -145,8 +155,8 @@ GEM rake (> 10, < 14) ruby-statistics (>= 2.1) thor (>= 0.19, < 2) - device_detector (1.1.1) - devise (4.9.2) + device_detector (1.1.2) + devise (4.9.3) bcrypt (~> 3.0) orm_adapter (~> 0.1) railties (>= 4.1.0) @@ -154,14 +164,15 @@ GEM warden (~> 1.2.3) devise-i18n (1.11.0) devise (>= 4.9.0) - devise_invitable (2.0.8) + devise_invitable (2.0.9) actionmailer (>= 5.0) devise (>= 4.6) - distributed-press-api-client (0.3.0rc0) + distributed-press-api-client (0.4.1) addressable (~> 2.3, >= 2.3.0) climate_control dry-schema httparty (~> 0.18) + httparty-cache (~> 0.0.6) json (~> 2.1, >= 2.1.0) jwt (~> 2.6.0) dotenv (2.8.1) @@ -170,10 +181,10 @@ GEM railties (>= 3.2) down (5.4.1) addressable (~> 2.8) - dry-configurable (1.0.1) + dry-configurable (1.1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) - dry-core (1.0.0) + dry-core (1.0.1) concurrent-ruby (~> 1.0) zeitwerk (~> 2.6) dry-inflector (1.0.0) @@ -182,7 +193,7 @@ GEM concurrent-ruby (~> 1.0) dry-core (~> 1.0, < 2) zeitwerk (~> 2.6) - dry-schema (1.13.1) + dry-schema (1.13.3) concurrent-ruby (~> 1.0) dry-configurable (~> 1.0, >= 1.0.1) dry-core (~> 1.0, < 2) @@ -190,7 +201,8 @@ GEM dry-logic (>= 1.4, < 2) dry-types (>= 1.7, < 2) zeitwerk (~> 2.6) - dry-types (1.7.1) + dry-types (1.7.2) + bigdecimal (~> 3.0) concurrent-ruby (~> 1.0) dry-core (~> 1.0) dry-inflector (~> 1.0) @@ -223,25 +235,25 @@ GEM ffi (~> 1.0) git_clone_url (2.0.0) uri-ssh_git (>= 2.0) - globalid (1.1.0) - activesupport (>= 5.0) + globalid (1.2.1) + activesupport (>= 6.1) groupdate (6.2.1) activesupport (>= 5.2) hairtrigger (1.0.0) activerecord (>= 6.0, < 8) ruby2ruby (~> 2.4) ruby_parser (~> 3.10) - haml (6.1.2-x86_64-linux-musl) + haml (6.3.0) temple (>= 0.8.2) thor tilt haml-lint (0.999.999) haml_lint - haml_lint (0.45.0) - haml (>= 4.0, < 6.2) + haml_lint (0.53.0) + haml (>= 5.0) parallel (~> 1.10) rainbow - rubocop (>= 0.50.0) + rubocop (>= 1.0) sysexits (~> 1.1) hamlit (3.0.3-x86_64-linux-musl) temple (>= 0.8.2) @@ -255,10 +267,14 @@ GEM heapy (0.2.0) thor hiredis (0.6.3-x86_64-linux-musl) + hiredis-client (0.14.1-x86_64-linux-musl) + redis-client (= 0.14.1) http_parser.rb (0.8.0-x86_64-linux-musl) httparty (0.21.0) mini_mime (>= 1.0.0) multi_xml (>= 0.5.2) + httparty-cache (0.0.6) + httparty (~> 0.18) i18n (1.14.1) concurrent-ruby (~> 1.0) icalendar (2.8.0) @@ -290,7 +306,7 @@ GEM terminal-table (~> 2.0) jekyll-commonmark (1.4.0) commonmarker (~> 0.22) - jekyll-images (0.4.1) + jekyll-images (0.4.4) jekyll (~> 4) ruby-filemagic (~> 0.7) ruby-vips (~> 2) @@ -300,7 +316,7 @@ GEM sassc (> 2.0.1, < 3.0) jekyll-watch (2.2.1) listen (~> 3.0) - json (2.6.3-x86_64-linux-musl) + json (2.7.1-x86_64-linux-musl) jwt (2.6.0) kaminari (1.2.2) activesupport (>= 4.1.0) @@ -329,12 +345,12 @@ GEM loaf (0.10.0) railties (>= 3.2) lockbox (1.2.0) - lograge (0.12.0) + lograge (0.14.0) actionpack (>= 4) activesupport (>= 4) railties (>= 4) request_store (~> 1.0) - loofah (2.21.3) + loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) mail (2.8.1) @@ -348,36 +364,39 @@ GEM method_source (1.0.0) mini_histogram (0.3.1) mini_magick (4.12.0) - mini_mime (1.1.2) - mini_portile2 (2.8.2) - minitest (5.18.0) + mini_mime (1.1.5) + mini_portile2 (2.8.5) + minitest (5.21.1) mobility (1.2.9) i18n (>= 0.6.10, < 2) request_store (~> 1.0) multi_xml (0.6.0) - net-imap (0.3.4) + mustermann (3.0.0) + ruby2_keywords (~> 0.0.1) + net-imap (0.4.9) date net-protocol net-pop (0.1.2) net-protocol - net-protocol (0.2.1) + net-protocol (0.2.2) timeout - net-smtp (0.3.3) + net-smtp (0.4.0) net-protocol - net-ssh (7.1.0) + net-ssh (7.2.1) netaddr (2.0.6) - nio4r (2.5.9-x86_64-linux-musl) - nokogiri (1.15.4-x86_64-linux-musl) + nio4r (2.7.0-x86_64-linux-musl) + nokogiri (1.16.0-x86_64-linux-musl) mini_portile2 (~> 2.8.2) racc (~> 1.4) orm_adapter (0.5.0) pairing_heap (3.0.1) - parallel (1.23.0) - parser (3.2.2.1) + parallel (1.24.0) + parser (3.2.2.3) ast (~> 2.4.1) + racc pathutil (0.16.2) forwardable-extended (~> 2.6) - pg (1.5.3-x86_64-linux-musl) + pg (1.5.4-x86_64-linux-musl) pg_search (2.3.6) activerecord (>= 5.2) activesupport (>= 5.2) @@ -387,55 +406,63 @@ GEM pry (0.14.2) coderay (~> 1.1) method_source (~> 1.0) - public_suffix (5.0.3) - puma (6.3.1-x86_64-linux-musl) + public_suffix (5.0.4) + puma (6.4.2-x86_64-linux-musl) nio4r (~> 2.0) - pundit (2.3.0) + pundit (2.3.1) activesupport (>= 3.0.0) que (2.2.1) - racc (1.7.1-x86_64-linux-musl) - rack (2.2.7) + que-web (0.10.0) + que (>= 1) + sinatra + racc (1.7.3-x86_64-linux-musl) + rack (2.2.8) rack-cors (2.0.1) rack (>= 2.0.0) rack-mini-profiler (3.1.0) rack (>= 1.2.0) - rack-proxy (0.7.6) + rack-protection (3.2.0) + base64 (>= 0.1.0) + rack (~> 2.2, >= 2.2.4) + rack-proxy (0.7.7) rack rack-test (2.1.0) rack (>= 1.3) - rails (6.1.7.3) - actioncable (= 6.1.7.3) - actionmailbox (= 6.1.7.3) - actionmailer (= 6.1.7.3) - actionpack (= 6.1.7.3) - actiontext (= 6.1.7.3) - actionview (= 6.1.7.3) - activejob (= 6.1.7.3) - activemodel (= 6.1.7.3) - activerecord (= 6.1.7.3) - activestorage (= 6.1.7.3) - activesupport (= 6.1.7.3) + rails (6.1.7.4) + actioncable (= 6.1.7.4) + actionmailbox (= 6.1.7.4) + actionmailer (= 6.1.7.4) + actionpack (= 6.1.7.4) + actiontext (= 6.1.7.4) + actionview (= 6.1.7.4) + activejob (= 6.1.7.4) + activemodel (= 6.1.7.4) + activerecord (= 6.1.7.4) + activestorage (= 6.1.7.4) + activesupport (= 6.1.7.4) bundler (>= 1.15.0) - railties (= 6.1.7.3) + railties (= 6.1.7.4) sprockets-rails (>= 2.0.0) - rails-dom-testing (2.0.3) - activesupport (>= 4.2.0) + rails-dom-testing (2.2.0) + activesupport (>= 5.0.0) + minitest nokogiri (>= 1.6) - rails-html-sanitizer (1.5.0) - loofah (~> 2.19, >= 2.19.1) - rails-i18n (7.0.7) + rails-html-sanitizer (1.6.0) + loofah (~> 2.21) + nokogiri (~> 1.14) + rails-i18n (7.0.8) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) rails_warden (0.6.0) warden (>= 1.2.0) - railties (6.1.7.3) - actionpack (= 6.1.7.3) - activesupport (= 6.1.7.3) + railties (6.1.7.4) + actionpack (= 6.1.7.4) + activesupport (= 6.1.7.4) method_source rake (>= 12.2) thor (~> 1.0) rainbow (3.1.1) - rake (13.0.6) + rake (13.1.0) rb-fsevent (0.11.2) rb-inotify (0.10.1) ffi (~> 1.0) @@ -447,6 +474,8 @@ GEM redis-activesupport (5.3.0) activesupport (>= 3, < 8) redis-store (>= 1.3, < 2) + redis-client (0.14.1) + connection_pool redis-rack (2.1.4) rack (>= 2.0.8, < 3) redis-store (>= 1.2, < 2) @@ -456,18 +485,19 @@ GEM redis-store (>= 1.2, < 2) redis-store (1.9.2) redis (>= 4, < 6) - regexp_parser (2.8.0) + regexp_parser (2.9.0) request_store (1.5.1) rack (>= 1.4) - responders (3.1.0) + responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.2.5) + rexml (3.2.6) rgl (0.6.3) pairing_heap (>= 0.3.0) rexml (~> 3.2, >= 3.2.4) stream (~> 0.5.3) rouge (3.30.0) + rubanok (0.5.0) rubocop (1.42.0) json (~> 2.3) parallel (~> 1.10) @@ -478,17 +508,21 @@ GEM rubocop-ast (>= 1.24.1, < 2.0) ruby-progressbar (~> 1.7) unicode-display_width (>= 1.4.0, < 3.0) - rubocop-ast (1.28.1) + rubocop-ast (1.30.0) parser (>= 3.2.1.0) - rubocop-rails (2.19.1) + rubocop-rails (2.23.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) + rubocop-ast (>= 1.30.0, < 2.0) + ruby-brs (1.3.3-x86_64-linux-musl) + adsp (~> 1.0) ruby-filemagic (0.7.3-x86_64-linux-musl) ruby-progressbar (1.13.0) ruby-statistics (3.0.2) - ruby-vips (2.1.4) + ruby-vips (2.2.0) ffi (~> 1.12) + ruby2_keywords (0.0.5) ruby2ruby (2.5.0) ruby_parser (~> 3.1) sexp_processor (~> 4.6) @@ -515,19 +549,24 @@ GEM sexp_processor (4.17.0) simpleidn (0.2.1) unf (~> 0.1.4) + sinatra (3.2.0) + mustermann (~> 3.0) + rack (~> 2.2, >= 2.2.4) + rack-protection (= 3.2.0) + tilt (~> 2.0) sourcemap (0.1.1) spring (4.1.1) spring-watcher-listen (2.1.0) listen (>= 2.7, < 4.0) spring (>= 4) - sprockets (4.2.0) + sprockets (4.2.1) concurrent-ruby (~> 1.0) rack (>= 2.2.4, < 4) sprockets-rails (3.4.2) actionpack (>= 5.2) activesupport (>= 5.2) sprockets (>= 3.0.0) - sqlite3 (1.6.3-x86_64-linux-musl) + sqlite3 (1.7.0-x86_64-linux-musl) mini_portile2 (~> 2.8.0) stackprof (0.2.25-x86_64-linux-musl) stream (0.5.5) @@ -536,13 +575,13 @@ GEM jekyll (~> 4) symbol-fstring (1.0.2-x86_64-linux-musl) sysexits (1.2.0) - temple (0.10.1) + temple (0.10.3) terminal-table (2.0.0) unicode-display_width (~> 1.1, >= 1.1.1) thor (1.3.0) - tilt (2.1.0) + tilt (2.3.0) timecop (0.9.6) - timeout (0.3.2) + timeout (0.4.1) turbolinks (5.2.1) turbolinks-source (~> 5.2) turbolinks-source (5.2.0) @@ -552,7 +591,7 @@ GEM execjs (>= 0.3.0, < 3) unf (0.1.4) unf_ext - unf_ext (0.0.8.2-x86_64-linux-musl) + unf_ext (0.0.9-x86_64-linux-musl) unicode-display_width (1.8.0) uri-ssh_git (2.0.0) validates_hostname (1.0.13) @@ -578,12 +617,14 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.34) - zeitwerk (2.6.8) + zeitwerk (2.6.12) PLATFORMS x86_64-linux-musl DEPENDENCIES + aasm + after_commit_everywhere (~> 1.0) bcrypt (~> 3.1.7) bcrypt_pbkdf blazer @@ -600,7 +641,7 @@ DEPENDENCIES devise devise-i18n devise_invitable - distributed-press-api-client (~> 0.3.0rc0) + distributed-press-api-client (~> 0.4.1) dotenv-rails down ed25519 @@ -616,6 +657,7 @@ DEPENDENCIES haml-lint hamlit-rails hiredis + hiredis-client httparty icalendar image_processing @@ -643,16 +685,20 @@ DEPENDENCIES puma pundit que + que-web rack-cors rack-mini-profiler rails (~> 6.1.0) rails-i18n rails_warden redis (~> 4.0) + redis-client redis-rails rgl rollups! + rubanok rubocop-rails + ruby-brs rubyzip rugged (= 1.5.0.1) safe_yaml diff --git a/Procfile b/Procfile index eab8a502..a74f613b 100644 --- a/Procfile +++ b/Procfile @@ -10,3 +10,4 @@ cleanup: bundle exec rake cleanup:everything emergency_cleanup: bundle exec rake cleanup:everything BEFORE=7 stats: bundle exec rake stats:process_all que: daemonize -c /srv/ -p /srv/tmp/que.pid -u rails /usr/local/bin/syslogize bundle exec que +fediblock: bundle exec rails activity_pub:fediblocks diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 88287d9e..4d1d0848 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -32,6 +32,22 @@ $sizes: ( @import "bootstrap"; @import "editor"; +@each $color, $rgb in $theme-colors { + .#{$color} { + color: var(--#{$color}); + + &:focus { + color: var(--#{$color}); + } + + ::-moz-selection, + ::selection { + background: var(--#{$color}); + color: white; + } + } +} + .editor { .editor-content { figure { @@ -580,24 +596,28 @@ $bezier: cubic-bezier(0.75, 0, 0.25, 1); // details styles .details { - summary { + & > summary { list-style: none; - cursor: default; - position: relative; + cursor: pointer; + + .hide-when-open { + display: inline; + } + + .show-when-open { + display: none; + } } - summary::after { - content: '▶'; - font-size: 1.8rem; - position: absolute; - left: 97%; - bottom: 3%; - transform: rotate(180deg); - } + &[open] { & > summary { - &::after { - transform: rotate(90deg); - } + .hide-when-open { + display: none; + } + + .show-when-open { + display: inline; + } } } -} +} diff --git a/app/controllers/activity_pubs_controller.rb b/app/controllers/activity_pubs_controller.rb new file mode 100644 index 00000000..428d5cb1 --- /dev/null +++ b/app/controllers/activity_pubs_controller.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# Gestiona acciones de moderación +class ActivityPubsController < ApplicationController + include ModerationConcern + + ActivityPub.events.each do |event| + define_method(event) do + authorize activity_pub + + if event == :report + remote_flag_params(activity_pub).tap do |p| + activity_pub.remote_flag_id = p[:remote_flag_attributes][:id] + activity_pub.update(p) + end + end + + message = + if activity_pub.public_send(:"may_#{event}?") && activity_pub.public_send(:"#{event}!") + :success + else + :error + end + + flash[message] = I18n.t("activity_pubs.#{event}.#{message}") + + redirect_to_moderation_queue! + end + end + + def action_on_several + redirect_to_moderation_queue! + + activity_pubs = site.activity_pubs.where(id: params[:activity_pub]) + + return if activity_pubs.count.zero? + + authorize activity_pubs + + action = params[:activity_pub_action].to_sym + method = :"#{action}_all!" + may = :"may_#{action}?" + + return unless ActivityPub.events.include? action + + # Crear una sola remote flag por autore + ActivityPub.transaction do + if action == :report + message = remote_flag_params(activity_pubs.first).dig(:remote_flag_attributes, :message) + + activity_pubs.distinct.pluck(:actor_id).each do |actor_id| + remote_flag = ActivityPub::RemoteFlag.find_or_initialize_by(actor_id: actor_id, site_id: site.id) + remote_flag.message = message + # Lo estamos actualizando, con lo que lo vamos a volver a enviar + remote_flag.requeue if remote_flag.persisted? + remote_flag.save + # XXX: Idealmente todas las ActivityPub que enviamos pueden + # cambiar de estado, pero chequeamos de todas formas. + remote_flag.activity_pubs << (activity_pubs.where(actor_id: actor_id).to_a.select { |a| a.public_send(may) }) + end + end + + message = activity_pubs.public_send(method) ? :success : :error + + flash[message] = I18n.t("activity_pubs.action_on_several.#{message}") + end + end + + private + + def activity_pub + @activity_pub ||= site.activity_pubs.find(params[:activity_pub_id]) + end +end diff --git a/app/controllers/actor_moderations_controller.rb b/app/controllers/actor_moderations_controller.rb new file mode 100644 index 00000000..04d2603b --- /dev/null +++ b/app/controllers/actor_moderations_controller.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +# Gestiona la cola de moderación de actores +class ActorModerationsController < ApplicationController + include ModerationConcern + include ModerationFiltersConcern + + before_action :authenticate_usuarie! + + breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path + breadcrumb 'sites.index', :sites_path, match: :exact + + ActorModeration.events.each do |actor_event| + define_method(actor_event) do + authorize actor_moderation + + # Crea una RemoteFlag si se envían los parámetros adecuados + if actor_event == :report + remote_flag_params(actor_moderation).tap do |p| + actor_moderation.remote_flag_id = p[:remote_flag_attributes][:id] + actor_moderation.update(p) + end + end + + message = + if actor_moderation.public_send(:"may_#{actor_event}?") && actor_moderation.public_send(:"#{actor_event}!") + :success + else + :error + end + + flash[message] = I18n.t("actor_moderations.#{actor_event}.#{message}") + + redirect_to_moderation_queue! + end + end + + # Ver el perfil remoto + def show + breadcrumb site.title, site_posts_path(site) + breadcrumb I18n.t('moderation_queue.index.title'), site_moderation_queue_path(site) + + @remote_profile = actor_moderation.actor.content + @moderation_queue = rubanok_process(site.activity_pubs.where(actor_id: actor_moderation.actor_id), + with: ActivityPubProcessor) + + breadcrumb @remote_profile['name'] || actor_moderation.actor.mention || actor_moderation.actor.uri, '' + end + + def action_on_several + redirect_to_moderation_queue! + + actor_moderations = site.actor_moderations.where(id: params[:actor_moderation]) + + return if actor_moderations.count.zero? + + authorize actor_moderations + + action = params[:actor_moderation_action].to_sym + method = :"#{action}_all!" + may = :"may_#{action}?" + + return unless ActorModeration.events.include? action + + ActorModeration.transaction do + if action == :report + actor_moderations.find_each do |actor_moderation| + next unless actor_moderation.public_send(may) + + actor_moderation.update(actor_moderation_params(actor_moderation)) + end + end + + message = actor_moderations.public_send(method) ? :success : :error + + flash[message] = I18n.t("actor_moderations.action_on_several.#{message}") + end + end + + private + + def actor_moderation + @actor_moderation ||= site.actor_moderations.find(params[:actor_moderation_id] || params[:id]) + end +end diff --git a/app/controllers/api/v1/activity_pub/remote_flags_controller.rb b/app/controllers/api/v1/activity_pub/remote_flags_controller.rb new file mode 100644 index 00000000..23245b8b --- /dev/null +++ b/app/controllers/api/v1/activity_pub/remote_flags_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api + module V1 + module ActivityPub + # Devuelve los reportes remotos hechos + # + # @todo Verificar la firma. Por ahora no es necesario porque no es + # posible obtener remotamente todos los reportes y se identifican por + # UUIDv4. + class RemoteFlagsController < BaseController + skip_forgery_protection + + def show + render json: (remote_flag&.content || {}), content_type: 'application/activity+json' + end + + private + + # @return [ActivityPub::RemoteFlag,nil] + def remote_flag + @remote_flag ||= ::ActivityPub::RemoteFlag.find(params[:id]) + end + end + end + end +end diff --git a/app/controllers/api/v1/webhooks/concerns/webhook_concern.rb b/app/controllers/api/v1/webhooks/concerns/webhook_concern.rb new file mode 100644 index 00000000..aef2dd83 --- /dev/null +++ b/app/controllers/api/v1/webhooks/concerns/webhook_concern.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Api + module V1 + module Webhooks + module Concerns + # Helpers para webhooks + module WebhookConcern + extend ActiveSupport::Concern + + included do + skip_before_action :verify_authenticity_token + + # Responde con forbidden si falla la validación del token + rescue_from ActiveRecord::RecordNotFound, with: :platforms_answer + rescue_from ActiveRecord::RecordInvalid, with: :platforms_answer + + private + + # Valida el token que envía la plataforma en el webhook + # + # @return [String] + def token + @token ||= + begin + header = request.headers + token = header['X-Social-Inbox'].presence + token ||= header['X-Gitlab-Token'].presence + token ||= token_from_signature(header['X-Gitea-Signature'].presence) + token ||= token_from_signature(header['X-Hub-Signature-256'].presence, 'sha256=') + token + ensure + raise ActiveRecord::RecordNotFound, 'Proveedor no soportado' if token.blank? + end + end + + # Valida token a partir de firma + # + # @param signature [String,nil] + # @param prepend [String] + # @return [String, nil] + def token_from_signature(signature, prepend = '') + return if signature.nil? + + payload = request.raw_post + + site.roles.where(temporal: false, rol: 'usuarie').pluck(:token).find do |token| + new_signature = prepend + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), token, payload) + + ActiveSupport::SecurityUtils.secure_compare(new_signature, signature.to_s) + end + end + + # Encuentra el sitio a partir de la URL + # + # @return [Site] + def site + @site ||= Site.find_by_name!(params[:site_id]) + end + + # Encuentra le usuarie + # + # @return [Site] + def usuarie + @usuarie ||= site.roles.find_by!(temporal: false, rol: 'usuarie', token: token).usuarie + end + + # Respuesta de error a plataformas + def platforms_answer(exception) + ExceptionNotifier.notify_exception(exception, data: { headers: request.headers.to_h }) + + head :forbidden + end + end + end + end + end + end +end diff --git a/app/controllers/api/v1/webhooks/pull_controller.rb b/app/controllers/api/v1/webhooks/pull_controller.rb new file mode 100644 index 00000000..93256fc7 --- /dev/null +++ b/app/controllers/api/v1/webhooks/pull_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + module Webhooks + # Recibe webhooks y lanza un PullJob + class PullController < BaseController + include Api::V1::Webhooks::Concerns::WebhookConcern + + # Trae los cambios a partir de un post de Webhooks: + # (Gitlab, Github, Gitea, etc) + # + # @return [nil] + def pull + message = I18n.with_locale(site.default_locale) do + I18n.t('webhooks.pull.message') + end + + GitPullJob.perform_later(site, usuarie, message) + head :ok + end + end + end + end +end diff --git a/app/controllers/api/v1/webhooks/social_inbox_controller.rb b/app/controllers/api/v1/webhooks/social_inbox_controller.rb new file mode 100644 index 00000000..9d215812 --- /dev/null +++ b/app/controllers/api/v1/webhooks/social_inbox_controller.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Api + module V1 + module Webhooks + # Recibe webhooks de la Social Inbox + # + # @see {https://www.w3.org/TR/activitypub/} + class SocialInboxController < BaseController + include Api::V1::Webhooks::Concerns::WebhookConcern + + # Validar que el token sea correcto + before_action :usuarie + + # Cuando una actividad ingresa en la cola de moderación, la + # recibimos por acá + # + # Vamos a recibir Create, Update, Delete, Follow, Undo, + # Announce, Like y obtener el objeto dentro de cada una para + # guardar un estado asociado al sitio. + # + # El objeto del estado puede ser un objeto o une actore, + # dependiendo de la actividad. + def moderationqueued + process! :paused + + head :accepted + end + + # Cuando la Social Inbox acepta una actividad, la recibimos + # igual y la guardamos por si cambiamos de idea. + def onapproved + process! :approved + + head :accepted + end + + # Cuando la Social Inbox rechaza una actividad, la recibimos + # igual y la guardamos por si cambiamos de idea. + def onrejected + process! :rejected + + head :accepted + end + + private + + # Envía la actividad para procesamiento por separado. + # + # @param initial_state [Symbol] + def process!(initial_state) + ::ActivityPub::ProcessJob + .set(wait: ApplicationJob.random_wait) + .perform_later(site: site, body: request.raw_post, initial_state: initial_state) + end + end + end + end +end diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb deleted file mode 100644 index 6e7b7022..00000000 --- a/app/controllers/api/v1/webhooks_controller.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -module Api - module V1 - # Recibe webhooks y lanza un PullJob - class WebhooksController < BaseController - # responde con forbidden si falla la validación del token - rescue_from ActiveRecord::RecordNotFound, with: :platforms_answer - - # Trae los cambios a partir de un post de Webhooks: - # (Gitlab, Github, Gitea, etc) - # - # @return [nil] - def pull - message = I18n.with_locale(site.default_locale) do - I18n.t('webhooks.pull.message') - end - - GitPullJob.perform_later(site, usuarie, message) - head :ok - end - - private - - # encuentra el sitio a partir de la url - def site - @site ||= Site.find_by_name!(params[:site_id]) - end - - # valida el token que envía la plataforma del webhook - # - # @return [String] - def token - @token ||= - begin - # Gitlab - if request.headers['X-Gitlab-Token'].present? - request.headers['X-Gitlab-Token'] - # Github - elsif request.headers['X-Hub-Signature-256'].present? - token_from_signature(request.headers['X-Hub-Signature-256'], 'sha256=') - # Gitea - elsif request.headers['X-Gitea-Signature'].present? - token_from_signature(request.headers['X-Gitea-Signature']) - else - raise ActiveRecord::RecordNotFound, 'proveedor no soportado' - end - end - end - - # valida token a partir de firma de webhook - # - # @return [String, Boolean] - def token_from_signature(signature, prepend = '') - payload = request.body.read - site.roles.where(temporal: false, rol: 'usuarie').pluck(:token).find do |token| - new_signature = prepend + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha256'), token, payload) - ActiveSupport::SecurityUtils.secure_compare(new_signature, signature.to_s) - end.tap do |t| - raise ActiveRecord::RecordNotFound, 'token no encontrado' if t.nil? - end - end - - # encuentra le usuarie - def usuarie - @usuarie ||= site.roles.find_by!(temporal: false, rol: 'usuarie', token: token).usuarie - end - - # respuesta de error a plataformas - def platforms_answer(exception) - ExceptionNotifier.notify_exception(exception, data: { headers: request.headers.to_h }) - - head :forbidden - end - end - end -end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f3c942f5..117be995 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,7 @@ # Forma de ingreso a Sutty class ApplicationController < ActionController::Base include ExceptionHandler - include Pundit + include Pundit::Authorization protect_from_forgery with: :null_session, prepend: true @@ -13,12 +13,8 @@ class ApplicationController < ActionController::Base around_action :set_locale after_action :store_location! - rescue_from Pundit::NilPolicyError, with: :page_not_found - rescue_from ActionController::RoutingError, with: :page_not_found - rescue_from ActionController::ParameterMissing, with: :page_not_found - before_action do - Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?('@' + ENV.fetch('SUTTY', 'sutty.nl')) + Rack::MiniProfiler.authorize_request if current_usuarie&.email&.ends_with?("@#{ENV.fetch('SUTTY', 'sutty.nl')}") end # No tenemos índice de sutty, vamos directamente a ver el listado de @@ -28,17 +24,7 @@ class ApplicationController < ActionController::Base end private - # Traer datos de muestra de la cola de moderación - def dummy_data - @moderation_queue = YAML.safe_load(File.read(Rails.root.join('db', 'seeds', 'moderation_queue.yaml'))) - @remote_profile = YAML.safe_load(File.read(Rails.root.join('db', 'seeds', 'remote_profile.yaml'))) - @instances = YAML.safe_load(File.read(Rails.root.join('db', 'seeds', 'instances.yaml'))) - @blocklists= YAML.safe_load(File.read(Rails.root.join('db', 'seeds', 'blocklists.yml'))) - @moderation_queue.each do |activity| - activity['attributedTo'] = @remote_profile - end - end - + def notify_unconfirmed_email return unless current_usuarie return if current_usuarie.confirmed? @@ -72,9 +58,7 @@ class ApplicationController < ActionController::Base def current_locale locale = params[:change_locale_to] - if locale.present? && I18n.locale_available?(locale) - session[:locale] = params[:change_locale_to] - end + session[:locale] = params[:change_locale_to] if locale.present? && I18n.locale_available?(locale) session[:locale] || current_usuarie&.lang || I18n.locale end @@ -86,11 +70,6 @@ class ApplicationController < ActionController::Base I18n.with_locale(current_locale, &action) end - # Muestra una página 404 - def page_not_found - render 'application/page_not_found', status: :not_found - end - # Necesario para poder acceder a Blazer. Solo les usuaries de este # sitio pueden acceder al panel. def require_usuarie @@ -138,5 +117,4 @@ class ApplicationController < ActionController::Base session[:usuarie_return_to] = request.fullpath end - end diff --git a/app/controllers/concerns/exception_handler.rb b/app/controllers/concerns/exception_handler.rb index 8c4f54c8..7c1cd540 100644 --- a/app/controllers/concerns/exception_handler.rb +++ b/app/controllers/concerns/exception_handler.rb @@ -12,13 +12,31 @@ module ExceptionHandler rescue_from PageNotFound, with: :page_not_found rescue_from ActionController::RoutingError, with: :page_not_found rescue_from Pundit::NilPolicyError, with: :page_not_found + rescue_from Pundit::NilPolicyError, with: :page_not_found + rescue_from ActionController::RoutingError, with: :page_not_found + rescue_from ActionController::ParameterMissing, with: :page_not_found end def site_not_found + reset_response! + + flash[:error] = I18n.t('errors.site_not_found') + redirect_to sites_path end def page_not_found - send_file Rails.root.join('public', '404.html') + reset_response! + + render 'application/page_not_found', status: :not_found + end + + private + + def reset_response! + self.response_body = nil + @_response_body = nil + + headers.delete('Location') end end diff --git a/app/controllers/concerns/moderation_concern.rb b/app/controllers/concerns/moderation_concern.rb new file mode 100644 index 00000000..8340ec2a --- /dev/null +++ b/app/controllers/concerns/moderation_concern.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ModerationConcern + extend ActiveSupport::Concern + + included do + private + + def redirect_to_moderation_queue! + redirect_back fallback_location: site_moderation_queue_path(**(session[:moderation_queue_filters] || {})) + end + + # @return [String] + def panel_actor_mention + @panel_actor_mention ||= ENV.fetch('PANEL_ACTOR_MENTION', '@sutty@sutty.nl') + end + + def remote_flag_params(model) + remote_flag = ActivityPub::RemoteFlag.find_by(actor_id: model.actor_id) + + { remote_flag_attributes: { id: remote_flag&.id, message: ''.dup } }.tap do |p| + p[:remote_flag_attributes][:site_id] = model.site_id + p[:remote_flag_attributes][:actor_id] = model.actor_id + + I18n.available_locales.each do |locale| + p[:remote_flag_attributes][:message].tap do |m| + m << I18n.t(locale) + m << ': ' + m << I18n.t('remote_flags.report_message', locale: locale, panel_actor_mention: panel_actor_mention) + m << '\n\n' + end + end + end + end + end +end diff --git a/app/controllers/concerns/moderation_filters_concern.rb b/app/controllers/concerns/moderation_filters_concern.rb new file mode 100644 index 00000000..25293a4f --- /dev/null +++ b/app/controllers/concerns/moderation_filters_concern.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ModerationFiltersConcern + extend ActiveSupport::Concern + + included do + before_action :store_filters_in_session!, only: %i[index show] + + private + + def store_filters_in_session! + session[:moderation_queue_filters] = params.permit(:instance_state, :actor_state, :activity_pub_state) + end + end +end diff --git a/app/controllers/fediblock_states_controller.rb b/app/controllers/fediblock_states_controller.rb new file mode 100644 index 00000000..4d9cc968 --- /dev/null +++ b/app/controllers/fediblock_states_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Estado de las listas de bloqueo en cada sitio +class FediblockStatesController < ApplicationController + # Realiza cambios en las listas de bloqueo + def action_on_several + # Encontrar todas y deshabilitar las que no se enviaron + site.fediblock_states.all.find_each do |fediblock_state| + if fediblock_states_ids.include? fediblock_state.id + fediblock_state.enable! if fediblock_state.may_enable? + elsif fediblock_state.may_disable? + fediblock_state.disable! + end + + flash[:success] = I18n.t('fediblock_states.action_on_several.success') + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { site: site.name }) + + flash.delete(:success) + flash[:error] = I18n.t('fediblock_states.action_on_several.error') + end + + # Bloquear otras instancias + if custom_blocklist.present? + if ActivityPub::InstanceModerationJob.perform_now(site: site, hostnames: custom_blocklist) + flash[:success] = I18n.t('fediblock_states.action_on_several.custom_blocklist_success') + else + flash[:error] = I18n.t('fediblock_states.action_on_several.custom_blocklist_error') + end + end + + redirect_to site_moderation_queue_path + end + + private + + def fediblock_states_ids + params[:fediblock_states_ids] || [] + end + + # La lista de hostnames + def custom_blocklist + @custom_blocklist ||= fediblocks_states_params[:custom_blocklist].split("\n").map(&:strip).select(&:present?) + end + + def fediblocks_states_params + @fediblocks_states_params ||= params.permit(:custom_blocklist, fediblock_states_ids: []) + end +end diff --git a/app/controllers/instance_moderations_controller.rb b/app/controllers/instance_moderations_controller.rb new file mode 100644 index 00000000..de990eb1 --- /dev/null +++ b/app/controllers/instance_moderations_controller.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +# Actualiza la relación entre un sitio y una instancia +class InstanceModerationsController < ApplicationController + include ModerationConcern + + InstanceModeration.events.each do |event| + define_method(event) do + authorize instance_moderation + + message = + if instance_moderation.public_send(:"may_#{event}?") && instance_moderation.public_send(:"#{event}!") + :success + else + :error + end + + flash[message] = I18n.t("instance_moderations.#{event}.#{message}") + + redirect_to_moderation_queue! + end + end + + def action_on_several + redirect_to_moderation_queue! + + instance_moderations = site.instance_moderations.where(id: params[:instance_moderation]) + + return if instance_moderations.count.zero? + + authorize instance_moderations + + action = params[:instance_moderation_action].to_sym + method = :"#{action}_all!" + + return unless InstanceModeration.events.include? action + + InstanceModeration.transaction do + message = instance_moderations.public_send(method) ? :success : :error + + flash[:message] = I18n.t("instance_moderations.action_on_several.#{message}") + end + end + + private + + # @return [InstanceModeration] + def instance_moderation + @instance_moderation ||= site.instance_moderations.find(params[:instance_moderation_id]) + end +end diff --git a/app/controllers/moderation_queue_controller.rb b/app/controllers/moderation_queue_controller.rb index eec0c70f..ef830c41 100644 --- a/app/controllers/moderation_queue_controller.rb +++ b/app/controllers/moderation_queue_controller.rb @@ -2,19 +2,25 @@ # Cola de moderación de ActivityPub class ModerationQueueController < ApplicationController + include ModerationFiltersConcern + + before_action :authenticate_usuarie! + + breadcrumb -> { current_usuarie.email }, :edit_usuarie_registration_path + breadcrumb 'sites.index', :sites_path, match: :exact + # Cola de moderación viendo todo el sitio def index - dummy_data - end + authorize ModerationQueue.new(site) + breadcrumb site.title, site_posts_path(site) + breadcrumb I18n.t('moderation_queue.index.title'), '' - # Perfil remoto de usuarie - def remote_profile - dummy_data - end + site.moderation_checked! - # todon.nl está usando /api/v2/instance - # mauve.moe usa /api/v1/instance - def instances - dummy_data + # @todo cambiar el estado por query + @activity_pubs = site.activity_pubs + @instance_moderations = rubanok_process(site.instance_moderations, with: InstanceModerationProcessor) + @actor_moderations = rubanok_process(site.actor_moderations, with: ActorModerationProcessor) + @moderation_queue = rubanok_process(site.activity_pubs, with: ActivityPubProcessor) end end diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb index 99dc6f7d..057c3068 100644 --- a/app/controllers/posts_controller.rb +++ b/app/controllers/posts_controller.rb @@ -38,7 +38,6 @@ class PostsController < ApplicationController @usuarie = site.usuarie? current_usuarie @site_stat = SiteStat.new(site) - dummy_data end def show @@ -82,7 +81,6 @@ class PostsController < ApplicationController authorize post breadcrumb post.title.value, site_post_path(site, post, locale: locale), match: :exact breadcrumb 'posts.edit', '' - dummy_data end def update diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 9f7be213..fcbd4074 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -13,7 +13,7 @@ module ApplicationHelper root = names.shift names.each do |n| - root += '[' + n.to_s + ']' + root += "[#{n}]" end [root, name] @@ -22,7 +22,7 @@ module ApplicationHelper def plain_field_name_for(*names) root, name = field_name_for(*names) - root + '[' + name.to_s + ']' + "#{root}[#{name}]" end def distance_of_time_in_words_if_more_than_a_minute(seconds) @@ -33,10 +33,24 @@ module ApplicationHelper end end - # Devuelve todas las etiquetas HTML que queremos mantener - def all_html_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 section article] + # Sanitizador que elimina todo + # + # @param html [String] + # @return [String] + def text_plain(html) + sanitize(html, tags: [], attributes: []) + end + + # Sanitizador con etiquetas y atributos por defecto + # + # @param html [String] + # @param options [Hash] + # @return [String] + def sanitize(html, options = {}) + options[:tags] ||= Sutty::ALLOWED_TAGS + options[:attributes] ||= Sutty::ALLOWED_ATTRIBUTES + + super(html, options) end # Genera HTML y limpia etiquetas innecesarias diff --git a/app/helpers/moderation_queue_helper.rb b/app/helpers/moderation_queue_helper.rb new file mode 100644 index 00000000..c69364ae --- /dev/null +++ b/app/helpers/moderation_queue_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module ModerationQueueHelper + def filter_states(**args) + params.permit(:instance_state, :actor_state, :activity_pub_state).merge(**args) + end + + def active?(states, state_name, state) + if params[state_name].present? + params[state_name] == state.to_s + else + states.first == state + end + end +end diff --git a/app/javascript/controllers/details_controller.js b/app/javascript/controllers/details_controller.js new file mode 100644 index 00000000..57935e1e --- /dev/null +++ b/app/javascript/controllers/details_controller.js @@ -0,0 +1,17 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static targets = []; + + connect() { + const state = window.sessionStorage.getItem(this.element.id); + + if (state === "open") { + this.element.setAttribute("open", true); + } + } + + store(event = undefined) { + window.sessionStorage.setItem(this.element.id, event.newState); + } +} diff --git a/app/javascript/controllers/select_all_controller.js b/app/javascript/controllers/select_all_controller.js new file mode 100644 index 00000000..7aca0f59 --- /dev/null +++ b/app/javascript/controllers/select_all_controller.js @@ -0,0 +1,11 @@ +import { Controller } from "stimulus"; + +export default class extends Controller { + static targets = ["toggle", "input"]; + + toggle(event = undefined) { + this.inputTargets.forEach(input => { + input.checked = this.toggleTarget.checked; + }); + } +} diff --git a/app/jobs/activity_pub/fediblock_fetch_job.rb b/app/jobs/activity_pub/fediblock_fetch_job.rb new file mode 100644 index 00000000..3d12f4cd --- /dev/null +++ b/app/jobs/activity_pub/fediblock_fetch_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ActivityPub + # Se encarga de mantener las listas de bloqueo actualizadas. Luego de + # actualizar el listado de instancias, bloquea las instancias en cada + # sitio que tenga el fediblock habilitado. + class FediblockFetchJob < ApplicationJob + self.priority = 50 + + def perform + ActivityPub::Fediblock.find_each do |fediblock| + fediblock.process! + + hostnames_added = fediblock.hostnames - fediblock.hostnames_was + + # No hacer nada si no cambió con respecto a la versión anterior + next if hostnames_added.empty? + + ActivityPub::FediblockUpdatedJob.perform_later(fediblock: fediblock, hostnames: hostnames_added) + rescue ActivityPub::Fediblock::FediblockDownloadError => e + ExceptionNotifier.notify_exception(e, data: { fediblock: fediblock.title }) + end + end + end +end diff --git a/app/jobs/activity_pub/fediblock_updated_job.rb b/app/jobs/activity_pub/fediblock_updated_job.rb new file mode 100644 index 00000000..1bb47517 --- /dev/null +++ b/app/jobs/activity_pub/fediblock_updated_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# Se encarga de mantener sincronizadas las listas de instancias +# de los fediblocks con los sitios que las tengan activadas. +# +# También va a asociar las listas con todos los sitios que tengan la +# Social Inbox habilitada. +class ActivityPub + class FediblockUpdatedJob < ApplicationJob + self.priority = 50 + + # @param :fediblock [ActivityPub::Fediblock] + # @param :hostnames [Array] + def perform(fediblock:, hostnames:) + instances = ActivityPub::Instance.where(hostname: hostnames) + + # Todos los sitios con la Social Inbox habilitada + Site.where(id: DeploySocialDistributedPress.pluck(:site_id)).find_each do |site| + # Crea el estado si no existía + fediblock_state = site.fediblock_states.find_or_create_by(fediblock: fediblock) + + # No hace nada con los deshabilitados + next unless fediblock_state.enabled? + + ActivityPub::InstanceModerationJob.perform_later(site: site, hostnames: hostnames) + end + end + end +end diff --git a/app/jobs/activity_pub/fetch_job.rb b/app/jobs/activity_pub/fetch_job.rb new file mode 100644 index 00000000..07190c35 --- /dev/null +++ b/app/jobs/activity_pub/fetch_job.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +# Obtiene o actualiza el contenido de un objeto, usando las credenciales +# del sitio. +# +# XXX: Esto usa las credenciales del sitio para volver el objeto +# disponible para todo el CMS. Asumimos que el objeto devuelto es el +# mismo para todo el mundo y las credenciales solo son para +# autenticación. +class ActivityPub + class FetchJob < ApplicationJob + self.priority = 50 + + attr_reader :object, :response + + # Notificar errores de JSON con el contenido, tomar los errores de + # validación y conexión como errores temporales y notificar todo lo + # demás sin reintentar. + # + # @param error [Exception] + # @return [Bool] + discard_on(FastJsonparser::ParseError) do |error| + ExceptionNotifier.notify_exception(error, data: { site: site.name, object: object.uri, body: response.body }) + end + + retry_on ActiveRecord::RecordInvalid + retry_on SocketError, wait: ApplicationJob.random_wait + retry_on SystemCallError, wait: ApplicationJob.random_wait + retry_on Net::OpenTimeout, wait: ApplicationJob.random_wait + retry_on OpenSSL::OpenSSLError, wait: ApplicationJob.random_wait + + def perform(site:, object_id:) + ActivityPub::Object.transaction do + @site = site + @object = ::ActivityPub::Object.find(object_id) + + return if object.blank? + return if object.activity_pubs.where(aasm_state: 'removed').count.positive? + + @response = site.social_inbox.dereferencer.get(uri: object.uri) + + # @todo Fallar cuando la respuesta no funcione? + # @todo Eliminar en 410 Gone + return unless response.success? + # Ignorar si ya la caché fue revalidada y ya teníamos el + # contenido + return if response.hit? && object.content.present? + + current_type = object.type + content = FastJsonparser.parse(response.body) + + # Modificar atómicamente + ::ActivityPub::Object.lock.find(object_id).update!(content: content, + type: ActivityPub::Object.type_from(content).name) + + object = ::ActivityPub::Object.find(object_id) + # Actualiza la mención + object.actor&.save! if object.actor_type? + + # Arreglar las relaciones con actividades también + ActivityPub.where(object_id: object.id).update_all(object_type: object.type, updated_at: Time.now) + end + end + end +end diff --git a/app/jobs/activity_pub/inbox_job.rb b/app/jobs/activity_pub/inbox_job.rb new file mode 100644 index 00000000..cb807704 --- /dev/null +++ b/app/jobs/activity_pub/inbox_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class ActivityPub + class InboxJob < ApplicationJob + self.priority = 10 + + # @param :site [Site] + # @param :activity [String] + # @param :action [Symbol] + def perform(site:, activity:, action:) + response = site.social_inbox.inbox.public_send(action, id: activity) + + raise response.body unless response.success? + end + end +end diff --git a/app/jobs/activity_pub/instance_fetch_job.rb b/app/jobs/activity_pub/instance_fetch_job.rb new file mode 100644 index 00000000..dc84caf2 --- /dev/null +++ b/app/jobs/activity_pub/instance_fetch_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub + # Obtiene o actualiza los datos de una instancia. Usamos un cliente + # de ActivityPub porque la instancia podría estar en federación + # limitada. + class InstanceFetchJob < ApplicationJob + self.priority = 100 + + def perform(site:, instance:) + %w[/api/v2/instance /api/v1/instance].each do |api| + uri = SocialInbox.generate_uri(instance.hostname) do |u| + u.path = api + end + + response = site.social_inbox.dereferencer.get(uri: uri) + + next unless response.success? + # @todo Validate schema + next unless response.parsed_response.is_a?(DistributedPress::V1::Social::ReferencedObject) + + instance.update(content: response.parsed_response.object) + + break + rescue BRS::BaseError, + Errno::ECONNREFUSED, + HTTParty::Error, + JSON::JSONError, + Net::OpenTimeout, + OpenSSL::OpenSSLError, + SocketError, + Errno::ENETUNREACH => e + ExceptionNotifier.notify_exception(e, data: { instance: uri }) + break + end + end + end +end diff --git a/app/jobs/activity_pub/instance_moderation_job.rb b/app/jobs/activity_pub/instance_moderation_job.rb new file mode 100644 index 00000000..9da0627f --- /dev/null +++ b/app/jobs/activity_pub/instance_moderation_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub + # Bloquea varias instancias de una sola vez + class InstanceModerationJob < ApplicationJob + # @param :site [Site] + # @param :hostnames [Array] + # @param :perform_remotely [Bool] + def perform(site:, hostnames:, perform_remotely: true) + # Crear las instancias que no existan todavía + hostnames.each do |hostname| + ActivityPub::Instance.lock.find_or_create_by(hostname: hostname) + end + + instances = ActivityPub::Instance.where(hostname: hostnames) + + Site.transaction do + # Crea todas las moderaciones de instancia con un estado por + # defecto si no existen + instances.find_each do |instance| + # Esto bloquea cada una individualmente en la Social Inbox, + # idealmente son pocas instancias las que aparecen. + site.instance_moderations.lock.find_or_create_by(instance: instance) + end + + scope = site.instance_moderations.where(instance_id: instances.ids) + + if perform_remotely + scope.block_all! + else + scope.block_all_without_callbacks! + end + + ActivityPub::SyncListsJob.perform_later(site: site) + end + end + end +end diff --git a/app/jobs/activity_pub/process_job.rb b/app/jobs/activity_pub/process_job.rb new file mode 100644 index 00000000..69c83e33 --- /dev/null +++ b/app/jobs/activity_pub/process_job.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +class ActivityPub + # Procesar las actividades a medida que llegan + class ProcessJob < ApplicationJob + attr_reader :body + + retry_on ActiveRecord::RecordInvalid + + # Procesa la actividad en segundo plano + # + # @param :body [String] + # @param :initial_state [Symbol,String] + def perform(site:, body:, initial_state: :paused) + @site = site + @body = body + + ActiveRecord::Base.connection_pool.with_connection do + ::ActivityPub.transaction do + # Crea todos los registros necesarios y actualiza el estado + actor.present? + instance.present? + object.present? + activity_pub.present? + activity_pub.update(aasm_state: initial_state) + + activity.update_activity_pub_state! + end + end + end + + private + + # Si el objeto ya viene incorporado en la actividad o lo tenemos + # que traer remotamente. + # + # @return [Bool] + def object_embedded? + @object_embedded ||= original_activity[:object].is_a?(Hash) + end + + # Encuentra la URI del objeto o falla si no la encuentra. + # + # @return [String] + def object_uri + @object_uri ||= ::ActivityPub.uri_from_object(original_activity[:object]) + ensure + raise ActiveRecord::RecordNotFound, 'object id missing' if @object_uri.blank? + end + + # Atajo a la instancia + # + # @return [ActivityPub::Instance] + def instance + actor.instance + end + + # Genera un objeto a partir de la actividad. Si el objeto ya + # existe, actualiza su contenido. Si el objeto no viene + # incorporado, obtenemos el contenido más tarde. + # + # @return [ActivityPub::Object] + def object + @object ||= ::ActivityPub::Object.lock.find_or_initialize_by(uri: object_uri).tap do |o| + o.lock! if o.persisted? + o.content = original_object if object_embedded? + + o.save! + + # XXX: el objeto necesita ser guardado antes de poder + # procesarlo. No usamos GlobalID porque el tipo de objeto + # cambia y produce un error de deserialización. + ::ActivityPub::FetchJob.perform_later(site: site, object_id: o.id) unless object_embedded? + end + end + + # Genera el seguimiento del estado del objeto con respecto al + # sitio. + # + # @return [ActivityPub] + def activity_pub + @activity_pub ||= site.activity_pubs.lock.find_or_create_by!(site: site, actor: actor, instance: instance, + object_id: object.id, object_type: object.type) + end + + # Crea la actividad y la vincula con el estado + # + # @return [ActivityPub::Activity] + def activity + @activity ||= + ::ActivityPub::Activity + .type_from(original_activity) + .lock + .find_or_initialize_by(uri: original_activity[:id], activity_pub: activity_pub, actor: actor).tap do |a| + a.lock! if a.persisted? + a.content = original_activity.dup + a.content[:object] = object.uri + a.save! + end + end + + # Actor, si no hay instancia, la crea en el momento, junto con + # su estado de moderación. + # + # @return [Actor] + def actor + @actor ||= ::ActivityPub::Actor.lock.find_or_initialize_by(uri: original_activity[:actor]).tap do |a| + a.lock! if a.persisted? + + unless a.instance + a.instance = ::ActivityPub::Instance.lock.find_or_create_by(hostname: URI.parse(a.uri).hostname) + + ::ActivityPub::InstanceFetchJob.perform_later(site: site, instance: a.instance) + end + + site.instance_moderations.lock.find_or_create_by(instance: a.instance) + a.save! + + site.actor_moderations.lock.find_or_create_by(actor: a) + + ::ActivityPub::FetchJob.perform_later(site: site, object_id: a.object.id) + end + end + + # @return [Hash,String] + def original_object + @original_object ||= original_activity[:object].dup.tap do |o| + o[:@context] = original_activity[:@context].dup + end + end + + # Descubre la actividad recibida, generando un error si la + # actividad no está dirigida a nosotres. + # + # @todo Validar formato con Dry::Schema + # @return [Hash] + def original_activity + @original_activity ||= FastJsonparser.parse(body).tap do |activity| + raise '@context missing' unless activity[:@context].present? + raise 'id missing' unless activity[:id].present? + raise 'object missing' unless activity[:object].present? + end + end + end +end diff --git a/app/jobs/activity_pub/remote_flag_job.rb b/app/jobs/activity_pub/remote_flag_job.rb new file mode 100644 index 00000000..211f46fc --- /dev/null +++ b/app/jobs/activity_pub/remote_flag_job.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# Envía un reporte directamente a la instancia remota +# +# @todo El panel debería ser su propia instancia y firmar sus propios +# mensajes. +# @todo Como la Social Inbox no soporta enviar actividades +# a destinataries que no sean seguidores, enviamos el reporte +# directamente a la instancia. +# @see {https://github.com/hyphacoop/social.distributed.press/issues/14} +class ActivityPub + class RemoteFlagJob < ApplicationJob + self.priority = 30 + + def perform(remote_flag:) + return unless remote_flag.may_queue? + + inbox = remote_flag.actor&.content&.[]('inbox') + + raise 'Inbox is missing for actor' if inbox.blank? + + remote_flag.queue! + + uri = URI.parse(inbox) + client = remote_flag.main_site.social_inbox.client_for(uri.origin) + response = client.post(endpoint: uri.path, body: remote_flag.content) + + raise 'No se pudo enviar el reporte' unless response.success? + + remote_flag.report! + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { remote_flag: remote_flag.id, response: response.parsed_response }) + remote_flag.resend! + raise + end + end +end diff --git a/app/jobs/activity_pub/sync_lists_job.rb b/app/jobs/activity_pub/sync_lists_job.rb new file mode 100644 index 00000000..e37e15be --- /dev/null +++ b/app/jobs/activity_pub/sync_lists_job.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +class ActivityPub + # Sincroniza las listas de bloqueo y permitidas con el estado actual + # de la base de datos. + class SyncListsJob < ApplicationJob + # Siempre correr al final + self.priority = 100 + + attr_reader :logs + + # Ejecuta todas las requests y consolida los posibles errores. + # + # @param site [Site] + def run(site:) + @logs = {} + + instance_scope = site.instance_moderations.joins(:instance) + actor_scope = site.actor_moderations.joins(:actor) + + blocklist = wildcardize(instance_scope.blocked.pluck(:hostname)) + actor_scope.blocked.distinct.pluck(:mention).compact + actor_scope.reported.distinct.pluck(:mention).compact + allowlist = wildcardize(instance_scope.allowed.pluck(:hostname)) + actor_scope.allowed.distinct.pluck(:mention).compact + pauselist = wildcardize(instance_scope.paused.pluck(:hostname)) + actor_scope.paused.distinct.pluck(:mention).compact + + if blocklist.present? + Rails.logger.info "Bloqueando: #{blocklist.join(', ')}" + process(:blocked) { site.social_inbox.allowlist.delete(list: blocklist) } + process(:blocked) { site.social_inbox.blocklist.post(list: blocklist) } + end + + if allowlist.present? + Rails.logger.info "Permitiendo: #{allowlist.join(', ')}" + process(:allowed) { site.social_inbox.blocklist.delete(list: allowlist) } + process(:allowed) { site.social_inbox.allowlist.post(list: allowlist) } + end + + if pauselist.present? + Rails.logger.info "Pausando: #{pauselist.join(', ')}" + process(:paused) { site.social_inbox.blocklist.delete(list: pauselist) } + process(:paused) { site.social_inbox.allowlist.delete(list: pauselist) } + end + + # Si alguna falló, reintentar + raise if logs.present? + rescue Exception => e + ExceptionNotifier.notify_exception(e, + data: { site: site.name, logs: logs, blocklist: blocklist, + allowlist: allowlist, pauselist: pauselist }) + + raise + end + + private + + def process(stage) + response = yield + + return if response.success? + + logs[stage] ||= [] + logs[stage] << { body: response.body, code: response.code } + end + + # @params hostnames [Array] + # @return [Array] + def wildcardize(hostnames) + hostnames.map do |hostname| + "@*@#{hostname}" + end + end + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb index d527c9b9..ee4e3b2c 100644 --- a/app/jobs/application_job.rb +++ b/app/jobs/application_job.rb @@ -4,6 +4,17 @@ class ApplicationJob < ActiveJob::Base include Que::ActiveJob::JobExtensions + # Esperar una cantidad random de segundos primos, para que no se + # superpongan tareas + # + # @return [Array] + RANDOM_WAIT = [3, 5, 7, 11, 13].freeze + + # @return [ActiveSupport::Duration] + def self.random_wait + RANDOM_WAIT.sample.seconds + end + attr_reader :site # Si falla por cualquier cosa informar y descartar diff --git a/app/models/activity_pub.rb b/app/models/activity_pub.rb new file mode 100644 index 00000000..7f8155cd --- /dev/null +++ b/app/models/activity_pub.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +# = ActivityPub = +# +# El registro de actividades recibidas y su estado. Cuando recibimos +# una actividad, puede estar destinada a varies actores dentro de Sutty, +# con lo que generamos una cola para cada une. +# +# +# @todo Ya que une actore puede hacer varias actividades sobre el mismo +# objeto, lo correcto sería que la actividad a moderar sea una sola en +# lugar de una lista acumulativa. Es decir cada ActivityPub representa +# el estado del conjunto (Actor, Object, Activity) +# +# @see {https://www.w3.org/TR/activitypub/#client-to-server-interactions} +class ActivityPub < ApplicationRecord + IGNORED_EVENTS = %i[pause remove].freeze + IGNORED_STATES = %i[removed].freeze + + include AASM + + belongs_to :instance + belongs_to :site + belongs_to :object, polymorphic: true + belongs_to :actor + belongs_to :remote_flag, optional: true, class_name: 'ActivityPub::RemoteFlag' + has_many :activities + + validates :site_id, presence: true + validates :object_id, presence: true + validates :aasm_state, presence: true, inclusion: { in: %w[paused approved rejected reported removed] } + + accepts_nested_attributes_for :remote_flag + + # Encuentra la URI de un objeto + # + # @return [String, nil] + def self.uri_from_object(object) + case object + when Array then uri_from_object(object.first) + when String then object + when Hash then (object['id'] || object[:id]) + end + end + + # Obtiene el campo `url` de diversas formas. Si es una String, asumir + # que es una URL, si es un Hash, asumir que es un Link, si es un + # Array de Strings, obtener la primera, si es de Hash, obtener el + # primer link con rel=canonical y mediaType=text/html + # + # De lo contrario devolver el ID. + # + # @todo Refactorizar + # @param object [Hash] + # @return [String] + def self.url_from_object(object) + raise unless object.respond_to?(:[]) + + url = + case object['url'] + when String then object['url'] + when Hash then object['href'] + # Esto es un lío porque queremos saber si es un Array o + # Array o mezcla y obtener el que más nos convenga o + # adivinar uno. + when Array + links = object['url'].map.with_index do |link, _i| + case link + when Hash then link + else { 'href' => link.to_s } + end + end + + links.find do |link| + link['rel'] == 'canonical' && link['mediaType'] == 'text/html' + end&.[]('href') || links.first&.[]('href') + end + + url || object['id'] + end + + aasm do + # Todavía no hay una decisión sobre el objeto + state :paused, initial: true + # Le usuarie aprobó el objeto + state :approved + # Le usuarie rechazó el objeto + state :rejected + # Le usuarie reportó el objeto + state :reported + # Le actore eliminó el objeto + state :removed + + # Gestionar todos los errores + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, + data: { site: site.name, activity_pub: id, activity: activities.first.uri }) + end + + # Se puede volver a pausa en caso de actualización remota, para + # revisar los cambios. + event :pause do + transitions to: :paused + end + + # Recibir una acción de eliminación, eliminar el contenido de la + # base de datos. Esto elimina el contenido para todos los sitios + # porque estamos respetando lo que pidió le actore. + event :remove do + transitions to: :removed + + after do + next if object.blank? + + object.update(content: {}) unless object.content.empty? + end + end + + # La actividad se aprueba, informándole a la Social Inbox que está + # aprobada. También recibimos la aprobación via + # webhook a modo de confirmación. + event :approve do + transitions from: %i[paused], to: :approved + + after do + ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :accept) + end + end + + # La actividad fue rechazada + event :reject do + transitions from: %i[paused approved], to: :rejected + + after do + ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :reject) + end + end + + # Reportarla implica rechazarla + event :report do + transitions from: %i[paused approved rejected], to: :reported + + after do + ActivityPub::InboxJob.perform_later(site: site, activity: activities.first.uri, action: :reject) + ActivityPub::RemoteFlagJob.perform_later(remote_flag: remote_flag) if remote_flag.waiting? + end + end + end + + # Definir eventos en masa + include AasmEventsConcern +end diff --git a/app/models/activity_pub/activity.rb b/app/models/activity_pub/activity.rb new file mode 100644 index 00000000..af005ff3 --- /dev/null +++ b/app/models/activity_pub/activity.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# = Activity = +# +# Lleva un registro de las actividades que nos piden hacer remotamente. +# +# Las actividades pueden tener distintos destinataries (sitios/actores). +# +# @todo Obtener el contenido del objeto dinámicamente si no existe +# localmente, por ejemplo cuando la actividad crea un objeto pero lo +# envía como referencia en lugar de anidarlo. +# +# @see {https://www.w3.org/TR/activitypub/#client-to-server-interactions} +class ActivityPub + class Activity < ApplicationRecord + include ActivityPub::Concerns::JsonLdConcern + + belongs_to :activity_pub + belongs_to :actor, touch: true + has_one :object, through: :activity_pub + + validates :activity_pub_id, presence: true + # Las actividades son únicas con respecto a su estado + validates :uri, presence: true, url: true, uniqueness: { scope: :activity_pub_id, message: 'estado duplicado' } + + # Siempre en orden descendiente para saber el último estado + default_scope -> { order(created_at: :desc) } + + # Cambia la máquina de estados según el tipo de actividad + def update_activity_pub_state! + nil + end + end +end diff --git a/app/models/activity_pub/activity/announce.rb b/app/models/activity_pub/activity/announce.rb new file mode 100644 index 00000000..8ca58906 --- /dev/null +++ b/app/models/activity_pub/activity/announce.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + # Boost + class Announce < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/activity/create.rb b/app/models/activity_pub/activity/create.rb new file mode 100644 index 00000000..9cd32559 --- /dev/null +++ b/app/models/activity_pub/activity/create.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + class Create < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/activity/delete.rb b/app/models/activity_pub/activity/delete.rb new file mode 100644 index 00000000..640c7ce9 --- /dev/null +++ b/app/models/activity_pub/activity/delete.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + class Delete < ActivityPub::Activity + # Los Delete se refieren a objetos. Al eliminar un objeto, + # cancelamos todas las actividades que tienen relacionadas. + # + # XXX: La actividad tiene una firma, pero la implementación no + # está recomendada + # + # @todo Validar que le Actor corresponda con los objetos. Esto ya + # lo haría la Social Inbox por nosotres. + # @see {https://docs.joinmastodon.org/spec/security/#ld} + def update_activity_pub_state! + ActiveRecord::Base.connection_pool.with_connection do + ActivityPub.transaction do + object = ActivityPub::Object.find_by(uri: ActivityPub.uri_from_object(content['object'])) + + if object.present? + object.activity_pubs.find_each do |activity_pub| + activity_pub.remove! if activity_pub.may_remove? + end + + # Encontrar todas las acciones de moderación de le actore + # eliminade y moverlas a eliminar. + if (actor = ActivityPub::Actor.find_by(uri: object.uri)).present? + ActorModeration.where(actor_id: actor.id).remove_all! + end + end + + activity_pub.remove! if activity_pub.may_remove? + end + end + end + end + end +end diff --git a/app/models/activity_pub/activity/flag.rb b/app/models/activity_pub/activity/flag.rb new file mode 100644 index 00000000..ffbc374b --- /dev/null +++ b/app/models/activity_pub/activity/flag.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + class Flag < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/activity/follow.rb b/app/models/activity_pub/activity/follow.rb new file mode 100644 index 00000000..b4c34d7a --- /dev/null +++ b/app/models/activity_pub/activity/follow.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# = Follow = +# +# Una actividad de seguimiento se refiere siempre a une actore (el +# sitio) y proviene de otre actore. +# +# Por ahora las solicitudes de seguimiento se auto-aprueban. +class ActivityPub + class Activity + class Follow < ActivityPub::Activity + # Auto-aprobar la solicitud de seguimiento + def update_activity_pub_state! + activity_pub.approve! if activity_pub.may_approve? + end + end + end +end diff --git a/app/models/activity_pub/activity/generic.rb b/app/models/activity_pub/activity/generic.rb new file mode 100644 index 00000000..95fff3eb --- /dev/null +++ b/app/models/activity_pub/activity/generic.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + class Generic < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/activity/like.rb b/app/models/activity_pub/activity/like.rb new file mode 100644 index 00000000..531cc32c --- /dev/null +++ b/app/models/activity_pub/activity/like.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + # Like + class Like < ActivityPub::Activity; end + end +end diff --git a/app/models/activity_pub/activity/undo.rb b/app/models/activity_pub/activity/undo.rb new file mode 100644 index 00000000..ae78a0d3 --- /dev/null +++ b/app/models/activity_pub/activity/undo.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# = Undo = +# +# Deshace una actividad, dependiendo de la actividad a la que se +# refiere. +class ActivityPub + class Activity + class Undo < ActivityPub::Activity + # Una actividad de deshacer tiene anidada como objeto la actividad + # a deshacer. Para respetar la voluntad de le actore remote, + # tendríamos que eliminar cualquier actividad pendiente sobre el + # objeto. + # + # Sin embargo, estas acciones nunca deberían llegar a nuestra + # Inbox. + # + # @todo Validar que le Actor corresponda con los objetos. Esto ya + # lo haría la Social Inbox por nosotres. + # @see {https://github.com/hyphacoop/social.distributed.press/issues/43} + def update_activity_pub_state! + ActivityPub.transaction do + ActivityPub::Activity.find_by(uri: content['object'])&.activity_pub&.remove! + activity_pub.remove! + end + end + end + end +end diff --git a/app/models/activity_pub/activity/update.rb b/app/models/activity_pub/activity/update.rb new file mode 100644 index 00000000..19c95b68 --- /dev/null +++ b/app/models/activity_pub/activity/update.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ActivityPub + class Activity + class Update < ActivityPub::Activity + # Si estamos actualizando el objeto, tenemos que devolverlo a estado + # de moderación + def update_activity_pub_state! + activity_pub.pause! if activity_pub.approved? + end + end + end +end diff --git a/app/models/activity_pub/actor.rb b/app/models/activity_pub/actor.rb new file mode 100644 index 00000000..6a284025 --- /dev/null +++ b/app/models/activity_pub/actor.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# = Actor = +# +# Actor es la entidad que realiza acciones en ActivityPub +# +# @todo Obtener el perfil dinámicamente +class ActivityPub + class Actor < ApplicationRecord + include ActivityPub::Concerns::JsonLdConcern + + belongs_to :instance + has_many :actor_moderation + has_many :activity_pubs, as: :object + has_many :activities + has_many :remote_flags + + # Les actores son únicxs a toda la base de datos + validates :uri, presence: true, url: true, uniqueness: true + + before_save :mentionize! + + # Obtiene el nombre de la Actor como mención, solo si obtuvimos el + # contenido de antemano. + # + # @return [String, nil] + def mentionize! + return if mention.present? + return if content['preferredUsername'].blank? + return if instance.blank? + + self.mention ||= "@#{content['preferredUsername']}@#{instance.hostname}" + end + + def object + @object ||= ActivityPub::Object.lock.find_or_create_by(uri: uri) + end + + def content + object.content + end + end +end diff --git a/app/models/activity_pub/concerns/json_ld_concern.rb b/app/models/activity_pub/concerns/json_ld_concern.rb new file mode 100644 index 00000000..282027df --- /dev/null +++ b/app/models/activity_pub/concerns/json_ld_concern.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class ActivityPub + module Concerns + module JsonLdConcern + extend ActiveSupport::Concern + + included do + # Cuando asignamos contenido, obtener la URI si no lo hicimos ya + before_save :uri_from_content!, unless: :uri? + + # Obtiene un tipo de actividad a partir del tipo informado + # + # @param object [Hash] + # @return [Activity] + def self.type_from(object) + raise NameError unless object.is_a?(Hash) + + "#{model_name.name}::#{object[:type].presence || 'Generic'}".constantize + rescue NameError + model_name.name.constantize::Generic + end + + private + + def uri_from_content! + self.uri = content[:id] + end + end + end + end +end diff --git a/app/models/activity_pub/fediblock.rb b/app/models/activity_pub/fediblock.rb new file mode 100644 index 00000000..e66e6e60 --- /dev/null +++ b/app/models/activity_pub/fediblock.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'httparty' + +# Listas de bloqueo y sus URLs de descarga +class ActivityPub + class Fediblock < ApplicationRecord + class Client + include ::HTTParty + + # @param url [String] + # @return [HTTParty::Response] + def get(url) + self.class.get(url, parser: csv_parser) + end + + # Procesa el CSV + # + # @return [Proc] + def csv_parser + @csv_parser ||= + begin + require 'csv' + + proc do |body, _| + CSV.parse(body, headers: true) + end + end + end + end + + class FediblockDownloadError < ::StandardError; end + + validates_presence_of :title, :url, :format + validates_inclusion_of :format, in: %w[mastodon fediblock none] + + HOSTNAME_HEADERS = { + 'mastodon' => '#domain', + 'fediblock' => 'domain' + }.freeze + + def client + @client ||= Client.new + end + + # Todas las instancias de este fediblock + def instances + ActivityPub::Instance.where(hostname: hostnames) + end + + # Descarga la lista y crea las instancias con el estado necesario + def process! + response = client.get(download_url) + + raise FediblockDownloadError unless response.success? + + Fediblock.transaction do + csv = response.parsed_response + process_csv! csv + + update(hostnames: csv.map { |r| r[hostname_header] }) + end + end + + private + + def hostname_header + HOSTNAME_HEADERS[format] + end + + # Crea o encuentra instancias que ya existían y las bloquea + # + # @param csv [CSV::Table] + def process_csv!(csv) + csv.each do |row| + ActivityPub::Instance.find_or_create_by(hostname: row[hostname_header]).tap do |i| + i.block! if i.may_block? + end + end + end + end +end diff --git a/app/models/activity_pub/instance.rb b/app/models/activity_pub/instance.rb new file mode 100644 index 00000000..cd14ef23 --- /dev/null +++ b/app/models/activity_pub/instance.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +# = Instance = +# +# Representa cada instancia del fediverso que interactúa con la Social +# Inbox. +class ActivityPub + class Instance < ApplicationRecord + include AASM + + validates :aasm_state, presence: true, inclusion: { in: %w[paused allowed blocked] } + validates :hostname, uniqueness: true, hostname: { allow_numeric_hostname: true } + + has_many :activity_pubs + has_many :actors + has_many :instance_moderations + + # XXX: Mantenemos esto por si queremos bloquear una instancia a + # nivel general + aasm do + state :paused, initial: true + state :allowed + state :blocked + + # Al pasar una instancia a bloqueo, quiere decir que todos los + # sitios adoptan esta lista + event :block do + transitions from: %i[paused allowed], to: :blocked + end + end + + def list_name + "@*@#{hostname}" + end + + def uri + @uri ||= "https://#{hostname}/" + end + end +end diff --git a/app/models/activity_pub/object.rb b/app/models/activity_pub/object.rb new file mode 100644 index 00000000..b10b4431 --- /dev/null +++ b/app/models/activity_pub/object.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Almacena objetos de ActivityPub, como Note, Article, etc. +class ActivityPub + class Object < ApplicationRecord + include ActivityPub::Concerns::JsonLdConcern + + before_validation :type_from_content!, unless: :type? + + # Los objetos son únicos a toda la base de datos + validates :uri, presence: true, url: true, uniqueness: true + validate :uri_is_content_id?, if: :content? + + has_many :activity_pubs, as: :object + + # Encontrar le Actor por su relación con el objeto + # + # @return [ActivityPub::Actor,nil] + def actor + ActivityPub::Actor.find_by(uri: actor_uri) + end + + # @return [String] + def actor_uri + content['attributedTo'] + end + + def actor_type? + false + end + + def object_type? + true + end + + # Poder explorar propiedades remotas + # + # @return [DistributedPress::V1::Social::ReferencedObject] + def referenced(site) + require 'distributed_press/v1/social/referenced_object' + + @referenced ||= DistributedPress::V1::Social::ReferencedObject.new(object: content, + dereferencer: site.social_inbox.dereferencer) + end + + private + + def uri_is_content_id? + return if uri == content['id'] + + errors.add(:activity_pub_objects, 'El ID del objeto no coincide con su URI') + end + + # Encuentra el tipo a partir del contenido, si existe. + # + # XXX: Si el objeto es una actividad, esto siempre va a ser + # Generic + def type_from_content! + self.type = + begin + "ActivityPub::Object::#{content['type'].presence || 'Generic'}".constantize + rescue NameError + ActivityPub::Object::Generic + end + end + end +end diff --git a/app/models/activity_pub/object/application.rb b/app/models/activity_pub/object/application.rb new file mode 100644 index 00000000..d26a7757 --- /dev/null +++ b/app/models/activity_pub/object/application.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# = Application = +# +# Una aplicación o instancia +class ActivityPub + class Object + class Application < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/article.rb b/app/models/activity_pub/object/article.rb new file mode 100644 index 00000000..126ba3f1 --- /dev/null +++ b/app/models/activity_pub/object/article.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Article = +# +# Representa artículos +class ActivityPub + class Object + class Article < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/audio.rb b/app/models/activity_pub/object/audio.rb new file mode 100644 index 00000000..48caea44 --- /dev/null +++ b/app/models/activity_pub/object/audio.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Audio = +# +# Representa artículos +class ActivityPub + class Object + class Audio < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/concerns/actor_type_concern.rb b/app/models/activity_pub/object/concerns/actor_type_concern.rb new file mode 100644 index 00000000..b2a643c7 --- /dev/null +++ b/app/models/activity_pub/object/concerns/actor_type_concern.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class ActivityPub + class Object + module Concerns + module ActorTypeConcern + extend ActiveSupport::Concern + + included do + # La URI de le Actor en este caso es la misma id + # + # @return [String] + def actor_uri + uri + end + + # El objeto referencia a une Actor + # + # @see {https://www.w3.org/TR/activitystreams-vocabulary/#actor-types} + def actor_type? + true + end + + # El objeto es un objeto + # + # @see {https://www.w3.org/TR/activitystreams-vocabulary/#object-types} + def object_type? + false + end + end + end + end + end +end diff --git a/app/models/activity_pub/object/document.rb b/app/models/activity_pub/object/document.rb new file mode 100644 index 00000000..d7444514 --- /dev/null +++ b/app/models/activity_pub/object/document.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Document = +# +# Representa artículos +class ActivityPub + class Object + class Document < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/event.rb b/app/models/activity_pub/object/event.rb new file mode 100644 index 00000000..9fa1f6fc --- /dev/null +++ b/app/models/activity_pub/object/event.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Event = +# +# Representa artículos +class ActivityPub + class Object + class Event < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/generic.rb b/app/models/activity_pub/object/generic.rb new file mode 100644 index 00000000..3e5ff719 --- /dev/null +++ b/app/models/activity_pub/object/generic.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# = Generic = +class ActivityPub + class Object + class Generic < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/group.rb b/app/models/activity_pub/object/group.rb new file mode 100644 index 00000000..08d11d0d --- /dev/null +++ b/app/models/activity_pub/object/group.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Group = +class ActivityPub + class Object + class Group < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/image.rb b/app/models/activity_pub/object/image.rb new file mode 100644 index 00000000..9939a14b --- /dev/null +++ b/app/models/activity_pub/object/image.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Image = +# +# Representa artículos +class ActivityPub + class Object + class Image < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/note.rb b/app/models/activity_pub/object/note.rb new file mode 100644 index 00000000..ca113c15 --- /dev/null +++ b/app/models/activity_pub/object/note.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Note = +# +# Representa notas, el tipo más común de objeto del Fediverso. +class ActivityPub + class Object + class Note < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/organization.rb b/app/models/activity_pub/object/organization.rb new file mode 100644 index 00000000..e820c305 --- /dev/null +++ b/app/models/activity_pub/object/organization.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# = Organization = +# +# Una organización +class ActivityPub + class Object + class Organization < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/page.rb b/app/models/activity_pub/object/page.rb new file mode 100644 index 00000000..f05503e2 --- /dev/null +++ b/app/models/activity_pub/object/page.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Page = +# +# Representa artículos +class ActivityPub + class Object + class Page < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/person.rb b/app/models/activity_pub/object/person.rb new file mode 100644 index 00000000..5bcab596 --- /dev/null +++ b/app/models/activity_pub/object/person.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# = Person = +# +# Una persona, el perfil de une actore +class ActivityPub + class Object + class Person < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/place.rb b/app/models/activity_pub/object/place.rb new file mode 100644 index 00000000..f04032ed --- /dev/null +++ b/app/models/activity_pub/object/place.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Place = +# +# Representa artículos +class ActivityPub + class Object + class Place < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/profile.rb b/app/models/activity_pub/object/profile.rb new file mode 100644 index 00000000..8f7183a2 --- /dev/null +++ b/app/models/activity_pub/object/profile.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Profile = +# +# Representa artículos +class ActivityPub + class Object + class Profile < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/relationship.rb b/app/models/activity_pub/object/relationship.rb new file mode 100644 index 00000000..ece995b4 --- /dev/null +++ b/app/models/activity_pub/object/relationship.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Relationship = +# +# Representa artículos +class ActivityPub + class Object + class Relationship < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/service.rb b/app/models/activity_pub/object/service.rb new file mode 100644 index 00000000..a276ea5b --- /dev/null +++ b/app/models/activity_pub/object/service.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Service = +class ActivityPub + class Object + class Service < ActivityPub::Object + include Concerns::ActorTypeConcern + end + end +end diff --git a/app/models/activity_pub/object/tombstone.rb b/app/models/activity_pub/object/tombstone.rb new file mode 100644 index 00000000..88f136b9 --- /dev/null +++ b/app/models/activity_pub/object/tombstone.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Tombstone = +# +# Representa artículos +class ActivityPub + class Object + class Tombstone < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/object/video.rb b/app/models/activity_pub/object/video.rb new file mode 100644 index 00000000..fa4bbffb --- /dev/null +++ b/app/models/activity_pub/object/video.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# = Video = +# +# Representa artículos +class ActivityPub + class Object + class Video < ActivityPub::Object; end + end +end diff --git a/app/models/activity_pub/remote_flag.rb b/app/models/activity_pub/remote_flag.rb new file mode 100644 index 00000000..d6348650 --- /dev/null +++ b/app/models/activity_pub/remote_flag.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class ActivityPub + class RemoteFlag < ApplicationRecord + IGNORED_EVENTS = [].freeze + IGNORED_STATES = [].freeze + + include AASM + + aasm do + state :waiting, initial: true + state :queued + state :sent + + event :queue do + transitions from: :waiting, to: :queued + end + + event :report do + transitions from: :queued, to: :sent + end + + event :resend do + transitions from: :sent, to: :waiting + end + end + + # Definir eventos en masa + include AasmEventsConcern + + belongs_to :actor + belongs_to :site + + has_one :actor_moderation + has_many :activity_pubs + # XXX: source_type es obligatorio para el `through` + has_many :objects, through: :activity_pubs, source_type: 'ActivityPub::Object::Note' + + # Genera la actividad a enviar + def content + { + '@context' => 'https://www.w3.org/ns/activitystreams', + 'id' => Rails.application.routes.url_helpers.v1_activity_pub_remote_flag_url(self, + host: site.social_inbox_hostname), + 'type' => 'Flag', + 'actor' => main_site.social_inbox.actor_id, + 'content' => message.to_s, + 'object' => [actor.uri] + objects.pluck(:uri) + } + end + + # Este es el sitio principal que actúa como origen del reporte. + # Tiene que tener la Social Inbox habilitada al mismo tiempo. + # + # @return [Site] + def main_site + @main_site ||= Site.find(ENV.fetch('PANEL_ACTOR_SITE_ID', 1)) + end + end +end diff --git a/app/models/actor_moderation.rb b/app/models/actor_moderation.rb new file mode 100644 index 00000000..1c3cf83a --- /dev/null +++ b/app/models/actor_moderation.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# Mantiene la relación entre Site y Actor +class ActorModeration < ApplicationRecord + IGNORED_EVENTS = %i[remove].freeze + IGNORED_STATES = %i[removed].freeze + + include AASM + + belongs_to :site + belongs_to :remote_flag, optional: true, class_name: 'ActivityPub::RemoteFlag' + belongs_to :actor, class_name: 'ActivityPub::Actor' + + accepts_nested_attributes_for :remote_flag + + aasm do + state :paused, initial: true + state :allowed + state :blocked + state :reported + state :removed + + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, data: { site: site.name, actor: actor.uri, actor_moderation: id }) + end + + event :pause do + transitions from: %i[allowed blocked reported], to: :paused, after: :synchronize! + end + + # Al permitir una cuenta no se permiten todos los comentarios + # pendientes de moderación que ya hizo. + event :allow do + transitions from: %i[paused blocked reported], to: :allowed, after: :synchronize! + end + + # Al bloquear una cuenta no se bloquean todos los comentarios + # pendientes de moderación que hizo. + event :block do + transitions from: %i[paused allowed], to: :blocked, after: :synchronize! + end + + # Al reportar, necesitamos asociar una RemoteFlag para poder + # enviarla. + event :report do + transitions from: %i[pause allowed blocked], to: :reported, after: :synchronize! + + after do + ActivityPub::RemoteFlagJob.perform_later(remote_flag: remote_flag) if remote_flag.waiting? + end + end + + # Si un perfil es eliminado remotamente, tenemos que dejar de + # mostrarlo y todas sus actividades. + event :remove do + transitions to: :removed + + after do + site.activity_pubs.where(actor_id: actor_id).remove_all! + end + end + end + + # Definir eventos en masa + include AasmEventsConcern + + def synchronize! + ActivityPub::SyncListsJob.perform_later(site: site) + end +end diff --git a/app/models/concerns/aasm_events_concern.rb b/app/models/concerns/aasm_events_concern.rb new file mode 100644 index 00000000..788e9e1a --- /dev/null +++ b/app/models/concerns/aasm_events_concern.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module AasmEventsConcern + extend ActiveSupport::Concern + + included do + # Todos los eventos de la máquina de estados + # + # @return [Array] + def self.events + aasm.events.map(&:name) - self::IGNORED_EVENTS + end + + # Encuentra todos los eventos que se pueden ejecutar con el filtro + # actual. + # + # @return [Array] + def self.transitionable_events(current_state) + events.select do |event| + aasm.events.find { |x| x.name == event }.transitions_from_state? current_state + end + end + + # Todos los estados de la máquina de estados + # + # @return [Array] + def self.states + aasm.states.map(&:name) - self::IGNORED_STATES + end + + # Define un método que cambia el estado para todos los objetos del + # scope actual. + # + # @return [Bool] Si hubo al menos un error, devuelve false. + aasm.events.map(&:name).each do |event| + define_singleton_method(:"#{event}_all!") do + successes = [] + + find_each do |object| + successes << (object.public_send(:"may_#{event}?") && object.public_send(:"#{event}!")) + end + + successes.all? + end + + # Ejecuta la transición del evento en la base de datos sin + # ejecutar los callbacks, sin modificar los items del scope que no + # pueden transicionar. + # + # @return [Integer] Registros modificados + define_singleton_method(:"#{event}_all_without_callbacks!") do + aasm_event = aasm.events.find { |e| e.name == event } + to_state = aasm_event.transitions.map(&:to).first + from_states = aasm_event.transitions.map(&:from) + + unscope(where: :aasm_state).where(aasm_state: from_states).update_all(aasm_state: to_state, + updated_at: Time.now) + end + end + end +end diff --git a/app/models/concerns/tienda.rb b/app/models/concerns/tienda.rb index cd09358e..a3e6007a 100644 --- a/app/models/concerns/tienda.rb +++ b/app/models/concerns/tienda.rb @@ -5,7 +5,7 @@ module Tienda extend ActiveSupport::Concern included do - encrypts :tienda_api_key + has_encrypted :tienda_api_key def tienda? tienda_api_key.present? && tienda_url.present? @@ -17,7 +17,7 @@ module Tienda return t if new_record? - t.blank? ? 'https://' + name + '.' + ENV.fetch('TIENDA', 'tienda.sutty.nl') : t + t.blank? ? "https://#{name}.#{ENV.fetch('TIENDA', 'tienda.sutty.nl')}" : t end end end diff --git a/app/models/deploy.rb b/app/models/deploy.rb index 1f087eb3..77646034 100644 --- a/app/models/deploy.rb +++ b/app/models/deploy.rb @@ -10,10 +10,12 @@ require 'open3' # :attributes`. class Deploy < ApplicationRecord belongs_to :site + belongs_to :rol + has_many :build_stats, dependent: :destroy - DEPENDENCIES = [] - SOFT_DEPENDENCIES = [] + DEPENDENCIES = [].freeze + SOFT_DEPENDENCIES = [].freeze def deploy(**) raise NotImplementedError @@ -72,7 +74,7 @@ class Deploy < ApplicationRecord 'HOME' => home_dir, 'PATH' => paths.join(':'), 'JEKYLL_ENV' => Rails.env, - 'LANG' => ENV['LANG'], + 'LANG' => ENV.fetch('LANG', nil) }) end @@ -137,7 +139,7 @@ class Deploy < ApplicationRecord # provisto con el archivo como parámetro # # @param :content [String] - def with_tempfile(content, &block) + def with_tempfile(content) Tempfile.create(SecureRandom.hex) do |file| file.write content.to_s file.rewind diff --git a/app/models/deploy_social_distributed_press.rb b/app/models/deploy_social_distributed_press.rb index db555ab7..e7f97406 100644 --- a/app/models/deploy_social_distributed_press.rb +++ b/app/models/deploy_social_distributed_press.rb @@ -5,7 +5,7 @@ require 'distributed_press/v1/social/client' # Publicar novedades al Fediverso class DeploySocialDistributedPress < Deploy # Solo luego de publicar remotamente - DEPENDENCIES = %i[deploy_distributed_press deploy_rsync deploy_full_rsync] + DEPENDENCIES = %i[deploy_distributed_press deploy_rsync deploy_full_rsync].freeze # Envía las notificaciones def deploy(output: false) @@ -13,7 +13,10 @@ class DeploySocialDistributedPress < Deploy key = Shellwords.escape file.path dest = Shellwords.escape destination - run %(bundle exec jekyll notify --trace --key #{key} --destination "#{dest}"), output: output + run(%(bundle exec jekyll notify --trace --key #{key} --destination "#{dest}"), output: output).tap do |_| + create_hooks! + enable_fediblocks! + end end end @@ -52,4 +55,50 @@ class DeploySocialDistributedPress < Deploy def flags_for_build(**args) "--key #{Shellwords.escape args[:private_key].path}" end + + private + + # Crea los hooks en la Social Inbox para que nos avise de actividades + # nuevas + # + # @return [nil] + def create_hooks! + hook_client = site.social_inbox.hook + webhook_class = DistributedPress::V1::Social::Schemas::Webhook + + hook_client.class::EVENTS.each do |event| + event_url = :"v1_site_webhooks_#{event}_url" + + webhook = + webhook_class.new.call({ + method: 'POST', + url: Rails.application.routes.url_helpers.public_send( + event_url, site_id: site.name, host: site.social_inbox_hostname + ), + headers: { + 'X-Social-Inbox': rol.token + } + }) + + raise ArgumentError, webhook.errors.messages if webhook.failure? + + response = hook_client.put(event: event, hook: webhook) + + raise ArgumentError, response.body unless response.success? + rescue ArgumentError => e + ExceptionNotifier.notify_exception(e, data: { site_id: site.name, usuarie_id: rol.usuarie_id }) + end + end + + # Habilita todos los fediblocks disponibles. + # + # @todo Hacer que algunos sean opcionales + # @todo Mover a un Job + def enable_fediblocks! + ActivityPub::Fediblock.find_each do |fediblock| + site.fediblock_states.find_or_create_by(fediblock: fediblock).tap do |state| + state.enable! if state.may_enable? + end + end + end end diff --git a/app/models/fediblock_state.rb b/app/models/fediblock_state.rb new file mode 100644 index 00000000..02dee2d8 --- /dev/null +++ b/app/models/fediblock_state.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +# Relación entre Fediblocks y Sites. +# +# Cuando se habilita un Fediblock, tenemos que asociar todas sus +# instancias con el sitio y bloquearlas. Cuando se deshabilita, la +# relación ya está creada y se va actualizando. +# +# @see ActivityPub::FediblockUpdatedJob +class FediblockState < ApplicationRecord + include AASM + + belongs_to :site + belongs_to :fediblock, class_name: 'ActivityPub::Fediblock' + + # El efecto secundario de esta máquina de estados es modificar el + # estado de moderación de cada instancia en el sitio. Nos salteamos + # los hooks de los eventos individuales. + aasm do + # Aunque queramos las listas habilitadas por defecto, tenemos que + # habilitarlas luego de crearlas para poder generar la lista de + # bloqueo en la Social Inbox. + state :disabled, initial: true, before_enter: :pause_unique_instances! + state :enabled, before_enter: :block_instances! + + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, data: { site: site.name, fediblock: id }) + end + + event :enable do + transitions from: :disabled, to: :enabled + end + + # Al deshabilitar, las listas pasan a modo pausa, a menos que estén + # activas en otros listados. + # + # @todo No cambiar el estado si se habían habilitado manualmente, + # pero esto implica que tenemos que encontrar las que sí y quitarlas + # de list_names + event :disable do + transitions from: :enabled, to: :disabled, after: :synchronize! + end + end + + private + + def block_instances! + ActivityPub::InstanceModerationJob.perform_later(site: site, hostnames: fediblock.hostnames, + perform_remotely: false) + end + + # Pausar todas las moderaciones de las instancias que no estén + # bloqueadas por otros fediblocks. + def pause_unique_instances! + instance_ids = ActivityPub::Instance.where(hostname: unique_hostnames).ids + site.instance_moderations.where(instance_id: instance_ids).pause_all_without_callbacks! + end + + def synchronize! + ActivityPub::SyncListsJob.perform_later(site: site) + end + + # Devuelve los hostnames únicos a esta instancia. + # + # @return [Array] + def unique_hostnames + @unique_hostnames ||= + begin + other_enabled_fediblock_ids = + site.fediblock_states.enabled.where.not(id: id).pluck(:fediblock_id) + other_enabled_hostnames = + ActivityPub::Fediblock + .where(id: other_enabled_fediblock_ids) + .pluck(:hostnames) + .flatten + .uniq + + fediblock.hostnames - other_enabled_hostnames + end + end +end diff --git a/app/models/instance_moderation.rb b/app/models/instance_moderation.rb new file mode 100644 index 00000000..c1192615 --- /dev/null +++ b/app/models/instance_moderation.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +# Mantiene el registro de relaciones entre sitios e instancias +class InstanceModeration < ApplicationRecord + IGNORED_EVENTS = [].freeze + IGNORED_STATES = [].freeze + + include AASM + + belongs_to :site + belongs_to :instance, class_name: 'ActivityPub::Instance' + + aasm do + state :paused, initial: true + state :allowed + state :blocked + + error_on_all_events do |e| + ExceptionNotifier.notify_exception(e, + data: { site: site.name, instance: instance.hostname, + instance_moderation: id }) + end + + after_all_events do + ActivityPub::SyncListsJob.perform_later(site: site) + end + + # Al volver la instancia a pausa no cambiamos el estado de + # moderación de actores pre-existente. + event :pause do + transitions from: %i[allowed blocked], to: :paused + end + + # Al permitir, solo bloqueamos la instancia, sin modificar el estado + # de les actores y comentarios retroactivamente. + event :allow do + transitions from: %i[paused blocked], to: :allowed + end + + # Al bloquear, solo bloqueamos la instancia, sin modificar el estado + # de les actores y comentarios retroactivamente. + event :block do + transitions from: %i[paused allowed], to: :blocked + end + end + + # Definir eventos en masa + include AasmEventsConcern +end diff --git a/app/models/metadata_template.rb b/app/models/metadata_template.rb index 823443d2..78989e15 100644 --- a/app/models/metadata_template.rb +++ b/app/models/metadata_template.rb @@ -134,7 +134,7 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, # En caso de que algún campo necesite realizar acciones antes de ser # guardado def save - if !changed? + unless changed? self[:value] = document_value if private? return true @@ -190,8 +190,8 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, sanitizer .sanitize(string.tr("\r", '').unicode_normalize, - tags: allowed_tags, - attributes: allowed_attributes) + tags: Sutty::ALLOWED_TAGS, + attributes: Sutty::ALLOWED_ATTRIBUTES) .strip .html_safe end @@ -200,16 +200,6 @@ MetadataTemplate = Struct.new(:site, :document, :name, :label, :type, @sanitizer ||= Rails::Html::Sanitizer.safe_list_sanitizer.new end - def allowed_attributes - @allowed_attributes ||= %w[style href src alt controls data-align data-multimedia data-multimedia-inner id - name rel target referrerpolicy class colspan rowspan role data-turbo start type reversed].freeze - end - - def allowed_tags - @allowed_tags ||= %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure blockquote - figcaption a sub sup small table thead tbody tfoot tr th td br code].freeze - end - # Decifra el valor # # XXX: Otros tipos de valores necesitan implementar su propio método diff --git a/app/models/moderation_queue.rb b/app/models/moderation_queue.rb new file mode 100644 index 00000000..31ca3c9b --- /dev/null +++ b/app/models/moderation_queue.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +ModerationQueue = Struct.new(:site) diff --git a/app/models/que_job.rb b/app/models/que_job.rb new file mode 100644 index 00000000..0bfffc92 --- /dev/null +++ b/app/models/que_job.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require 'que/active_record/model' + +class QueJob < Que::ActiveRecord::Model; end diff --git a/app/models/rol.rb b/app/models/rol.rb index 37332400..c9a92515 100644 --- a/app/models/rol.rb +++ b/app/models/rol.rb @@ -11,6 +11,7 @@ class Rol < ApplicationRecord belongs_to :usuarie belongs_to :site + has_many :deploys validates_inclusion_of :rol, in: ROLES diff --git a/app/models/site.rb b/app/models/site.rb index f51d2c6f..8c02f91b 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -19,7 +19,7 @@ class Site < ApplicationRecord # tiene acceso pero los datos se guardan cifrados en el sitio. Esto # protege información privada en repositorios públicos, pero no la # protege de acceso al panel de Sutty! - encrypts :private_key + has_encrypted :private_key validates :name, uniqueness: true, hostname: { allow_root_label: true diff --git a/app/models/site/api.rb b/app/models/site/api.rb index 73f8e710..6c6f0ece 100644 --- a/app/models/site/api.rb +++ b/app/models/site/api.rb @@ -5,7 +5,7 @@ class Site extend ActiveSupport::Concern included do - encrypts :api_key + has_encrypted :api_key before_save :add_api_key_if_missing! # Genera mensajes secretos que podemos usar para la API de cada diff --git a/app/models/site/social_distributed_press.rb b/app/models/site/social_distributed_press.rb index 3be6404e..8d8d60d4 100644 --- a/app/models/site/social_distributed_press.rb +++ b/app/models/site/social_distributed_press.rb @@ -1,22 +1,69 @@ # frozen_string_literal: true +require 'distributed_press/v1/social/client' + class Site # Agrega soporte para Social Distributed Press en los sitios module SocialDistributedPress extend ActiveSupport::Concern included do - encrypts :private_key_pem + has_encrypted :private_key_pem + + has_many :activity_pubs + has_many :instance_moderations + has_many :actor_moderations + has_many :fediblock_states + has_many :instances, through: :instance_moderations + has_many :remote_flags, class_name: 'ActivityPub::RemoteFlag' before_save :generate_private_key_pem!, unless: :private_key_pem? + def moderation_enabled? + deploy_social_inbox.present? + end + + def deploy_social_inbox + @deploy_social_inbox ||= deploys.find_by(type: 'DeploySocialDistributedPress') + end + + def moderation_checked! + deploy_social_inbox.touch + end + + # @return [Bool] + def moderation_needed? + return false unless moderation_enabled? + + last_activity_pub = activity_pubs.order(updated_at: :desc).first&.updated_at + + return false if last_activity_pub.blank? + + last_activity_pub > deploy_social_inbox.updated_at + end + + # @return [SocialInbox] + def social_inbox + @social_inbox ||= SocialInbox.new(site: self) + end + + # Obtiene el hostname de la API de Sutty + # + # @return [String] + def social_inbox_hostname + Rails.application.routes.default_url_options[:host].sub('panel', 'api') + end + private # Genera la llave privada y la almacena # # @return [nil] def generate_private_key_pem! - self.private_key_pem ||= ::DistributedPress::V1::Social::Client.new(public_key_url: nil, key_size: 2048).private_key.export + self.private_key_pem ||= DistributedPress::V1::Social::Client.new( + public_key_url: nil, + key_size: 2048 + ).private_key.export end end end diff --git a/app/models/social_inbox.rb b/app/models/social_inbox.rb new file mode 100644 index 00000000..adeedffc --- /dev/null +++ b/app/models/social_inbox.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'distributed_press/v1/social/client' +require 'distributed_press/v1/social/allowlist' +require 'distributed_press/v1/social/blocklist' +require 'distributed_press/v1/social/hook' +require 'distributed_press/v1/social/inbox' +require 'distributed_press/v1/social/dereferencer' +require 'httparty/cache/store/redis' + +# Gestiona la Social Inbox de un sitio +class SocialInbox + # @return [Site] + attr_reader :site + + # @param :site [Site] + def initialize(site:) + @site = site + end + + # @return [String] + def actor + @actor ||= + begin + user = site.config.dig('activity_pub', 'username') + user ||= hostname.split('.', 2).first + + "@#{user}@#{hostname}" + end + end + + def actor_id + @actor_id ||= SocialInbox.generate_uri(hostname) do |uri| + uri.path = '/about.jsonld' + end + end + + # @return [DistributedPress::V1::Social::Client] + def client + @client ||= client_for site.config.dig('activity_pub', 'url') + end + + # Permite enviar mensajes directo a otro servidor + # + # @param url [String] + # @return [DistributedPress::V1::Social::Client] + def client_for(url) + raise 'Falló generar un cliente' if url.blank? + + @client_for ||= {} + @client_for[url] ||= + DistributedPress::V1::Social::Client.new( + url: url, + public_key_url: public_key_url, + private_key_pem: site.private_key_pem, + logger: Rails.logger, + cache_store: HTTParty::Cache::Store::Redis.new(redis_url: ENV.fetch('REDIS_SERVER', nil)) + ) + end + + # @return [DistributedPress::V1::Social::Inbox] + def inbox + @inbox ||= DistributedPress::V1::Social::Inbox.new(client: client, actor: actor) + end + + # @return [DistributedPress::V1::Social::Dereferencer] + def dereferencer + @dereferencer ||= DistributedPress::V1::Social::Dereferencer.new(client: client) + end + + # @return [DistributedPress::V1::Social::Hook] + def hook + @hook ||= DistributedPress::V1::Social::Hook.new(client: client, actor: actor) + end + + # @return [DistributedPress::V1::Social::Allowlist] + def allowlist + @allowlist ||= DistributedPress::V1::Social::Allowlist.new(client: client, actor: actor) + end + + # @return [DistributedPress::V1::Social::Blocklist] + def blocklist + @blocklist ||= DistributedPress::V1::Social::Blocklist.new(client: client, actor: actor) + end + + # @return [String] + def public_key_url + @public_key_url ||= SocialInbox.generate_uri(hostname) do |uri| + uri.path = '/about.jsonld' + uri.fragment = 'main-key' + end + end + + # El hostname puede estar en varios lados... + # + # @return [String] + def hostname + @hostname ||= + site.config.dig('activity_pub', 'hostname') || site.config['hostname'] || site.hostname + end + + # Genera una URI dentro de este sitio + # + # @return [String] + def self.generate_uri(hostname, &block) + URI("https://#{hostname}").tap(&block).to_s + end +end diff --git a/app/policies/activity_pub_policy.rb b/app/policies/activity_pub_policy.rb new file mode 100644 index 00000000..f5755840 --- /dev/null +++ b/app/policies/activity_pub_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Solo les usuaries pueden moderar comentarios +ActivityPubPolicy = Struct.new(:usuarie, :activity_pub) do + ActivityPub.events.each do |event| + define_method(:"#{event}?") do + activity_pub.site.usuarie? usuarie + end + end + + # En este paso tenemos varias instancias por moderar pero todas son + # del mismo sitio. + def action_on_several? + activity_pub.first.site.usuarie? usuarie + end +end diff --git a/app/policies/actor_moderation_policy.rb b/app/policies/actor_moderation_policy.rb new file mode 100644 index 00000000..07a9a752 --- /dev/null +++ b/app/policies/actor_moderation_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Solo les usuaries pueden moderar actores +ActorModerationPolicy = Struct.new(:usuarie, :actor_moderation) do + ActorModeration.events.each do |actor_event| + define_method(:"#{actor_event}?") do + actor_moderation.site.usuarie? usuarie + end + end + + # En este paso tenemos varias cuentas por moderar pero todas son + # del mismo sitio. + def action_on_several? + actor_moderation.first.site.usuarie? usuarie + end +end diff --git a/app/policies/instance_moderation_policy.rb b/app/policies/instance_moderation_policy.rb new file mode 100644 index 00000000..c6a229d3 --- /dev/null +++ b/app/policies/instance_moderation_policy.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Solo les usuaries pueden moderar instancias +InstanceModerationPolicy = Struct.new(:usuarie, :instance_moderation) do + InstanceModeration.events.each do |event| + define_method(:"#{event}?") do + instance_moderation.site.usuarie? usuarie + end + end + + # En este paso tenemos varias instancias por moderar pero todas son + # del mismo sitio. + def action_on_several? + instance_moderation.first.presence && instance_moderation.first.site.usuarie?(usuarie) + end +end diff --git a/app/policies/moderation_queue_policy.rb b/app/policies/moderation_queue_policy.rb new file mode 100644 index 00000000..75a4c45a --- /dev/null +++ b/app/policies/moderation_queue_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Si la cola de moderación está activada y le usuarie tiene permisos de +# usuarie. +ModerationQueuePolicy = Struct.new(:usuarie, :moderation_queue) do + def index? + moderation_queue.site.moderation_enabled? && moderation_queue.site.usuarie?(usuarie) + end +end diff --git a/app/processors/activity_pub_processor.rb b/app/processors/activity_pub_processor.rb new file mode 100644 index 00000000..501b73a5 --- /dev/null +++ b/app/processors/activity_pub_processor.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Gestiona los filtros de ActivityPub +class ActivityPubProcessor < Rubanok::Processor + # En orden descendiente para encontrar la última actividad + # + # Por ahora solo queremos moderar comentarios. + prepare do + raw + .joins(:activities) + .where( + activity_pub_activities: { + type: %w[ActivityPub::Activity::Create ActivityPub::Activity::Update] + }, + object_type: %w[ActivityPub::Object::Note ActivityPub::Object::Article] + ).order(updated_at: :desc) + end + + map :activity_pub_state, activate_always: true do |activity_pub_state: 'paused'| + raw.where(aasm_state: activity_pub_state) + end +end diff --git a/app/processors/actor_moderation_processor.rb b/app/processors/actor_moderation_processor.rb new file mode 100644 index 00000000..a3035654 --- /dev/null +++ b/app/processors/actor_moderation_processor.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Gestiona los filtros de ActorModeration +class ActorModerationProcessor < Rubanok::Processor + # En orden descendiente para encontrar le últime Actor + prepare do + raw.order(updated_at: :desc) + end + + map :actor_state, activate_always: true do |actor_state: 'paused'| + raw.where(aasm_state: actor_state) + end +end diff --git a/app/processors/instance_moderation_processor.rb b/app/processors/instance_moderation_processor.rb new file mode 100644 index 00000000..eb9a7c8b --- /dev/null +++ b/app/processors/instance_moderation_processor.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Gestiona los filtros de InstanceModeration +class InstanceModerationProcessor < Rubanok::Processor + prepare do + raw.includes(:instance).order('activity_pub_instances.hostname') + end + + map :instance_state, activate_always: true do |instance_state: 'paused'| + raw.where(aasm_state: instance_state) + end +end diff --git a/app/services/site_service.rb b/app/services/site_service.rb index a28e5ee8..1a85d401 100644 --- a/app/services/site_service.rb +++ b/app/services/site_service.rb @@ -13,7 +13,7 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do def create self.site = Site.new params - add_role temporal: false, rol: 'usuarie' + role = site.roles.build(usuarie: usuarie, temporal: false, rol: 'usuarie') site.deploys.build type: 'DeployLocal' # Los sitios de testing no se sincronizan sync_nodes unless site.name.end_with? '.testing' @@ -26,6 +26,8 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do # que no haya estados intermedios. site.locales = [usuarie.lang] + I18n.available_locales + add_role_to_deploys! role + site.save && site.config.write && commit_config(action: :create) && @@ -43,7 +45,10 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do # Actualiza el sitio y guarda los cambios en la configuración def update I18n.with_locale(usuarie&.lang&.to_sym || I18n.default_locale) do - site.update(params) && + site.assign_attributes(params) + add_role_to_deploys! + + site.save && site.config.write && commit_config(action: :update) && site.reset.nil? && @@ -101,11 +106,6 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do GitPushJob.perform_later(site) end - def add_role(temporal: true, rol: 'invitade') - site.roles << Rol.new(site: site, usuarie: usuarie, - temporal: temporal, rol: rol) - end - # Crea la licencia del sitio para cada locale disponible en el sitio # # @return [Boolean] @@ -222,6 +222,17 @@ SiteService = Struct.new(:site, :usuarie, :params, keyword_init: true) do end end + # Asignar un rol a cada deploy si no lo tenía ya + def add_role_to_deploys!(role = current_role) + site.deploys.each do |deploy| + deploy.rol ||= role + end + end + + def current_role + @current_role ||= usuarie.rol_for_site(site) + end + def with_all_locales site.locales.map do |locale| next unless I18n.available_locales.include? locale diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb new file mode 100644 index 00000000..291f9288 --- /dev/null +++ b/app/validators/url_validator.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# Valida URLs +# +# @see {https://storck.io/posts/better-http-url-validation-in-ruby-on-rails/} +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + if value.blank? + record.errors.add(attribute, :url_missing) + return + end + + uri = URI.parse(value) + + record.errors.add(attribute, :scheme_missing) if uri.scheme.blank? + record.errors.add(attribute, :host_missing) if uri.host.blank? + record.errors.add(attribute, :path_missing) if uri.path.blank? + rescue URI::Error + record.errors.add(attribute, :invalid) + end +end diff --git a/app/views/actor_moderations/show.haml b/app/views/actor_moderations/show.haml new file mode 100644 index 00000000..ca5764f4 --- /dev/null +++ b/app/views/actor_moderations/show.haml @@ -0,0 +1,8 @@ +.row.justify-content-center + .col-12.col-md-8 + %h1= t('.profile') + = render 'components/actor', remote_profile: @remote_profile + .col-12.col-md-8 + = render 'components/profiles_btn_box', actor_moderation: @actor_moderation + .col-12.col-md-8 + = render 'moderation_queue/comments', site: @site, moderation_queue: @moderation_queue diff --git a/app/views/components/_actor.haml b/app/views/components/_actor.haml new file mode 100644 index 00000000..c58beae0 --- /dev/null +++ b/app/views/components/_actor.haml @@ -0,0 +1,22 @@ +-# Componente Remote_Profile + +- uri = text_plain(remote_profile['id']) + +.py-2 + %dl + %dt= t('.profile_name') + %dd= text_plain remote_profile['name'] + + %dt= t('.preferred_name') + %dd= text_plain remote_profile['preferredUsername'] + + %dt= t('.profile_id') + %dd + = link_to uri, uri + + - if remote_profile['published'].present? + %dt= t('.profile_published') + %dd + = render 'layouts/time', time: text_plain(remote_profile['published']) + %dt= t('.profile_summary') + %dd= sanitize remote_profile['summary'] diff --git a/app/views/components/_block_list.haml b/app/views/components/_block_list.haml index 0c7a7b04..27e44cac 100644 --- a/app/views/components/_block_list.haml +++ b/app/views/components/_block_list.haml @@ -1,9 +1,13 @@ -# Componente Listas de bloqueo de Instancias +- know_more = t('.know_more') +- instances_blocked = t('.instances_blocked') .card.mt-3.mb-3 .card-body - .d-flex.flex-row - = render 'components/checkbox', id: blocklist['id'] do - %span.h4= blocklist["title"] + = render 'components/checkbox', id: state.id, name: 'fediblock_states_ids[]', value: state.id, checked: state.enabled? do + %span.h4.mb-0= blocklist.title - %p.mb-0 - %a{ href: blocklist['link'] }= blocklist['title'] + %dl.mb-0 + %dt.d-inline= instances_blocked + %dd.d-inline.font-weight-normal= blocklist.hostnames.count + %p.mb-0.font-weight-normal + %a{ href: blocklist.url }= know_more diff --git a/app/views/components/_block_lists.haml b/app/views/components/_block_lists.haml index 1e9cd76f..b6dc0afa 100644 --- a/app/views/components/_block_lists.haml +++ b/app/views/components/_block_lists.haml @@ -1,2 +1,2 @@ -- @blocklists.each do |blocklist| - = render 'components/block_list', blocklist: blocklist +- blocklists.each do |blocklist| + = render 'components/block_list', blocklist: blocklist.fediblock, state: blocklist diff --git a/app/views/components/_btn_base.haml b/app/views/components/_btn_base.haml index 7fa507ca..fed3254c 100644 --- a/app/views/components/_btn_base.haml +++ b/app/views/components/_btn_base.haml @@ -1,3 +1,8 @@ -# Componente Botón general Moderación -%button.btn{ href: href, class: local_assigns[:class] }= text +- local_assigns[:method] ||= 'patch' +- local_assigns[:class] = "btn #{local_assigns[:class]}" +- local_assigns.delete(:text) + += button_to(path, **local_assigns.compact) do + = text diff --git a/app/views/components/_checkbox.haml b/app/views/components/_checkbox.haml index 27f9a776..a58c85b7 100644 --- a/app/views/components/_checkbox.haml +++ b/app/views/components/_checkbox.haml @@ -1,4 +1,6 @@ -# Componente Checkbox +- local_assigns[:name] ||= id + .custom-control.custom-checkbox - %input.custom-control-input{ type: 'checkbox', id: id, name: id, class: local_assigns[:class] } + %input.custom-control-input{ type: 'checkbox', id: id, **local_assigns.compact } %label.custom-control-label{ for: id }= yield diff --git a/app/views/components/_comments_btn_box.haml b/app/views/components/_comments_btn_box.haml index 8b8d7268..578f6662 100644 --- a/app/views/components/_comments_btn_box.haml +++ b/app/views/components/_comments_btn_box.haml @@ -1,8 +1,14 @@ -# Componente Botonera de Comentarios -- btn_class = 'btn-secondary py-1 px-2' -= render 'components/btn_base', text: t('.text_pause'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_reject'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_accept'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_reply'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_report'), class: btn_class, href: '' \ No newline at end of file +- local = { reject: { data: { confirm: t('.confirm_reject') } }, report: { class: 'ml-auto', data: { confirm: t('.confirm_report') } } } + +.d-flex.flex-row + - ActivityPub.events.each do |event| + - possible = activity_pub.public_send(:"may_#{event}?") + %div{ class: local.dig(event, :class) } + = render 'components/btn_base', + text: t(".text_#{event}"), + path: public_send(:"site_activity_pub_#{event}_path", activity_pub_id: activity_pub), + class: ('btn-secondary' if possible), + disabled: !possible, + data: local.dig(event, :data) diff --git a/app/views/components/_comments_checked_submenu.haml b/app/views/components/_comments_checked_submenu.haml index 4998e5c7..d94e12a9 100644 --- a/app/views/components/_comments_checked_submenu.haml +++ b/app/views/components/_comments_checked_submenu.haml @@ -1,3 +1,9 @@ -= render 'components/dropdown_item', text: t('.submenu_pause'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_accept'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_reject'), path: '/' \ No newline at end of file +-# + @param form [String] + +- current_state = params[:activity_pub_state]&.to_sym || ActivityPub.states.first + +- ActivityPub.aasm.events.each do |event| + - next if ActivityPub::IGNORED_EVENTS.include? event.name + - next unless event.transitions_from_state?(current_state) + = render 'components/dropdown_button', form: form, text: t(".submenu_#{event.name}"), name: 'activity_pub_action', value: event.name diff --git a/app/views/components/_comments_filters.haml b/app/views/components/_comments_filters.haml index 7c453088..b2870c5a 100644 --- a/app/views/components/_comments_filters.haml +++ b/app/views/components/_comments_filters.haml @@ -1,6 +1,12 @@ -.d-flex.py-2 - = render 'components/dropdown', text: t('.text_checked') do - = render 'components/comments_checked_submenu' +-# + @params form [String] + +- current_state = params[:activity_pub_state]&.to_sym || ActivityPub.states.first + +.d-flex.flex-row.justify-content-between.py-2 + - if ActivityPub.transitionable_events(current_state).present? + = render 'components/dropdown', text: t('.text_checked') do + = render 'components/comments_checked_submenu', form: form = render 'components/dropdown', text: t('.text_show') do - = render 'components/comments_show_submenu' \ No newline at end of file + = render 'components/comments_show_submenu', activity_pubs: activity_pubs diff --git a/app/views/components/_comments_show_submenu.haml b/app/views/components/_comments_show_submenu.haml index 0308b926..9964a62a 100644 --- a/app/views/components/_comments_show_submenu.haml +++ b/app/views/components/_comments_show_submenu.haml @@ -1,4 +1,5 @@ -= render 'components/dropdown_item', text: t('.submenu_pause'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_accept'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_report'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_reject'), path: '/' \ No newline at end of file +- ActivityPub.states.each do |state| + = render 'components/dropdown_item', + text: t(".submenu_#{state}", count: activity_pubs.unscope(where: :aasm_state).public_send(state).count), + path: filter_states(activity_pub_state: state), + class: ('active' if active?(ActivityPub.states, :activity_pub_state, state)) diff --git a/app/views/components/_dropdown.haml b/app/views/components/_dropdown.haml index 54ddcffb..6f34950b 100644 --- a/app/views/components/_dropdown.haml +++ b/app/views/components/_dropdown.haml @@ -11,7 +11,7 @@ controller: 'dropdown' } } - %button.btn.dropdown-toggle{ + %button.btn.btn-outline-secondary.dropdown-toggle{ type: 'button', class: button_classes, data: { diff --git a/app/views/components/_dropdown_button.haml b/app/views/components/_dropdown_button.haml new file mode 100644 index 00000000..d6de6c8e --- /dev/null +++ b/app/views/components/_dropdown_button.haml @@ -0,0 +1,6 @@ +-# + @param name [String] + @param value [String] + @param text [String] +- local_assigns.delete(:text) +%button.dropdown-item{type: 'submit', data: { target: 'dropdown.item' }, name: name, value: value, **local_assigns.compact }= text diff --git a/app/views/components/_dropdown_item.haml b/app/views/components/_dropdown_item.haml index 3f79403d..a4d363a8 100644 --- a/app/views/components/_dropdown_item.haml +++ b/app/views/components/_dropdown_item.haml @@ -1,4 +1,5 @@ -# @param :text [String] Contenido del link - @param :path [String] Link -= link_to text, path, class: 'dropdown-item', data: { target: 'dropdown.item' } + @param :path [String,Hash] Link +- local_assigns[:class] = "dropdown-item #{local_assigns[:class]}" += link_to text, path, class: local_assigns[:class], data: { target: 'dropdown.item' } diff --git a/app/views/components/_instances_btn_box.haml b/app/views/components/_instances_btn_box.haml index 854262c0..8c3a5f88 100644 --- a/app/views/components/_instances_btn_box.haml +++ b/app/views/components/_instances_btn_box.haml @@ -1,6 +1,11 @@ -# Componente botonera de moderación de Instancias -- btn_class = 'btn btn-secondary' -= render 'components/btn_base', text: t('.text_check'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_allow'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_deny'), class: btn_class, href: '' \ No newline at end of file +- local_data = {} +- InstanceModeration.events.each do |event| + - possible = instance_moderation.public_send(:"may_#{event}?") + = render 'components/btn_base', + path: public_send(:"site_instance_moderation_#{event}_path", instance_moderation_id: instance_moderation), + text: t(".text_#{event}"), + class: ('btn-secondary' if possible), + disabled: !possible, + data: local_data[event] diff --git a/app/views/components/_instances_checked_submenu.haml b/app/views/components/_instances_checked_submenu.haml index f0b76185..7c9dbd87 100644 --- a/app/views/components/_instances_checked_submenu.haml +++ b/app/views/components/_instances_checked_submenu.haml @@ -1,3 +1,5 @@ -= render 'components/dropdown_item', text: t('.submenu_case'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_allow'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_reject'), path: '/' \ No newline at end of file +-# + @params form [String] + +- InstanceModeration.transitionable_events(current_state).each do |event| + = render 'components/dropdown_button', text: t(".submenu_#{event}"), name: 'instance_moderation_action', value: event, form: form diff --git a/app/views/components/_instances_filters.haml b/app/views/components/_instances_filters.haml index 213bb7c0..f2296c7b 100644 --- a/app/views/components/_instances_filters.haml +++ b/app/views/components/_instances_filters.haml @@ -1,6 +1,12 @@ -.d-flex.py-2 - = render 'components/dropdown', text: t('.text_checked') do - = render 'components/instances_checked_submenu' +-# + @params form [String] + +- current_state = params[:state]&.to_sym || InstanceModeration.states.first + +.d-flex.flex-row.justify-content-between.py-2 + - if InstanceModeration.transitionable_events(current_state).present? + = render 'components/dropdown', text: t('.text_checked') do + = render 'components/instances_checked_submenu', form: form, current_state: current_state = render 'components/dropdown', text: t('.text_show') do - = render 'components/comments_show_submenu' + = render 'components/instances_show_submenu', instance_moderations: instance_moderations diff --git a/app/views/components/_instances_show_submenu.haml b/app/views/components/_instances_show_submenu.haml index 1074cc3f..6b9b747e 100644 --- a/app/views/components/_instances_show_submenu.haml +++ b/app/views/components/_instances_show_submenu.haml @@ -1,2 +1,5 @@ -= render 'components/dropdown_item', text: t('.submenu_allow'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_reject'), path: '/' \ No newline at end of file +- InstanceModeration.states.each do |state| + = render 'components/dropdown_item', + text: t(".submenu_#{state}", count: instance_moderations.unscope(where: :aasm_state).public_send(state).count), + path: filter_states(instance_state: state), + class: ('active' if active?(InstanceModeration.states, :instance_state, state)) diff --git a/app/views/components/_profiles_btn_box.haml b/app/views/components/_profiles_btn_box.haml index 06faa8a1..2023de96 100644 --- a/app/views/components/_profiles_btn_box.haml +++ b/app/views/components/_profiles_btn_box.haml @@ -1,7 +1,12 @@ -# Componente Botonera de Moderación de Cuentas (Remote_profile) - -- btn_class = 'btn-secondary' -= render 'components/btn_base', text: t('.text_approve'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_check'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_deny'), class: btn_class, href: '' -= render 'components/btn_base', text: t('.text_report'), class: btn_class, href: '' \ No newline at end of file +.d-flex.flex-row.w-100 + - local = { report: { class: 'ml-auto', data: { confirm: t('.confirm_report') } } } + - ActorModeration.events.each do |actor_event| + - possible = actor_moderation.public_send(:"may_#{actor_event}?") + %div{ class: local.dig(actor_event, :class) } + = render 'components/btn_base', + text: t(".text_#{actor_event}"), + path: public_send(:"site_actor_moderation_#{actor_event}_path", actor_moderation_id: actor_moderation), + class: ('btn-secondary' if possible), + disabled: !possible, + data: local.dig(actor_event, :data) diff --git a/app/views/components/_profiles_checked_submenu.haml b/app/views/components/_profiles_checked_submenu.haml index 8d8f8940..04c86fd4 100644 --- a/app/views/components/_profiles_checked_submenu.haml +++ b/app/views/components/_profiles_checked_submenu.haml @@ -1,4 +1,5 @@ -= render 'components/dropdown_item', text: t('.submenu_pause'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_accept'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_reject'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_block'), path: '/' +-# + @params form [String] + +- ActorModeration.transitionable_events(current_state).each do |actor_event| + = render 'components/dropdown_button', text: t(".submenu_#{actor_event}"), name: 'actor_moderation_action', value: actor_event, form: form diff --git a/app/views/components/_profiles_filters.haml b/app/views/components/_profiles_filters.haml index 0088afef..c2670944 100644 --- a/app/views/components/_profiles_filters.haml +++ b/app/views/components/_profiles_filters.haml @@ -1,6 +1,12 @@ -.d-flex.py-2 - = render 'components/dropdown', text: t('.text_checked') do - = render 'components/profiles_checked_submenu' +-# + @params form [String] + +- current_state = params[:actor_state]&.to_sym || ActorModeration.states.first + +.d-flex.flex-row.justify-content-between.py-2 + - if ActorModeration.transitionable_events(current_state).present? + = render 'components/dropdown', text: t('.text_checked') do + = render 'components/profiles_checked_submenu', form: form, current_state: current_state = render 'components/dropdown', text: t('.text_show') do - = render 'components/profiles_show_submenu' \ No newline at end of file + = render 'components/profiles_show_submenu', actor_moderations: actor_moderations diff --git a/app/views/components/_profiles_show_submenu.haml b/app/views/components/_profiles_show_submenu.haml index 2ba949b1..bebfbe20 100644 --- a/app/views/components/_profiles_show_submenu.haml +++ b/app/views/components/_profiles_show_submenu.haml @@ -1,3 +1,5 @@ -= render 'components/dropdown_item', text: t('.submenu_accept'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_reject'), path: '/' -= render 'components/dropdown_item', text: t('.submenu_block'), path: '/' \ No newline at end of file +- ActorModeration.states.each do |actor_state| + = render 'components/dropdown_item', + text: t(".submenu_#{actor_state}", count: actor_moderations.unscope(where: :aasm_state).public_send(actor_state).count), + path: filter_states(actor_state: actor_state), + class: ('active' if active?(ActorModeration.states, :actor_state, actor_state)) diff --git a/app/views/components/_select_all.haml b/app/views/components/_select_all.haml new file mode 100644 index 00000000..9778cd13 --- /dev/null +++ b/app/views/components/_select_all.haml @@ -0,0 +1,4 @@ +-# + @param id [String] += render 'components/checkbox', id: id, data: { action: 'select-all#toggle', target: 'select-all.toggle', **local_assigns.compact } do + %span.sr-only= t('.label') diff --git a/app/views/components/_select_all_container.haml b/app/views/components/_select_all_container.haml new file mode 100644 index 00000000..8c8d9426 --- /dev/null +++ b/app/views/components/_select_all_container.haml @@ -0,0 +1,13 @@ +-# + Contenedor para las acciones en masa. + + Es un formulario auto-contenido, que permite colocar los elementos + fuera del formulario para evitar anidarlos. Mientras los elementos + tengan el atributo `form` con el mismo parámetro `form_id`, el + navegador los va a asignar a este formulario. + + @param path [String] + @param form [String] + += form_tag path, id: form, method: :patch do + -# nada diff --git a/app/views/layouts/_breadcrumb.haml b/app/views/layouts/_breadcrumb.haml index 137f086e..a946243a 100644 --- a/app/views/layouts/_breadcrumb.haml +++ b/app/views/layouts/_breadcrumb.haml @@ -13,7 +13,7 @@ %span.line-clamp-1= link_to crumb.name, crumb.url - if @current_usuarie || current_usuarie - %ul.navbar-nav + %ul.navbar-nav.flex-row - if @site&.tienda? %li.nav-item = link_to t('.tienda'), @site.tienda_url, diff --git a/app/views/layouts/_details.haml b/app/views/layouts/_details.haml index a40d0403..a21f46c1 100644 --- a/app/views/layouts/_details.haml +++ b/app/views/layouts/_details.haml @@ -1,6 +1,16 @@ --# Detail Cola de Moderación +-# + Detail Cola de Moderación -%details.details.py-2 - %summary - %h3.py-2.pr-2= summary + @param :id [String] El ID opcional sirve para mantener el historial de + cuál estaba abierto y recuperarlo al cargar la página + @param :summary [String] El resumen + @param :summary_class [String] Clases para el summary + +- local_assigns[:summary_class] ||= 'h3' + +%details.details.py-2{ id: local_assigns[:id], data: { controller: 'details', action: 'toggle->details#store' } } + %summary.d-flex.flex-row.align-items-center.justify-content-between{ class: local_assigns[:summary_class] } + %span= summary + %span.hide-when-open ▶ + %span.show-when-open ▼ = yield diff --git a/app/views/moderation_queue/_account.haml b/app/views/moderation_queue/_account.haml index 2c929402..498d78f4 100644 --- a/app/views/moderation_queue/_account.haml +++ b/app/views/moderation_queue/_account.haml @@ -1,11 +1,16 @@ +-# + @params form [String] + .row.no-gutters.pt-2 .col-1 - = render 'components/checkbox', id: profile['id'] + = render 'components/checkbox', id: actor_moderation.id, form: form, name: 'actor_moderation[]', value: actor_moderation.id, data: { target: 'select-all.input' } .col-11 - %h4 - %a{href: profile['id']}= profile['preferredUsername'] - =profile['summary'].html_safe + - cache [actor_moderation, profile] do + %h4 + = link_to text_plain(profile['name']), site_actor_moderation_path(id: actor_moderation) + .mb-3 + = sanitize profile['summary'] -# Botones de Moderación .d-flex.pb-4 - = render 'components/profiles_btn_box' \ No newline at end of file + = render 'components/profiles_btn_box', actor_moderation: actor_moderation diff --git a/app/views/moderation_queue/_accounts.haml b/app/views/moderation_queue/_accounts.haml index 2d8e1420..257b0fbf 100644 --- a/app/views/moderation_queue/_accounts.haml +++ b/app/views/moderation_queue/_accounts.haml @@ -1,12 +1,17 @@ -.row.no-gutters.pt-2 +- form_id = 'actor_moderations_action_on_several' + += render 'components/select_all_container', path: site_actor_moderations_action_on_several_path, form: form_id + +.row.no-gutters.pt-2{ data: { controller: 'select-all' } } .col-1.d-flex.align-items-center - = render 'components/checkbox', id: moderation_queue.first['id'] + = render 'components/select_all', id: 'actors', form: form_id .col-11 -# Filtros - = render 'components/profiles_filters' - -- @moderation_queue.map{ |c| c['attributedTo'] }.uniq.each do |remote_profile| - %hr - = render 'account', profile: remote_profile - - + = render 'components/profiles_filters', actor_moderations: actor_moderations, form: form_id + .col-12 + - if actor_moderations.count.zero? + %h4= t('moderation_queue.nothing') + - actor_moderations.find_each do |actor_moderation| + - next if actor_moderation.actor.content.empty? + %hr + = render 'account', actor_moderation: actor_moderation, profile: actor_moderation.actor.content, form: form_id diff --git a/app/views/moderation_queue/_block_instances_textarea.haml b/app/views/moderation_queue/_block_instances_textarea.haml index 9b388a0d..7daf0410 100644 --- a/app/views/moderation_queue/_block_instances_textarea.haml +++ b/app/views/moderation_queue/_block_instances_textarea.haml @@ -1,5 +1,3 @@ .form-group - = label_tag "custom_blocklist", t('moderation_queue.instances.custom_block') - = text_area_tag "custom_blocklist", nil, class: 'form-control' - %button.btn.btn-secondary.mt-3{ type: 'submit' }= t('moderation_queue.instances.submit') - + = label_tag 'custom_blocklist', t('moderation_queue.instances.custom_block') + = text_area_tag 'custom_blocklist', nil, class: 'form-control', placeholder: t('moderation_queue.instances.custom_block_placeholder') diff --git a/app/views/moderation_queue/_comment.haml b/app/views/moderation_queue/_comment.haml index afd2a335..a80bd27c 100644 --- a/app/views/moderation_queue/_comment.haml +++ b/app/views/moderation_queue/_comment.haml @@ -1,27 +1,47 @@ --# Componente Comentario +-# + Componente Comentario + + @param site [Site] + @param form [String] + @param profile [Hash] + @param comment [Hash] + @param activity_pub [ActivityPub] + +- in_reply_to = text_plain comment['inReplyTo'] +:ruby + begin + if in_reply_to && (remote_object = object.referenced(site)['inReplyTo']) + in_reply_to = ActivityPub.url_from_object(remote_object) + end + rescue Exception => e + ExceptionNotifier.notify_exception(e, data: { site: site.name, object: comment }) + end +- summary = text_plain comment['summary'] +-# @todo Generar un desplegable con todas las opciones +- url = text_plain ActivityPub.url_from_object(comment) .row.no-gutters .col-1 - = render 'components/checkbox', id: comment['id'] + = render 'components/checkbox', id: activity_pub.id, name: 'activity_pub[]', value: activity_pub.id, data: { target: 'select-all.input' }, form: form .col-11 - .row.no-gutters - .col.col-lg-10.d-inline-flex.justify-content-between - %h4 - %a{ href: comment['attributedTo'] }= profile['preferredUsername'] - = render 'layouts/time', time: comment['published'] - - if comment['inReplyTo'] - .row.no-gutters - .col.p-0 - %p - %span= t('.reply_to') - %span - %a{ href: comment['inReplyTo'] }= comment['inReplyTo'] - .row.no-gutters - .col.p-0 - - if comment['summary'] - - summary = comment['summary'] - = render 'layouts/details', summary: summary do - %p= comment['content'].html_safe + - cache [activity_pub, comment] do + .d-flex.flex-row.align-items-center.justify-content-between + %h4.mb-0 + %a{ href: text_plain(comment['attributedTo']) }= text_plain profile['preferredUsername'] + %a{ href: url } + %small + = render 'layouts/time', time: text_plain(comment['published']) + - if in_reply_to.present? + %dl + %dt.d-inline + %small= t('.reply_to') + %dd.d-inline + %small + %a{ href: in_reply_to }= in_reply_to + .content.mb-3 + - if summary.present? + = render 'layouts/details', summary: summary, summary_class: 'h5' do + = sanitize comment['content'] - else - %p= comment['content'].html_safe - + = sanitize comment['content'] + = render 'components/comments_btn_box', activity_pub: activity_pub diff --git a/app/views/moderation_queue/_comments.haml b/app/views/moderation_queue/_comments.haml index eadfd78b..a7523517 100644 --- a/app/views/moderation_queue/_comments.haml +++ b/app/views/moderation_queue/_comments.haml @@ -1,14 +1,18 @@ -.row.no-gutters.pt-2 - .col-1.d-flex.align-items-center - = render 'components/checkbox', id: moderation_queue.first['id'] - .col-md-9 - -# Filtros - = render 'components/comments_filters' +- form_id = 'activity_pub_action_on_several' -- moderation_queue.each do |comment| - %hr - = render 'comment', comment: comment, profile: comment['attributedTo'] - - -# Botones moderación - .d-flex.justify-content-center - = render 'components/comments_btn_box', comment: comment \ No newline at end of file += render 'components/select_all_container', path: site_activity_pubs_action_on_several_path, form: form_id + +.row.no-gutters.pt-2{ data: { controller: 'select-all' } } + .col-1.d-flex.align-items-center + = render 'components/select_all', id: 'select-all-comments', form: form_id + .col-11 + -# Filtros + = render 'components/comments_filters', activity_pubs: moderation_queue, form: form_id + .col-12 + - if moderation_queue.count.zero? + %h4= t('moderation_queue.nothing') + - moderation_queue.each do |activity_pub| + - next if activity_pub.object.content.empty? + - next if activity_pub.actor.content.empty? + %hr + = render 'moderation_queue/comment', comment: activity_pub.object.content, profile: activity_pub.actor.content, activity_pub: activity_pub, form: form_id, site: site, object: activity_pub.object diff --git a/app/views/moderation_queue/_instance.haml b/app/views/moderation_queue/_instance.haml index ec887d2d..c380089a 100644 --- a/app/views/moderation_queue/_instance.haml +++ b/app/views/moderation_queue/_instance.haml @@ -1,20 +1,23 @@ -- host = instance['domain'] -- host ||= instance['uri'] -- hosthttps = "https://#{host}" +- usuaries = instance.content.dig('usage', 'users', 'active_month') +- usuaries ||= instance.content.dig('stats', 'user_count') +- title = sanitize(instance.content['title']) .row.no-gutters.pt-2 .col-1 - = render 'components/checkbox', id: host + = render 'components/checkbox', id: instance.hostname, form: form, name: 'instance_moderation[]', value: instance_moderation.id, data: { target: 'select-all.input' } .col-11 - %h4 - %a{ href: hosthttps }= instance['title'] - %p= instance['description'].html_safe - %dl.d-inline-flex - %dt.pr-2= t('.users') - %dd - = instance.dig('usage', 'users', 'active_month') - = instance.dig('stats', 'user_count') - + - cache [instance_moderation, instance] do + %h4 + %a{ href: instance.uri }= title || instance.hostname + - if title.present? + = " (#{instance.hostname})".html_safe + .content + = sanitize instance.content['description'] + - if usuaries.present? + %dl + %dt.d-inline= t('.users') + %dd.d-inline= text_plain usuaries.to_s + -# Botones moderación .d-flex.pb-4 - = render 'components/instances_btn_box' + = render 'components/instances_btn_box', instance_moderation: instance_moderation diff --git a/app/views/moderation_queue/_instances.haml b/app/views/moderation_queue/_instances.haml index 72be522f..6bc08b95 100644 --- a/app/views/moderation_queue/_instances.haml +++ b/app/views/moderation_queue/_instances.haml @@ -1,16 +1,30 @@ -.row.no-gutters.pt-2 - .col-1.d-flex.align-items-center - = render 'components/checkbox', id: moderation_queue.first['id'] - .col-11 - -# Filtros - = render 'components/instances_filters' +- form_id = 'instance_moderation_action_on_several' -- @instances.each do |instance| - %hr - = render 'moderation_queue/instance', instance: instance +%section + = render 'components/select_all_container', path: site_instance_moderations_action_on_several_path, form: form_id + .row.no-gutters.pt-2{ data: { controller: 'select-all' } } + .col-1.d-flex.align-items-center + = render 'components/select_all', id: 'instances', form: form_id + .col-11 + -# Filtros + = render 'components/instances_filters', instance_moderations: instance_moderations, form: form_id -%hr -%h3.mt-5= t('moderation_queue.instances.title') -%lead= t('moderation_queue.instances.description') -= render 'components/block_lists', blocklists: @blocklists -= render 'moderation_queue/block_instances_textarea' + .col-12 + - if instance_moderations.count.zero? + %h4= t('moderation_queue.nothing') + + - instance_moderations.each do |instance_moderation| + %hr + = render 'moderation_queue/instance', instance_moderation: instance_moderation, instance: instance_moderation.instance, form: form_id + + %hr + %div + %h3.mt-5= t('moderation_queue.instances.title') + %lead= t('moderation_queue.instances.description') + + = form_tag site_fediblock_states_action_on_several_path, method: :patch do + = render 'components/block_lists', blocklists: fediblock_states + = render 'moderation_queue/block_instances_textarea' + + .form-group + %button.btn.btn-secondary.mt-3{ type: 'submit' }= t('moderation_queue.instances.submit') diff --git a/app/views/moderation_queue/_remote_profile.haml b/app/views/moderation_queue/_remote_profile.haml deleted file mode 100644 index 263f9c6b..00000000 --- a/app/views/moderation_queue/_remote_profile.haml +++ /dev/null @@ -1,28 +0,0 @@ --# Componente Remote_Profile - -.flex.py-2.mx-2 - %dl - %dt= t('.profile_name') - %dd= remote_profile['name'] - - %dt= t('.preferred_name') - %dd= remote_profile['preferredUsername'] - - %dt= t('.profile_id') - %dd - %a{ href: 'https://mastodon.mauve.moe/users/mauve' }= remote_profile['id'] - - %dt= t('.profile_published') - %dd - = render 'layouts/time', time: remote_profile['published'] - %dt= t('.profile_summary') - %dd= remote_profile['summary'].html_safe - - = render 'moderation_queue/comments', moderation_queue: @moderation_queue - -%dl.mt-5 - %dt= t('.profile_name') - %dd= remote_profile['name'] - --# Botones de Moderación -= render 'components/profiles_btn_box' diff --git a/app/views/moderation_queue/index.haml b/app/views/moderation_queue/index.haml index ab98ee30..80f0bd7c 100644 --- a/app/views/moderation_queue/index.haml +++ b/app/views/moderation_queue/index.haml @@ -3,14 +3,11 @@ %h1= t('.title') .row .col - - summary = t('.instances') - = render 'layouts/details', summary: summary do - = render 'moderation_queue/instances', site: @site, post: @post, moderation_queue: @moderation_queue + = render 'layouts/details', id: 'summary', summary: t('.instances') do + = render 'moderation_queue/instances', site: @site, instance_moderations: @instance_moderations, fediblock_states: @site.fediblock_states %hr - - summary = t('.accounts') - = render 'layouts/details', summary: summary do - = render 'moderation_queue/accounts', site: @site, post: @post, moderation_queue: @moderation_queue + = render 'layouts/details', id: 'accounts', summary: t('.accounts') do + = render 'moderation_queue/accounts', site: @site, actor_moderations: @actor_moderations %hr - - summary = t('.comments') - = render 'layouts/details', summary: summary do - = render 'moderation_queue/comments', site: @site, post: @post, moderation_queue: @moderation_queue + = render 'layouts/details', id: 'comments', summary: t('.comments') do + = render 'moderation_queue/comments', site: @site, moderation_queue: @moderation_queue diff --git a/app/views/moderation_queue/remote_profile.haml b/app/views/moderation_queue/remote_profile.haml deleted file mode 100644 index ba0fc257..00000000 --- a/app/views/moderation_queue/remote_profile.haml +++ /dev/null @@ -1,4 +0,0 @@ -.row.justify-content-center - .col-md-8 - %h1= t('.profile') - = render 'moderation_queue/remote_profile', remote_profile: @remote_profile diff --git a/app/views/posts/edit.haml b/app/views/posts/edit.haml index 2e46590e..e7e0260d 100644 --- a/app/views/posts/edit.haml +++ b/app/views/posts/edit.haml @@ -1,8 +1,9 @@ .row.justify-content-center .col-md-8 - - summary = t('posts.edit.post') - = render 'layouts/details', summary: summary do + - if policy(@site).edit? + = render 'layouts/details', summary: t('posts.edit.post') do + = render 'posts/form', site: @site, post: @post + = render 'layouts/details', summary: t('posts.edit.moderation_queue') do + = render 'posts/moderation_queue', site: @site, post: @post, moderation_queue: @moderation_queue + - else = render 'posts/form', site: @site, post: @post - - summary = t('posts.edit.moderation_queue') - = render 'layouts/details', summary: summary do - = render 'posts/moderation_queue', site: @site, post: @post, moderation_queue: @moderation_queue diff --git a/app/views/posts/index.haml b/app/views/posts/index.haml index af3f0a79..9def84b0 100644 --- a/app/views/posts/index.haml +++ b/app/views/posts/index.haml @@ -1,9 +1,10 @@ %main.row %aside.menu.col-md-3 - = render 'sites/header', site: @site - - = render 'sites/status', site: @site - = render 'sites/build', site: @site, class: 'btn-block' + .mb-3 + = render 'sites/header', site: @site + = render 'sites/status', site: @site + = render 'sites/build', site: @site, class: 'btn-block' + = render 'sites/moderation_queue', site: @site, class: 'btn-block' %h3= t('posts.new') %table.table.table-sm.mb-3 @@ -124,10 +125,11 @@ %br/ = post.order %td.text-nowrap - - if @usuarie || policy(post).edit? - = link_to t('posts.edit_post'), edit_site_post_path(@site, post.path), class: 'btn btn-secondary btn-block' - - if @usuarie || policy(post).destroy? - = link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-secondary btn-block', method: :delete, data: { confirm: t('posts.confirm_destroy') } + .d-flex.flex-row.align-items-start + - if @usuarie || policy(post).edit? + = link_to t('posts.edit_post'), edit_site_post_path(@site, post.path), class: 'btn btn-secondary' + - if @usuarie || policy(post).destroy? + = link_to t('posts.destroy'), site_post_path(@site, post.path), class: 'btn btn-secondary', method: :delete, data: { confirm: t('posts.confirm_destroy') } #footnotes{ hidden: true } - @filter_params.each do |param, value| diff --git a/app/views/posts/show.haml b/app/views/posts/show.haml index ec191d87..10fe64e3 100644 --- a/app/views/posts/show.haml +++ b/app/views/posts/show.haml @@ -20,7 +20,6 @@ post: @post, attribute: attr, metadata: metadata, site: @site, - tags: all_html_tags, locale: @locale, dir: dir) diff --git a/app/views/sites/_moderation_queue.haml b/app/views/sites/_moderation_queue.haml new file mode 100644 index 00000000..6b39d797 --- /dev/null +++ b/app/views/sites/_moderation_queue.haml @@ -0,0 +1,9 @@ +- if policy(ModerationQueue.new(site)).index? + - moderation_needed = site.moderation_needed? + + - local_assigns[:class] = "btn btn-secondary #{local_assigns[:class]}" + = link_to site_moderation_queue_path(site), class: local_assigns[:class], title: (t('.moderation_needed') if moderation_needed) do + = t('moderation_queue.index.title') + - if moderation_needed + %span.primary ⏺ + %span.sr-only= t('.moderation_needed') diff --git a/app/views/sites/index.haml b/app/views/sites/index.haml index ed87180a..fc8184e1 100644 --- a/app/views/sites/index.haml +++ b/app/views/sites/index.haml @@ -15,43 +15,39 @@ %tbody - @sites.each do |site| - next unless site.jekyll? - - rol = current_usuarie.rol_for_site(site) - -# - TODO: Solo les usuaries cachean porque tenemos que separar - les botones por permisos. - - cache_if (rol.usuarie? && !rol.temporal), [site, I18n.locale] do - %tr - %td - %h2 - - if policy(site).show? - = link_to site.title, site_posts_path(site, locale: site.default_locale) - - else - = site.title - %p.lead= site.description - %br - = link_to t('.visit'), site.url, class: 'btn btn-secondary' - - if rol.temporal - = button_to t('sites.invitations.accept'), - site_usuaries_accept_invitation_path(site), - method: :patch, - title: t('help.sites.invitations.accept'), - class: 'btn btn-secondary' - = button_to t('sites.invitations.reject'), - site_usuaries_reject_invitation_path(site), - method: :patch, - title: t('help.sites.invitations.reject'), - class: 'btn btn-secondary' + %tr + %td + %h2 + - if policy(site).show? + = link_to site.title, site_posts_path(site, locale: site.default_locale) - else - - if policy(site).show? - = render 'layouts/btn_with_tooltip', - tooltip: t('help.sites.edit_posts'), - type: 'success', - link: site_path(site), - text: t('sites.posts') - - if policy(SiteUsuarie.new(site, current_usuarie)).index? - = render 'layouts/btn_with_tooltip', - tooltip: t('usuaries.index.help.self'), - text: t('usuaries.index.title'), - type: 'info', - link: site_usuaries_path(site) - = render 'sites/build', site: site + = site.title + %p.lead= site.description + %br + = link_to t('.visit'), site.url, class: 'btn btn-secondary' + - if current_usuarie.rol_for_site(site).temporal? + = render 'components/btn_base', + text: t('sites.invitations.accept'), + path: site_usuaries_accept_invitation_path(site), + title: t('help.sites.invitations.accept'), + class: 'btn-secondary' + = render 'components/btn_base', + text: t('sites.invitations.reject'), + path: site_usuaries_reject_invitation_path(site), + title: t('help.sites.invitations.reject'), + class: 'btn-secondary' + - else + - if policy(site).show? + = render 'layouts/btn_with_tooltip', + tooltip: t('help.sites.edit_posts'), + type: 'success', + link: site_path(site), + text: t('sites.posts') + = render 'sites/build', site: site + = render 'sites/moderation_queue', site: site + - if policy(SiteUsuarie.new(site, current_usuarie)).index? + = render 'layouts/btn_with_tooltip', + tooltip: t('usuaries.index.help.self'), + text: t('usuaries.index.title'), + type: 'info', + link: site_usuaries_path(site) diff --git a/config/application.rb b/config/application.rb index 529e341a..ed7e5a78 100644 --- a/config/application.rb +++ b/config/application.rb @@ -2,6 +2,10 @@ require_relative 'boot' +require 'aasm' +require 'redis-client' +require 'hiredis-client' +require 'brs' require 'rails' # Pick the frameworks you want: require 'active_model/railtie' @@ -33,6 +37,11 @@ if %w[development test].include? ENV['RAILS_ENV'] end module Sutty + ALLOWED_ATTRIBUTES = %w[style href src alt controls data-align data-multimedia data-multimedia-inner id name rel + target referrerpolicy class colspan rowspan role data-turbo start type reversed].freeze + ALLOWED_TAGS = %w[strong em del u mark p h1 h2 h3 h4 h5 h6 ul ol li img iframe audio video div figure blockquote + figcaption a sub sup small table thead tbody tfoot tr th td br code].freeze + # Sutty! class Application < Rails::Application # Initialize configuration defaults for originally generated Rails diff --git a/config/environments/production.rb b/config/environments/production.rb index 5e089ff9..5b0667a5 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -62,7 +62,7 @@ Rails.application.configure do config.log_tags = %i[request_id] # Use a different cache store in production. - config.cache_store = :redis_cache_store, { url: ENV['REDIS_SERVER'] } + config.cache_store = :redis_cache_store, { url: ENV.fetch('REDIS_SERVER', nil) } config.action_mailer.perform_caching = false @@ -87,7 +87,7 @@ Rails.application.configure do config.lograge.enabled = true # Use default logging formatter so that PID and timestamp are not # suppressed. - config.log_formatter = ::Logger::Formatter.new + config.log_formatter = Logger::Formatter.new # Use a different logger for distributed setups. require 'syslog/logger' @@ -140,9 +140,10 @@ Rails.application.configure do domain: ENV.fetch('SUTTY', 'sutty.nl'), enable_starttls_auto: false } - config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', "noreply@sutty.nl") } + config.action_mailer.default_options = { from: ENV.fetch('DEFAULT_FROM', 'noreply@sutty.nl') } - config.middleware.use ExceptionNotification::Rack, gitlab: {}, ignore_exceptions: ['DeployJob::DeployAlreadyRunningException'] + config.middleware.use ExceptionNotification::Rack, gitlab: {}, error_grouping: true, + ignore_exceptions: ['DeployJob::DeployAlreadyRunningException'] Rails.application.routes.default_url_options[:host] = "panel.#{ENV.fetch('SUTTY', 'sutty.nl')}" Rails.application.routes.default_url_options[:protocol] = 'https' diff --git a/config/initializers/que_web.rb b/config/initializers/que_web.rb new file mode 100644 index 00000000..a6b87cf8 --- /dev/null +++ b/config/initializers/que_web.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Que::Web.use(Rack::Auth::Basic) do |user, password| + [user, password] == [ENV.fetch('HTTP_BASIC_USER', nil), ENV.fetch('HTTP_BASIC_PASSWORD', nil)] +end diff --git a/config/locales/en.yml b/config/locales/en.yml index 7b2971da..5e9a2377 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -50,58 +50,127 @@ en: pm: pm format: '%-I:%M %p' components: + actor: + user: Username + profile: Profile + profile_name: Profile name + preferred_name: Name in Fediverse + profile_id: ID + profile_published: Published + profile_summary: Summary + block_list: + know_more: Know more + instances_blocked: Instances blocked instances_filters: text_show: Show - text_checked: With selected + text_checked: With selected... instances_checked_submenu: - submenu_case: Check case by case - submenu_allow: Allow everything - submenu_reject: Reject - instances_show_submenu: + submenu_pause: Moderate submenu_allow: Allow - submenu_reject: Reject + submenu_block: Block + instances_show_submenu: + submenu_paused: "Moderated (%{count})" + submenu_allowed: "Allowed (%{count})" + submenu_blocked: "Blocked (%{count})" comments_filters: text_show: Show - text_checked: With selected + text_checked: With selected... comments_checked_submenu: submenu_pause: Pause - submenu_accept: Accept + submenu_approve: Approve submenu_reject: Reject - comments_show_submenu: - submenu_pause: Pause - submenu_accept: Accept submenu_report: Report - submenu_reject: Reject + comments_show_submenu: + submenu_paused: "Paused (%{count})" + submenu_approved: "Approved (%{count})" + submenu_rejected: "Rejected (%{count})" + submenu_reported: "Reported (%{count})" profiles_filters: text_show: Show - text_checked: With selected + text_checked: With selected... profiles_checked_submenu: submenu_pause: Pause - submenu_accept: Accept - submenu_reject: Reject + submenu_allow: Allow submenu_block: Block + submenu_report: Report profiles_show_submenu: - submenu_accept: Accept - submenu_block: Block - submenu_reject: Reject - block_lists: + submenu_paused: "Paused (%{count})" + submenu_allowed: "Allowed (%{count})" + submenu_blocked: "Blocked (%{count})" + submenu_reported: "Reported (%{count})" + block_lists: title: Block lists comments_btn_box: text_pause: Pause + text_approve: Approve text_reject: Reject - text_accept: Accept text_reply: Reply text_report: Report + confirm_report: "Send report to the remote instance? This action will also reject the comment." + confirm_reject: "Reject this comment? Please notice we can't undo this action at this moment." instances_btn_box: - text_check: Check case by case + text_pause: Check case by case text_allow: Allow everything - text_deny: Block instance + text_block: Block instance profiles_btn_box: - text_approve: Always approve - text_check: Always check - text_deny: Block + text_pause: Always check + text_allow: Always approve + text_block: Block text_report: Report + confirm_report: "Send report to the remote instance? This action will also block the account." + remote_flags: + report_message: "Hi! Someone using Sutty CMS reported this account on your instance. We don't have support for customized report messages yet, but we will soon. You can reach us at %{panel_actor_mention}." + activity_pubs: + action_on_several: + success: "Several comments have changed moderation state. You can find them using the filters on the Comments section." + error: "There was an error while changing moderation state. We received a report and will be acting on it soon." + approve: + success: "Comment approved." + error: "There was an error while approving the comment. We received a report and will be acting on it soon." + reject: + success: "Comment rejected. You can report it using the Report button." + error: "There was an error while rejecting the comment. We received a report and will be acting on it soon." + report: + success: "Comment reported." + error: "There was an error while reporting the comment. We received a report and will be acting on it soon." + actor_moderations: + action_on_several: + success: "Several accounts have changed moderation state. You can find them using the filters on the Accounts section. No action was performed over existing Comments." + error: "There was an error while changing moderation state. We received a report and will be acting on it soon." + pause: + success: "Account paused. No action was performed on existing Comments." + error: "There was an error while pausing the account. We received a report and will be acting on it soon." + allow: + success: "Account allowed. All of their comments from now on will be approved automatically. No action was performed over existing Comments." + error: "There was an error while allowing the account. We received a report and will be acting on it soon." + block: + success: "Account blocked. All of their comments from now on will be rejected automatically. No action was performed over existing Comments. If you want to report it to their instance, please use the Report button." + error: "There was an error while blocking the account. We received a report and will be acting on it soon." + report: + success: "Account reported." + error: "There was an error while reporting the account. We received a report and will be acting on it soon." + instance_moderations: + action_on_several: + success: "Several instances have changed moderation state. You can find them using the filters on the Instances section. No action was performed over existing Accounts and Comments." + error: "There was an error while changing moderation state. We received a report and will be acting on it soon." + pause: + success: "Instance paused. All of their comments and accounts from now on will need to be moderated individually. No action was performed over existing Accounts and Comments." + error: "There was an error while pausing the instance. We received a report and will be acting on it soon." + allow: + success: "Instance allowed. All of their comments and accounts from now on will be approved automatically. No action was performed over existing Accounts and Comments." + error: "There was an error while allowing the instance. We received a report and will be acting on it soon." + block: + success: "Instance blocked. All of their comments and accounts from now on will be rejected automatically. No action was performed over existing Accounts and Comments." + error: "There was an error while blocking the instance. We received a report and will be acting on it soon." + fediblock_states: + action_on_several: + success: "Blocklists have been enabled, you can find their instances by filtering by Blocked. You can approve them individually on the Accounts section. No action was performed over existing Accounts and Comments." + error: "There was an error while enabling or disabling blocklists. We received a report and will be acting on it soon." + custom_blocklist_success: "Custom blocklist has been added, you can find the instances by filtering by Blocked. No action was performed over existing Accounts and Comments." + custom_blocklist_error: "There was an error while adding a custom blocklist. We received a report and will be acting on it soon." moderation_queue: + everything: 'Select all' + nothing: "There's nothing for this filter" index: title: Moderation instances: Instances @@ -110,18 +179,13 @@ en: comment: source_profile: Source Profile reply_to: Reply to - remote_profile: - user: Username - profile: Profile - profile_name: Profile name - preferred_name: Name in Fediverse - profile_id: ID - profile_published: Published - profile_summary: Summary instances: title: My block lists - description: Description + description: "Blocklists contain instances known for hosting hate speech, promote fascism, violence, sexual/gendered abuse and/or misinformation." custom_block: Custom block lists + custom_block_placeholder: | + a.doma.in + per.li.ne submit: Save block lists instance: users: "Users:" @@ -332,6 +396,7 @@ en: lang: not_available: "This language is not yet available, would you help us by translating Sutty into it?" errors: + site_not_found: "Site not found, or maybe you don't have access to it." argument_error: 'Argument `%{argument}` must be an instance of %{class}' unknown_locale: 'Unknown %{locale} locale' posts: @@ -508,6 +573,8 @@ en: column: "Country" empty: "(couldn't detect country)" sites: + moderation_queue: + moderation_needed: "There are new activities pending revision since the last time you moderated." donations: url: 'https://donaciones.sutty.nl/en/' text: 'Support us' @@ -704,10 +771,7 @@ en: categories: 'Everything' index: search: 'Search' - edit_post: 'Edit' - edit: - moderation_queue: Moderation Queue - post: Post + edit: Edit preview: btn: 'Preliminary version' alert: 'Not every article type has a preliminary version' diff --git a/config/locales/es.yml b/config/locales/es.yml index 2142492a..a07b3799 100644 --- a/config/locales/es.yml +++ b/config/locales/es.yml @@ -50,58 +50,126 @@ es: pm: pm format: '%-H:%M' components: + actor: + user: Nombre de usuarie + profile: Cuenta de Origen + profile_name: Nombre de la cuenta + preferred_name: Nombre en el Fediverso + profile_id: ID + profile_published: Publicada + profile_summary: Presentación + block_list: + know_more: Saber más (en inglés) + instances_blocked: Instancias bloqueadas instances_filters: text_show: Ver - text_checked: Con los marcados + text_checked: Con los marcados... instances_checked_submenu: - submenu_case: Moderar caso por caso + submenu_pause: Moderar caso por caso submenu_allow: Permitir todo - submenu_reject: Rechazado + submenu_block: Rechazar todo instances_show_submenu: - submenu_allow: Permitido - submenu_reject: Rechazado + submenu_paused: "Pausadas (%{count})" + submenu_allowed: "Permitidas (%{count})" + submenu_blocked: "Bloqueadas (%{count})" comments_filters: text_show: Ver - text_checked: Con los marcados + text_checked: Con los marcados... comments_checked_submenu: - submenu_pause: Pausado - submenu_accept: Aceptado - submenu_reject: Rechazado + submenu_pause: Pausar + submenu_approve: Aprobar + submenu_reject: Rechazar + submenu_report: Reportar comments_show_submenu: - submenu_pause: Pausado - submenu_accept: Aceptado - submenu_report: Reportado - submenu_reject: Rechazado + submenu_paused: "Pausados (%{count})" + submenu_approved: "Aprobados (%{count})" + submenu_rejected: "Rechazados (%{count})" + submenu_reported: "Reportados (%{count})" profiles_filters: text_show: Ver - text_checked: Con los marcados + text_checked: Con los marcados... profiles_checked_submenu: - submenu_pause: Pausado - submenu_accept: Aceptado - submenu_reject: Rechazado - submenu_block: Bloqueado + submenu_pause: Pausar + submenu_allow: Aceptar + submenu_block: Bloquear + submenu_report: Reportar profiles_show_submenu: - submenu_accept: Aceptado - submenu_block: Bloqueado - submenu_reject: Rechazado - block_lists: + submenu_paused: "Pausadas (%{count})" + submenu_allowed: "Permitidas (%{count})" + submenu_blocked: "Bloqueadas (%{count})" + submenu_reported: "Reportadas (%{count})" + block_lists: title: Listas de bloqueo comments_btn_box: - text_pause: Pausa + text_pause: Pausar + text_approve: Aceptar text_reject: Rechazar - text_accept: Aceptar Publicación - text_reply: Responder text_report: Reportar + confirm_report: "¿Enviar el reporte a la instancia remota? Esta acción también rechazará el comentario." + confirm_reject: "¿Rechazar este comentario? Tené en cuenta que por el momento no es posible deshacer esta acción." instances_btn_box: - text_check: Moderar caso por caso + text_pause: Moderar caso por caso text_allow: Permitir todo - text_deny: Bloquear instancia + text_block: Bloquear instancia profiles_btn_box: - text_approve: Aprobar siempre - text_check: Revisar siempre - text_deny: Bloquear + text_pause: Revisar siempre + text_allow: Aprobar siempre + text_block: Bloquear text_report: Reportar + confirm_report: "¿Enviar el reporte a la instancia remota? Esta acción también bloqueará la cuenta." + remote_flags: + report_message: "¡Hola! Une usuarie de Sutty CMS reportó esta cuenta en tu instancia. Todavía no tenemos soporte para mensajes personalizados. Podés contactarnos en %{panel_actor_mention}." + activity_pubs: + action_on_several: + success: "Se ha modificado el estado de moderación de varios comentarios. Podés encontrarlos usando los filtros en la sección Comentarios." + error: "Hubo un error al modificar el estado de moderación de varios comentarios. Hemos recibido el reporte y lo estaremos verificando." + approve: + success: "Comentario aprobado." + error: "No se puedo aprobar el comentario. Hemos recibido el reporte y lo estaremos verificando." + reject: + success: "Comentario rechazado. Podés reportarlo usando el botón Reportar." + error: "No se puedo rechazar el comentario. Hemos recibido el reporte y lo estaremos verificando." + report: + success: "Comentario reportado." + error: "No se puedo reportar el comentario. Hemos recibido el reporte y lo estaremos verificando." + actor_moderations: + action_on_several: + success: "Se ha modificado el estado de moderación de varias cuentas. Podés encontrarlas usando los filtros en la sección Cuentas. No se modificaron comentarios pre-existentes." + error: "Hubo un error al modificar el estado de moderación de varias cuentas. Hemos recibido el reporte y lo estaremos verificando." + pause: + success: "Cuenta pausada. Todos los comentarios que haga necesitan ser aprobados manualmente en la sección Comentarios. No se modificaron comentarios pre-existentes." + error: "No se pudo pausar la cuenta. Hemos recibido el reporte y lo estaremos verificando." + allow: + success: "Cuenta permitida. Todos los comentarios que haga serán aprobados inmediatamente. No se modificaron comentarios pre-existentes." + error: "No se pudo permitir la cuenta. Hemos recibido el reporte y lo estaremos verificando." + block: + success: "Cuenta bloqueada. Todos los comentarios que haga serán rechazados inmediatamente. Si querés reportarla a su instancia, podés usar el botón Reportar. No se modificaron comentarios pre-existentes." + error: "No se pudo bloquear la cuenta. Hemos recibido el reporte y lo estaremos verificando." + report: + success: "Cuenta reportada a su instancia." + error: "No se pudo reportar la cuenta. Hemos recibido el reporte y lo estaremos verificando." + instance_moderations: + action_on_several: + success: "Se ha modificado el estado de moderación de varias instancias. Podés encontrarlas usando los filtros en la sección Instancias. No se modificaron cuentas y comentarios pre-existentes." + error: "Hubo un error al modificar el estado de moderación de varias instancias. Hemos recibido el reporte y lo estaremos verificando." + pause: + success: "Instancia pausada. A partir de ahora, todos los comentarios y cuentas de esta instancia necesitan ser aprobados manualmente. No se ha modificado el estado de moderación de cuentas ni comentarios pre-existentes." + error: "No se pudo pausar la instancia. Hemos recibido el reporte y lo estaremos verificando." + allow: + success: "Instancia permitida. A partir de ahora, todos los comentarios y cuentas pendientes serán aprobados inmediatamente. No se modificaron cuentas ni comentarios pre-existentes." + error: "No se pudo permitir la instancia. Hemos recibido el reporte y lo estaremos verificando." + block: + success: "Instancia bloqueada. A partir de ahora, todos los comentarios y cuentas serán rechazados inmediatamente. No se modificaron cuentas ni comentarios pre-existentes." + error: "No se pudo bloquear la instancia. Hemos recibido el reporte y lo estaremos verificando." + fediblock_states: + action_on_several: + success: "Se habilitaron las listas de bloqueo, podés encontrar las instancias filtrando por Bloqueadas. Podés activarlas individualmente en la sección Cuentas. No se modificaron cuentas ni comentarios pre-existentes." + error: "Hubo un error al activar o desactivar listas de bloqueo, ya recibimos el reporte y lo estaremos verificando." + custom_blocklist_success: "Se agregaron las instancias personalizadas a la lista de bloqueo, podés encontrarlas filtrando por Bloqueadas. Podés aprobarlas individualmente en la sección Cuentas. No se modificaron cuentas ni comentarios pre-existentes." + custom_blocklist_error: "Hubo un error al agregar instancias personalizadas a la lista de bloqueo, ya recibimos el reporte y lo estaremos verificando." moderation_queue: + everything: 'Seleccionar todo' + nothing: 'No hay nada para este filtro' index: title: Actividades de moderación instances: Instancias @@ -110,18 +178,13 @@ es: comment: source_profile: Cuenta de Origen reply_to: En respuesta a - remote_profile: - user: Nombre de usuario - profile: Cuenta de Origen - profile_name: Nombre de la Cuenta - preferred_name: Nombre en el Fediverso - profile_id: ID - profile_published: Publicada - profile_summary: Presentación instances: title: Mis listas de bloqueo - description: Descripción de listas de bloqueo + description: "Las listas de bloqueo contienen instancias conocidas por alojar discurso de odio, promover el fascismo, la violencia, abuso sexual y/o desinformación." custom_block: Lista personalizada de bloqueo + custom_block_placeholder: | + un.domin.io + por.lin.ea submit: Guardar listas de bloqueo instance: users: "Usuaries:" @@ -332,6 +395,7 @@ es: lang: not_available: "Este idioma todavía no está disponible, ¿nos ayudas a agregarlo y mantenerlo?" errors: + site_not_found: "No encontramos ese sitio o quizás no tengas acceso." argument_error: 'El argumento `%{argument}` debe ser una instancia de %{class}' unknown_locale: 'El idioma %{locale} es desconocido' posts: @@ -513,6 +577,8 @@ es: column: "País" empty: "(no se pudo detectar el país)" sites: + moderation_queue: + moderation_needed: "Hay actividades pendientes de revisión desde la última vez que moderaste." donations: url: 'https://donaciones.sutty.nl/' text: 'Apoyá nuestro trabajo' @@ -646,9 +712,7 @@ es: en: 'inglés' ar: 'árabe' posts: - edit: - moderation_queue: Comentarios - post: Contenido + edit: Editar prev: Página anterior next: Página siguiente empty: No hay artículos con estos parámetros de búsqueda. diff --git a/config/routes.rb b/config/routes.rb index 1899c656..9d5c974a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,9 @@ Rails.application.routes.draw do devise_for :usuaries get '/.well-known/change-password', to: redirect('/usuaries/edit') + require 'que/web' + mount Que::Web => '/que' + root 'application#index' constraints(Constraints::ApiSubdomain.new) do @@ -11,6 +14,10 @@ Rails.application.routes.draw do namespace :v1 do resources :csp_reports, only: %i[create] + namespace :activity_pub do + resources :remote_flags, only: %i[show] + end + resources :sites, only: %i[index], constraints: { site_id: /[a-z0-9\-.]+/, id: /[a-z0-9\-.]+/ } do get :'invitades/cookie', to: 'invitades#cookie' post :'posts/:layout', to: 'posts#create', as: :posts @@ -18,7 +25,15 @@ Rails.application.routes.draw do get :'contact/cookie', to: 'invitades#contact_cookie' post :'contact/:form', to: 'contact#receive', as: :contact - post :'webhooks/pull', to: 'webhooks#pull' + namespace :webhooks do + post :pull, to: 'pull#pull' + + scope :social_inbox do + post :moderationqueued, to: 'social_inbox#moderationqueued' + post :onapproved, to: 'social_inbox#onapproved' + post :onrejected, to: 'social_inbox#onrejected' + end + end end end end @@ -54,8 +69,31 @@ Rails.application.routes.draw do post 'collaborate', to: 'collaborations#accept_collaboration' get 'moderation_queue', to: 'moderation_queue#index' - get 'remote_profile', to: 'moderation_queue#remote_profile' - get 'instances', to: 'moderation_queue#instances' + + resources :instance_moderations, only: [] do + patch :pause, to: 'instance_moderations#pause' + patch :allow, to: 'instance_moderations#allow' + patch :block, to: 'instance_moderations#block' + end + + patch :instance_moderations_action_on_several, to: 'instance_moderations#action_on_several' + patch :fediblock_states_action_on_several, to: 'fediblock_states#action_on_several' + + resources :actor_moderations, only: %i[show] do + ActorModeration.events.each do |actor_event| + patch actor_event, to: "actor_moderations##{actor_event}" + end + end + + patch :actor_moderations_action_on_several, to: 'actor_moderations#action_on_several' + + resources :activity_pub, only: [] do + ActivityPub.events.each do |event| + patch event, to: "activity_pubs##{event}" + end + end + + patch :activity_pubs_action_on_several, to: 'activity_pubs#action_on_several' # Gestionar artículos según idioma nested do diff --git a/db/migrate/20240216170202_add_rol_to_deploys.rb b/db/migrate/20240216170202_add_rol_to_deploys.rb new file mode 100644 index 00000000..93c4553d --- /dev/null +++ b/db/migrate/20240216170202_add_rol_to_deploys.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Establece una relación entre roles y deploys +class AddRolToDeploys < ActiveRecord::Migration[6.1] + def up + add_column :deploys, :rol_id, :integer, index: true + + Deploy.find_each do |deploy| + rol_id = deploy.site.roles.find_by(rol: 'usuarie', temporal: false)&.id + + deploy.update_column(:rol_id, rol_id) if rol_id + end + end + + def down + remove_column :deploys, :rol_id + end +end diff --git a/db/migrate/20240219153919_create_activity_pub_activities.rb b/db/migrate/20240219153919_create_activity_pub_activities.rb new file mode 100644 index 00000000..555656ad --- /dev/null +++ b/db/migrate/20240219153919_create_activity_pub_activities.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Actividades. Se asocian a un objeto y a una cola de moderación +class CreateActivityPubActivities < ActiveRecord::Migration[6.1] + def change + create_table :activity_pub_activities, id: :uuid do |t| + t.timestamps + + t.uuid :activity_pub_id, index: true, null: false + + t.string :type, null: false + t.string :uri, null: false + t.jsonb :content, default: {} + end + end +end diff --git a/db/migrate/20240219175839_create_activity_pub_actors.rb b/db/migrate/20240219175839_create_activity_pub_actors.rb new file mode 100644 index 00000000..656b3f63 --- /dev/null +++ b/db/migrate/20240219175839_create_activity_pub_actors.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Almacena actores de ActivityPub y los relaciona con actividades +class CreateActivityPubActors < ActiveRecord::Migration[6.1] + def change + create_table :activity_pub_actors, id: :uuid do |t| + t.timestamps + t.uuid :instance_id, index: true, null: false + t.string :uri, index: true, unique: true, null: false + end + end +end diff --git a/db/migrate/20240219204011_create_activity_pubs.rb b/db/migrate/20240219204011_create_activity_pubs.rb new file mode 100644 index 00000000..cf797fc8 --- /dev/null +++ b/db/migrate/20240219204011_create_activity_pubs.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Registro de actividades. +class CreateActivityPubs < ActiveRecord::Migration[6.1] + def change + create_table :activity_pubs, id: :uuid do |t| + t.timestamps + + t.bigint :site_id, null: false + t.uuid :object_id, null: false + t.string :object_type, null: false + + t.string :aasm_state, null: false + + t.index %i[site_id object_id object_type], unique: true + end + end +end diff --git a/db/migrate/20240219204224_create_activity_pub_objects.rb b/db/migrate/20240219204224_create_activity_pub_objects.rb new file mode 100644 index 00000000..865589ab --- /dev/null +++ b/db/migrate/20240219204224_create_activity_pub_objects.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +# Almacena objetos de ActivityPub. Los objetos pueden estar compartidos +# por toda la instancia. +class CreateActivityPubObjects < ActiveRecord::Migration[6.1] + def change + create_table :activity_pub_objects, id: :uuid do |t| + t.timestamps + + t.uuid :actor_id, index: true, null: false + + t.string :type, null: false + t.string :uri, null: false, unique: true + t.jsonb :content, default: {} + end + end +end diff --git a/db/migrate/20240220161414_create_activity_pub_instances.rb b/db/migrate/20240220161414_create_activity_pub_instances.rb new file mode 100644 index 00000000..feb9351d --- /dev/null +++ b/db/migrate/20240220161414_create_activity_pub_instances.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Almacena las instancias +class CreateActivityPubInstances < ActiveRecord::Migration[6.1] + def change + create_table :activity_pub_instances, id: :uuid do |t| + t.timestamps + t.string :hostname, index: true, unique: true, null: false + t.string :aasm_state, null: false + t.jsonb :content, default: {} + end + end +end diff --git a/db/migrate/20240221184007_remove_actor_from_objects.rb b/db/migrate/20240221184007_remove_actor_from_objects.rb new file mode 100644 index 00000000..6ee5822c --- /dev/null +++ b/db/migrate/20240221184007_remove_actor_from_objects.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# No es necesario vincular actores con objetos, porque la forma en que +# lo estábamos haciendo no se refiere a le actore del objeto, sino de +# acciones distintas sobre el mismo objeto, generado por une actore. +# +# Y ese valor ya lo podemos obtener desde attributedTo +class RemoveActorFromObjects < ActiveRecord::Migration[6.1] + def change + remove_column :activity_pub_objects, :actor_id, :uuid, index: true + end +end diff --git a/db/migrate/20240223170317_add_actor_to_activities.rb b/db/migrate/20240223170317_add_actor_to_activities.rb new file mode 100644 index 00000000..a546cd94 --- /dev/null +++ b/db/migrate/20240223170317_add_actor_to_activities.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Relaciona Actor con Activity +class AddActorToActivities < ActiveRecord::Migration[6.1] + def up + add_column :activity_pub_activities, :actor_id, :uuid, index: true + + ActivityPub::Activity.find_each do |activity| + actor = ActivityPub::Actor.find_by(uri: activity.content['actor']) + + activity.update(actor: actor) if actor.present? + end + end + + def down + remove_column :activity_pub_activities, :actor_id, :uuid, index: true + end +end diff --git a/db/migrate/20240226133022_add_instance_id_to_activity_pubs.rb b/db/migrate/20240226133022_add_instance_id_to_activity_pubs.rb new file mode 100644 index 00000000..710aacef --- /dev/null +++ b/db/migrate/20240226133022_add_instance_id_to_activity_pubs.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Relaciona instancias con sus actividades +class AddInstanceIdToActivityPubs < ActiveRecord::Migration[6.1] + def up + add_column :activity_pubs, :instance_id, :uuid, index: true + + ActivityPub.all.find_each do |activity_pub| + activity_pub.update(instance: activity_pub&.object&.actor&.instance) + end + end + + def down + remove_column :activity_pubs, :instance_id, :uuid, index: true + end +end diff --git a/db/migrate/20240226134335_create_instance_moderation.rb b/db/migrate/20240226134335_create_instance_moderation.rb new file mode 100644 index 00000000..8b08e14e --- /dev/null +++ b/db/migrate/20240226134335_create_instance_moderation.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Como la instancia es única para todo el panel, necesitamos llevar +# registro de su relación con cada sitio por separado. +class CreateInstanceModeration < ActiveRecord::Migration[6.1] + def up + create_table :instance_moderations do |t| + t.timestamps + + t.belongs_to :site + t.uuid :instance_id, index: true + + t.string :aasm_state, null: false, default: 'paused' + + t.index %i[site_id instance_id], unique: true + end + + ActivityPub.all.find_each do |activity_pub| + InstanceModeration.find_or_create_by(site: activity_pub.site, instance: activity_pub.instance) + end + end + + def down + drop_table :instance_moderations + end +end diff --git a/db/migrate/20240227134845_create_fediblocks.rb b/db/migrate/20240227134845_create_fediblocks.rb new file mode 100644 index 00000000..03f65f7c --- /dev/null +++ b/db/migrate/20240227134845_create_fediblocks.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Las fediblocks son listas descargables de instancias bloqueadas. El +# formato hace una recomendación sobre suspensión o desfederación, pero +# nosotres bloqueamos todo. +class CreateFediblocks < ActiveRecord::Migration[6.1] + def up + create_table :activity_pub_fediblocks, id: :uuid do |t| + t.timestamps + + t.string :title, null: false + t.string :url, null: false + t.string :download_url, null: false + t.string :format, null: false + t.jsonb :instances, default: [] + end + + YAML.safe_load(File.read('db/seeds/activity_pub/fediblocks.yml')).each do |fediblock| + ActivityPub::Fediblock.create(**fediblock).process! + end + end + + def down + drop_table :activity_pub_fediblocks + end +end diff --git a/db/migrate/20240227142019_create_fediblock_states.rb b/db/migrate/20240227142019_create_fediblock_states.rb new file mode 100644 index 00000000..1e718343 --- /dev/null +++ b/db/migrate/20240227142019_create_fediblock_states.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# La relación entre sitios y fediblocks +class CreateFediblockStates < ActiveRecord::Migration[6.1] + def up + create_table :fediblock_states, id: :uuid do |t| + t.timestamps + + t.belongs_to :site + t.uuid :fediblock_id, index: true + t.string :aasm_state + + t.index %i[site_id fediblock_id], unique: true + end + + # Todas las listas están activas por defecto + DeploySocialDistributedPress.find_each do |deploy| + ActivityPub::Fediblock.find_each do |fediblock| + FediblockState.create(site: deploy.site, fediblock: fediblock, aasm_state: 'disabled').tap(&:enable!) + end + end + end + + def down + drop_table :fediblock_states + end +end diff --git a/db/migrate/20240228171335_rename_fediblock_instances_to_hostnames.rb b/db/migrate/20240228171335_rename_fediblock_instances_to_hostnames.rb new file mode 100644 index 00000000..bad343f2 --- /dev/null +++ b/db/migrate/20240228171335_rename_fediblock_instances_to_hostnames.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +# Cambia el nombre de la columna para que podamos obtener todas las +# instancias de un fediblock +class RenameFediblockInstancesToHostnames < ActiveRecord::Migration[6.1] + def change + rename_column :activity_pub_fediblocks, :instances, :hostnames + end +end diff --git a/db/migrate/20240228202830_create_actor_moderations.rb b/db/migrate/20240228202830_create_actor_moderations.rb new file mode 100644 index 00000000..01460eae --- /dev/null +++ b/db/migrate/20240228202830_create_actor_moderations.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Relación entre Actor y Site +class CreateActorModerations < ActiveRecord::Migration[6.1] + def change + create_table :actor_moderations, id: :uuid do |t| + t.timestamps + + t.belongs_to :site + t.uuid :actor_id, index: true + t.string :aasm_state, null: false + end + end +end diff --git a/db/migrate/20240229201155_create_activity_pub_remote_flags.rb b/db/migrate/20240229201155_create_activity_pub_remote_flags.rb new file mode 100644 index 00000000..c60aca22 --- /dev/null +++ b/db/migrate/20240229201155_create_activity_pub_remote_flags.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Lleva el registro de reportes remotos +class CreateActivityPubRemoteFlags < ActiveRecord::Migration[6.1] + def change + create_table :activity_pub_remote_flags, id: :uuid do |t| + t.timestamps + t.belongs_to :site + t.uuid :actor_id, index: true + + t.text :message + + t.index %i[site_id actor_id], unique: true + end + end +end diff --git a/db/migrate/20240301181224_add_remote_flag_to_actor_moderation.rb b/db/migrate/20240301181224_add_remote_flag_to_actor_moderation.rb new file mode 100644 index 00000000..63e4ce1b --- /dev/null +++ b/db/migrate/20240301181224_add_remote_flag_to_actor_moderation.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Las acciones de moderación pueden tener un reporte remoto asociado +class AddRemoteFlagToActorModeration < ActiveRecord::Migration[6.1] + def up + add_column :actor_moderations, :remote_flag_id, :uuid, null: true + + ActivityPub::RemoteFlag.all.find_each do |remote_flag| + actor_moderation = ActorModeration.find_by(actor_id: remote_flag.actor_id) + + actor_moderation&.update_column(:remote_flag_id, remote_flag.id) + end + end + + def down + remove_column :actor_moderations, :remote_flag_id + end +end diff --git a/db/migrate/20240301194154_remove_unique_index_from_activity_pubs.rb b/db/migrate/20240301194154_remove_unique_index_from_activity_pubs.rb new file mode 100644 index 00000000..0fa80e60 --- /dev/null +++ b/db/migrate/20240301194154_remove_unique_index_from_activity_pubs.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# A veces tenemos varias acciones sobre el mismo objeto +class RemoveUniqueIndexFromActivityPubs < ActiveRecord::Migration[6.1] + def change + remove_index :activity_pubs, %i[site_id object_id object_type], unique: true + end +end diff --git a/db/migrate/20240301202955_add_actor_id_to_activity_pubs.rb b/db/migrate/20240301202955_add_actor_id_to_activity_pubs.rb new file mode 100644 index 00000000..37db4bfc --- /dev/null +++ b/db/migrate/20240301202955_add_actor_id_to_activity_pubs.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# Relaciona estados de actividades con les actores que las hicieron +class AddActorIdToActivityPubs < ActiveRecord::Migration[6.1] + def up + add_column :activity_pubs, :actor_id, :uuid + + ActivityPub.all.find_each do |activity_pub| + activity_pub.update_column(:actor_id, activity_pub.activities.last.actor_id) + end + end + + def down + remove_column :activity_pubs, :actor_id + end +end diff --git a/db/migrate/20240305164653_change_remote_flags.rb b/db/migrate/20240305164653_change_remote_flags.rb new file mode 100644 index 00000000..258f3335 --- /dev/null +++ b/db/migrate/20240305164653_change_remote_flags.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Agrega relaciones en las remote flags +class ChangeRemoteFlags < ActiveRecord::Migration[6.1] + def up + add_column :activity_pubs, :remote_flag_id, :uuid, index: true, null: true + end + + def down + remove_column :activity_pubs, :remote_flag_id + end +end diff --git a/db/migrate/20240305184854_add_state_to_remote_flags.rb b/db/migrate/20240305184854_add_state_to_remote_flags.rb new file mode 100644 index 00000000..7ff78dfb --- /dev/null +++ b/db/migrate/20240305184854_add_state_to_remote_flags.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# Estado de los reportes remotos +class AddStateToRemoteFlags < ActiveRecord::Migration[6.1] + def change + add_column :activity_pub_remote_flags, :aasm_state, :string, null: false, default: 'waiting' + end +end diff --git a/db/migrate/20240307201510_remove_actor_moderations.rb b/db/migrate/20240307201510_remove_actor_moderations.rb new file mode 100644 index 00000000..b451c589 --- /dev/null +++ b/db/migrate/20240307201510_remove_actor_moderations.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Mover todes les actores eliminades +class RemoveActorModerations < ActiveRecord::Migration[6.1] + def up + actor_ids = + ActivityPub.where(aasm_state: 'removed', object_type: 'ActivityPub::Object::Person').distinct.pluck(:actor_id) + + ActorModeration.where(actor_id: actor_ids).remove_all! + end + + def down; end +end diff --git a/db/migrate/20240307203039_remove_actor_moderations2.rb b/db/migrate/20240307203039_remove_actor_moderations2.rb new file mode 100644 index 00000000..555a4ffe --- /dev/null +++ b/db/migrate/20240307203039_remove_actor_moderations2.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Algunes quedaron como genéricxs +class RemoveActorModerations2 < ActiveRecord::Migration[6.1] + def up + actor_uris = ActivityPub::Activity.unscope(:order).where(type: 'ActivityPub::Activity::Delete').distinct.pluck(Arel.sql("content->>'object'")) + actor_ids = ActivityPub::Actor.where(uri: actor_uris).ids + + ActorModeration.where(actor_id: actor_ids).remove_all! + end + + def down; end +end diff --git a/db/migrate/20240313192134_fix_fetch_jobs.rb b/db/migrate/20240313192134_fix_fetch_jobs.rb new file mode 100644 index 00000000..54ffa7e6 --- /dev/null +++ b/db/migrate/20240313192134_fix_fetch_jobs.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class FixFetchJobs < ActiveRecord::Migration[6.1] + def up + QueJob.where("last_error_message like '%ActiveJob::DeserializationError%'").find_each do |job| + job.error_count = 0 + job.run_at = Time.now + + job.args.first['arguments'].first['_aj_ruby2_keywords'].delete('object') + job.args.first['arguments'].first['_aj_ruby2_keywords'] << 'object_id' + + object = job.args.first['arguments'].first.delete('object')['_aj_globalid'] + job.args.first['arguments'].first['object_id'] = object.split('/').last + + job.save + end + end + + def down; end +end diff --git a/db/migrate/20240313204105_brs_decompressor_corrupted_source_error.rb b/db/migrate/20240313204105_brs_decompressor_corrupted_source_error.rb new file mode 100644 index 00000000..e22d759b --- /dev/null +++ b/db/migrate/20240313204105_brs_decompressor_corrupted_source_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Comprueba que se pueden volver a correr las tareas que dieron error de +# decompresión +class BrsDecompressorCorruptedSourceError < ActiveRecord::Migration[6.1] + def up + raise unless HTTParty.get('https://mas.to/api/v2/instance', + headers: { 'Accept-Encoding': 'br;q=1.0,gzip;q=1.0,deflate;q=0.6,identity;q=0.3' }).ok? + + QueJob.where("last_error_message like '%BRS::DecompressorCorruptedSourceError%'").update_all(error_count: 0, + run_at: Time.now) + end + + def down; end +end diff --git a/db/migrate/20240314141536_remove_actor_moderations_for_generic_objects.rb b/db/migrate/20240314141536_remove_actor_moderations_for_generic_objects.rb new file mode 100644 index 00000000..a60e755a --- /dev/null +++ b/db/migrate/20240314141536_remove_actor_moderations_for_generic_objects.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Elimina actores que no pudieron ser eliminades porque su perfil ya no +# existe. +class RemoveActorModerationsForGenericObjects < ActiveRecord::Migration[6.1] + def up + object_ids = ActivityPub.removed.where(object_type: 'ActivityPub::Object::Generic').distinct.pluck(:object_id) + uris = ActivityPub::Object.where(id: object_ids).pluck(:uri) + actor_ids = ActivityPub::Actor.where(uri: uris).ids + + ActorModeration.where(actor_id: actor_ids).remove_all! + end + + def down; end +end diff --git a/db/migrate/20240314153017_fix_object_type_on_activity_pubs.rb b/db/migrate/20240314153017_fix_object_type_on_activity_pubs.rb new file mode 100644 index 00000000..d5475f71 --- /dev/null +++ b/db/migrate/20240314153017_fix_object_type_on_activity_pubs.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# Arregla la relación rota entre ActivityPub y Objects +class FixObjectTypeOnActivityPubs < ActiveRecord::Migration[6.1] + def up + ActivityPub::Object.where.not(type: 'ActivityPub::Object::Generic').find_each do |object| + ActivityPub.where(object_id: object.id).update_all(object_type: object.type, updated_at: Time.now) + end + end + + def down; end +end diff --git a/db/migrate/20240314205923_fix_activity_type.rb b/db/migrate/20240314205923_fix_activity_type.rb new file mode 100644 index 00000000..e6640ff8 --- /dev/null +++ b/db/migrate/20240314205923_fix_activity_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Soportar nuevos tipos +class FixActivityType < ActiveRecord::Migration[6.1] + def up + %w[Like Announce].each do |type| + ActivityPub::Activity.where(Arel.sql("content->>'type' = '#{type}'")).update_all( + type: "ActivityPub::Activity::#{type}", updated_at: Time.now + ) + end + end + + def down; end +end diff --git a/db/migrate/20240316203721_add_mention_to_actors.rb b/db/migrate/20240316203721_add_mention_to_actors.rb new file mode 100644 index 00000000..caa4f526 --- /dev/null +++ b/db/migrate/20240316203721_add_mention_to_actors.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Guarda la mención en la tabla de actores +class AddMentionToActors < ActiveRecord::Migration[6.1] + def up + add_column :activity_pub_actors, :mention, :string, null: true + + actor_types = %w[ + ActivityPub::Object::Application + ActivityPub::Object::Group + ActivityPub::Object::Organization + ActivityPub::Object::Person + ActivityPub::Object::Service + ] + + ActivityPub::Object.where(type: actor_types).where.not(content: {}).find_each do |object| + ActivityPub::Actor.find_by_uri(object.uri)&.save + end + end + + def down + remove_column :activity_pub_actors, :mention + end +end diff --git a/db/migrate/20240318183846_fix_duplicate_objects.rb b/db/migrate/20240318183846_fix_duplicate_objects.rb new file mode 100644 index 00000000..9f02c3db --- /dev/null +++ b/db/migrate/20240318183846_fix_duplicate_objects.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# De alguna forma se guardaron objetos duplicados! +class FixDuplicateObjects < ActiveRecord::Migration[6.1] + def up + ActivityPub::Object.group(:uri).count.select { |_, v| v > 1 }.each_key do |uri| + objects = ActivityPub::Object.where(uri: uri) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(object_id: deleted_ids).update_all(object_id: objects.first.id, updated_at: Time.now) + end + end + + def down; end +end diff --git a/db/migrate/20240319124212_add_fedipact_to_fediblocks.rb b/db/migrate/20240319124212_add_fedipact_to_fediblocks.rb new file mode 100644 index 00000000..f751123a --- /dev/null +++ b/db/migrate/20240319124212_add_fedipact_to_fediblocks.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +# Agrega threads.net a las listas de bloqueo +class AddFedipactToFediblocks < ActiveRecord::Migration[6.1] + def up + change_column :activity_pub_fediblocks, :download_url, :string, null: true + + fedipact = + ActivityPub::Fediblock.create( + hostnames: %w[threads.net], + title: 'Fedipact', + url: 'https://fedipact.online/', + format: 'none' + ) + + DeploySocialDistributedPress.find_each do |deploy| + FediblockState.create(site: deploy.site, fediblock: fedipact, aasm_state: 'disabled').tap(&:enable!) + end + end + + def down + fedipact = ActivityPub::Fediblock.find_by(url: 'https://fedipact.online/').delete + FediblockState.where(fediblock_id: fedipact.id).delete_all + change_column :activity_pub_fediblocks, :download_url, :string, null: false + end +end diff --git a/db/migrate/20240319144735_add_missing_unique_indexes.rb b/db/migrate/20240319144735_add_missing_unique_indexes.rb new file mode 100644 index 00000000..2f6ef1aa --- /dev/null +++ b/db/migrate/20240319144735_add_missing_unique_indexes.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +# Parece que la sintaxis que veníamos usando para los índices únicos ya +# no es válida y por eso teníamos objetos duplicados. +class AddMissingUniqueIndexes < ActiveRecord::Migration[6.1] + def up + ActivityPub::Object.group(:uri).count.select { |_, v| v > 1 }.each_key do |uri| + objects = ActivityPub::Object.where(uri: uri) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(object_id: deleted_ids).update_all(object_id: objects.first.id, updated_at: Time.now) + end + + ActivityPub::Actor.group(:uri).count.select { |_, v| v > 1 }.each_key do |uri| + objects = ActivityPub::Actor.where(uri: uri) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + ActorModeration.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + ActivityPub::Activity.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + ActivityPub::RemoteFlag.where(actor_id: deleted_ids).update_all(actor_id: objects.first.id, updated_at: Time.now) + end + + ActivityPub::Instance.group(:hostname).count.select { |_, v| v > 1 }.each_key do |hostname| + objects = ActivityPub::Instance.where(hostname: hostname) + deleted_ids = objects[1..].map(&:delete).map(&:id) + + ActivityPub.where(instance_id: deleted_ids).update_all(instance_id: objects.first.id, updated_at: Time.now) + InstanceModeration.where(instance_id: deleted_ids).update_all(instance_id: objects.first.id, updated_at: Time.now) + ActivityPub::Actor.where(instance_id: deleted_ids).update_all(instance_id: objects.first.id, updated_at: Time.now) + end + + remove_index :activity_pub_instances, :hostname + remove_index :activity_pub_actors, :uri + add_index :activity_pub_instances, :hostname, unique: true + add_index :activity_pub_objects, :uri, unique: true + add_index :activity_pub_actors, :uri, unique: true + end + + def down + remove_index :activity_pub_instances, :hostname, unique: true + remove_index :activity_pub_objects, :uri, unique: true + remove_index :activity_pub_actors, :uri, unique: true + add_index :activity_pub_instances, :hostname + add_index :activity_pub_objects, :uri + add_index :activity_pub_actors, :uri + end +end diff --git a/db/seeds.rb b/db/seeds.rb index b9ef96a1..41474883 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -20,10 +20,16 @@ if CodeOfConduct.count.zero? YAML.safe_load(File.read('db/seeds/codes_of_conduct.yml')).each do |coc| CodeOfConduct.new(**coc).save! end -end +end if PrivacyPolicy.count.zero? YAML.safe_load(File.read('db/seeds/privacy_policies.yml')).each do |pp| PrivacyPolicy.new(**pp).save! end -end +end + +YAML.safe_load(File.read('db/seeds/activity_pub/fediblocks.yml')).each do |fediblock| + ActivityPub::Fediblock.find_or_create_by(id: fediblock['id']).tap do |f| + f.update(**fediblock) + end +end diff --git a/db/seeds/activity_pub/fediblocks.yml b/db/seeds/activity_pub/fediblocks.yml new file mode 100644 index 00000000..c977f9bf --- /dev/null +++ b/db/seeds/activity_pub/fediblocks.yml @@ -0,0 +1,16 @@ +--- +- title: "Gardenfence" + url: "https://gardenfence.github.io/" + download_url: "https://github.com/gardenfence/blocklist/raw/main/gardenfence-fediblocksync.csv" + format: "fediblock" + id: "9046789a-5de8-4b16-beed-796060f8f3cc" +- title: "Oliphant Tier 0" + url: "https://writer.oliphant.social/oliphant/the-oliphant-social-blocklist" + download_url: "https://codeberg.org/oliphant/blocklists/raw/branch/main/blocklists/mastodon/tier0.csv" + format: "mastodon" + id: "fc1efcb8-7e68-4a76-ae9e-0c447752b12b" +- title: "The Bad Space (90%)" + url: "https://tweaking.thebad.space/exports" + download_url: "https://tweaking.thebad.space/exports/mastodon/90" + format: "fediblock" + id: "5dd6705a-c28f-4912-9456-07b0d4983108" diff --git a/db/seeds/blocklists.yml b/db/seeds/blocklists.yml deleted file mode 100644 index 54dfe6c9..00000000 --- a/db/seeds/blocklists.yml +++ /dev/null @@ -1,7 +0,0 @@ ---- -- id: gardenfence - title: Gardenfence - link: 'https://gardenfence.github.io/' -- id: lista - title: Lista - link: '#' diff --git a/db/seeds/instances.yaml b/db/seeds/instances.yaml deleted file mode 100644 index bf326832..00000000 --- a/db/seeds/instances.yaml +++ /dev/null @@ -1,285 +0,0 @@ ---- -- domain: todon.nl - title: Todon.nl - version: 4.2.3 - source_url: https://github.com/mastodon/mastodon - description: Radicaal linkse anti-autoritaire server. Voor anarchisten, socialisten, - (klimaat)activisten, LHBTQIA+, antiracisten, antifascisten, antikapitalisten, - intersectionelen, veganisten, mensenrechten, enz. - usage: - users: - active_month: 372 - thumbnail: - url: https://todon.nl/system/site_uploads/files/000/000/004/@1x/297e509bc8a81f62.png - blurhash: UXAw3zN4M|xsoga#WBay9DxntQRmITocofWE - versions: - "@1x": https://todon.nl/system/site_uploads/files/000/000/004/@1x/297e509bc8a81f62.png - "@2x": https://todon.nl/system/site_uploads/files/000/000/004/@2x/297e509bc8a81f62.png - languages: - - en - configuration: - urls: - streaming: wss://todon.nl - status: https://status.todon.eu - accounts: - max_featured_tags: 10 - statuses: - max_characters: 1312 - max_media_attachments: 4 - characters_reserved_per_url: 23 - media_attachments: - supported_mime_types: - - image/jpeg - - image/png - - image/gif - - image/heic - - image/heif - - image/webp - - image/avif - - video/webm - - video/mp4 - - video/quicktime - - video/ogg - - audio/wave - - audio/wav - - audio/x-wav - - audio/x-pn-wave - - audio/vnd.wave - - audio/ogg - - audio/vorbis - - audio/mpeg - - audio/mp3 - - audio/webm - - audio/flac - - audio/aac - - audio/m4a - - audio/x-m4a - - audio/mp4 - - audio/3gpp - - video/x-ms-asf - image_size_limit: 16777216 - image_matrix_limit: 33177600 - video_size_limit: 103809024 - video_frame_rate_limit: 120 - video_matrix_limit: 8294400 - polls: - max_options: 4 - max_characters_per_option: 50 - min_expiration: 300 - max_expiration: 2629746 - translation: - enabled: true - registrations: - enabled: false - approval_required: false - message: | -

¡No pasarán!

- -

Je kunt tijdelijk geen nieuw account op Todon.nl aanvragen.

- - - -

Ga naar joinmastodon.org of FediDB Network om een andere server te vinden.

- -

It is temporary not possible to request on account on Todon.nl.

- - - -

Go to joinmastodon.org or FediDB Network to find another server.

- url: - max_toot_chars: 1312 - contact: - email: todon@posteo.eu - account: - id: '1' - username: admin - acct: admin - display_name: "Admin \U0001F913 Todon.nl (mod)" - locked: false - bot: false - discoverable: false - group: false - created_at: '2017-04-28T00:00:00.000Z' - note: "

This account is used for \U0001F399 Todon.nl announcements and ⚖️ - moderation.

\U0001F6AB Don't follow this account when you are not - on Todon.nl.

New? First read our \U0001F469‍\U0001F3EB Todon 101 \U0001F469‍\U0001F393 - at https://wiki.todon.eu/todon/101

⚖️ - For all our moderators go to https://wiki.todon.nl/todon/moderators

\U0001F4DD Public toots from this account - are in English.

\U0001F515 Criticism is fine, but people who do false - accusations are muted.

✉ todon@posteo.eu

#nobot

" - url: https://todon.nl/@admin - uri: https://todon.nl/users/admin - avatar: https://todon.nl/system/accounts/avatars/000/000/001/original/2db61726225ed3e6.png - avatar_static: https://todon.nl/system/accounts/avatars/000/000/001/original/2db61726225ed3e6.png - header: https://todon.nl/system/accounts/headers/000/000/001/original/fb3a846cbc20aa09.png - header_static: https://todon.nl/system/accounts/headers/000/000/001/original/fb3a846cbc20aa09.png - followers_count: 3164 - following_count: 8 - statuses_count: 724 - last_status_at: '2024-01-12' - noindex: true - emojis: [] - roles: - - id: '3' - name: Admin - color: "#595aff" - fields: - - name: "\U0001F4DC Terms of Service" - value: wiki.todon.nl/todon/terms_en - verified_at: '2018-11-01T14:39:45.465+00:00' - - name: ℹ️ Wiki - value: wiki.todon.nl/todon/informatio - verified_at: '2018-11-01T14:40:54.679+00:00' - - name: "\U0001F4CA Status" - value: status.todon.eu - verified_at: '2023-10-26T20:38:30.185+00:00' - - name: "\U0001F4B3️ Donations" - value: wiki.todon.eu/todon/donations - verified_at: '2022-11-02T00:06:31.865+00:00' - rules: - - id: '1' - text: We do not accept racism (in all its forms, incl. hate against Muslims, antisemitism, - apartheid and casteism - see our Terms of Service for our complete definition). - - id: '2' - text: We do not accept hate against lesbians, gays, bisexuals, pansexuals, transgenders, - non-binary people, intersexual people, queer people in general, etc. - - id: '4' - text: Sexism, misogyny and hate against black women (misogynoir). - - id: '6' - text: We do not accept ableism (incl. COVID-19 denial/downplaying and anti-vax) - and body-shaming. - - id: '8' - text: We do not accept harassment and trolling. - - id: '10' - text: We also do not accept other forms of hate speech. - - id: '11' - text: We do not accept (sexual) abuse of minors, adults and animals (also not - virtual). - - id: '13' - text: We do not accept glorification of violence, calls for murder, death threats, - terrorism and militarism. - - id: '15' - text: We do not accept (neo)colonialism (incl. Zionism), imperialism in all forms - and nationalism (above all nationalism of nation states, incl. flags/symbols - of those on Todon.*, see our Terms of Service). - - id: '16' - text: We do not accept fascism, right-wing populism, and right-wing and religious - extremism. - - id: '17' - text: We do not accept evangelisation and other forms of religious propaganda - [local only], and extreme sects and cults. - - id: '19' - text: We do not accept Marxist-Leninists, Stalinists, Maoists or other followers - of extreme authoritarian (so called) communist/socialist ideologies/regimes - (aka tankies). - - id: '20' - text: We do not accept capitalists, including so called 'anarcho-capitalists' - (aka ancaps) and neoliberals. - - id: '21' - text: We do not accept anthropogenic climate change denial, downplaying the climate - crisis, greenwashing and deceptive climate solutions (like nuclear energy). - - id: '27' - text: We do not accept (right-wing) conspiracy 'theories', hoaxes, fake news and - other forms of disinformation. - - id: '28' - text: Another rule in our terms of service at wiki.todon.eu/todon/terms_en. Explain - in the final step. -- uri: mastodon.mauve.moe - title: Mauvestodon - short_description: Escape ship from centralized social media run by Mauve. - description: Chat about random techie and anarchist stuff. - email: contact@mauve.moe - version: 3.5.10 - urls: - streaming_api: wss://mastodon.mauve.moe - stats: - user_count: 12 - status_count: 3287 - domain_count: 11625 - thumbnail: https://mastodon.mauve.moe/system/site_uploads/files/000/000/001/original/mauvesoftwareinc.png - languages: - - en - registrations: false - approval_required: false - invites_enabled: true - configuration: - statuses: - max_characters: 500 - max_media_attachments: 4 - characters_reserved_per_url: 23 - media_attachments: - supported_mime_types: - - image/jpeg - - image/png - - image/gif - - video/webm - - video/mp4 - - video/quicktime - - video/ogg - - audio/wave - - audio/wav - - audio/x-wav - - audio/x-pn-wave - - audio/ogg - - audio/vorbis - - audio/mpeg - - audio/mp3 - - audio/webm - - audio/flac - - audio/aac - - audio/m4a - - audio/x-m4a - - audio/mp4 - - audio/3gpp - - video/x-ms-asf - image_size_limit: 10485760 - image_matrix_limit: 16777216 - video_size_limit: 41943040 - video_frame_rate_limit: 60 - video_matrix_limit: 2304000 - polls: - max_options: 4 - max_characters_per_option: 50 - min_expiration: 300 - max_expiration: 2629746 - contact_account: - id: '1' - username: admin - acct: admin - display_name: '' - locked: false - bot: false - discoverable: true - group: false - created_at: '2022-04-25T00:00:00.000Z' - note: '' - url: https://mastodon.mauve.moe/@admin - avatar: https://mastodon.mauve.moe/system/accounts/avatars/000/000/001/original/8c21e71667b48a95.png - avatar_static: https://mastodon.mauve.moe/system/accounts/avatars/000/000/001/original/8c21e71667b48a95.png - header: https://mastodon.mauve.moe/headers/original/missing.png - header_static: https://mastodon.mauve.moe/headers/original/missing.png - followers_count: 0 - following_count: 0 - statuses_count: 0 - last_status_at: '2023-01-30' - emojis: [] - fields: - - name: Alternatel Contact - value: @mauve - verified_at: - rules: [] diff --git a/db/seeds/moderation_queue.yaml b/db/seeds/moderation_queue.yaml deleted file mode 100644 index c7075c7e..00000000 --- a/db/seeds/moderation_queue.yaml +++ /dev/null @@ -1,153 +0,0 @@ ---- -- "@context": - - https://www.w3.org/ns/activitystreams - - ostatus: http://ostatus.org# - atomUri: ostatus:atomUri - inReplyToAtomUri: ostatus:inReplyToAtomUri - conversation: ostatus:conversation - sensitive: as:sensitive - toot: http://joinmastodon.org/ns# - votersCount: toot:votersCount - Hashtag: as:Hashtag - id: https://mastodon.mauve.moe/users/mauve/statuses/111462305634770041 - type: Note - summary: - inReplyTo: https://mastodon.mauve.moe/users/mauve/statuses/111461923538534886 - published: '2023-11-23T22:50:10Z' - url: https://mastodon.mauve.moe/@mauve/111462305634770041 - attributedTo: https://mastodon.mauve.moe/users/mauve - to: - - https://www.w3.org/ns/activitystreams#Public - cc: - - https://mastodon.mauve.moe/users/mauve/followers - - https://hypha.coop/about.jsonld - sensitive: false - atomUri: https://mastodon.mauve.moe/users/mauve/statuses/111462305634770041 - inReplyToAtomUri: https://mastodon.mauve.moe/users/mauve/statuses/111461923538534886 - conversation: tag:mastodon.mauve.moe,2023-11-23:objectId=551471:objectType=Conversation - content:

Follow @HyphaCoop@hypha.coop for our announcement post on the 5th!

-

- contentMap: - en:

Follow @HyphaCoop@hypha.coop for our announcement post on the 5th!

-

- attachment: [] - tag: - - type: Mention - href: https://hypha.coop/about.jsonld - name: "@dripline@hypha.coop" - - type: Hashtag - href: https://mastodon.mauve.moe/tags/p2p - name: "#p2p" - - type: Hashtag - href: https://mastodon.mauve.moe/tags/activitypub - name: "#activitypub" - - type: Hashtag - href: https://mastodon.mauve.moe/tags/fediverse - name: "#fediverse" - replies: - id: https://mastodon.mauve.moe/users/mauve/statuses/111462305634770041/replies - type: Collection - first: - type: CollectionPage - next: https://mastodon.mauve.moe/users/mauve/statuses/111462305634770041/replies?only_other_accounts=true&page=true - partOf: https://mastodon.mauve.moe/users/mauve/statuses/111462305634770041/replies - items: [] -- "@context": - - https://www.w3.org/ns/activitystreams - - "@language": es - sensitive: as:sensitive - type: Note - id: https://sutty.nl/lanzamiento-de-publicaciones-distribuidas-en-el-fediverso-a-trav%C3%A9s-de-sutty/ - summary: Lanzamiento de publicaciones distribuidas en el Fediverso a través de Sutty - published: '2023-12-04T21:53:05+00:00' - updated: '2023-12-05T20:41:34+00:00' - attributedTo: https://sutty.nl/about.jsonld - to: - - https://www.w3.org/ns/activitystreams#Public - cc: - - https://social.distributed.press/v1/@sutty@sutty.nl/followers - inReplyTo: https://hypha.coop/dripline/announcing-dp-social-inbox/ - sensitive: true - content: | -

Estamos felices y orgulloses de anunciar el lanzamiento de la funcionalidad que permite la publicación en el Fediverso de los artículos de todos los sitios creados a través de Sutty.

Gracias al trabajo conjunto con Distributed Press, Hypha y apoyado por la Filecoin Foundation for the Distributed Web, Sutty hace posible que la seguridad de tu sitio estático se combine con la rápida difusión de tu contenido a través de las redes sociales libres y descentralizadas que constituyen el Fediverso.

Esto se logró a través del desarollo y la integración de dos componentes, trabajados en forma conjunta y colaborativa:

    -
  1. Social Inbox, desarrollado principalmente Distributed Press. Aporta la funcionalidad de recibir artículos, responder y mencionar otras cuentas en el Fediverso.

  2. -
  3. Jekyll Activity Pub Plugin, desarrollado principalmente por Sutty. Permite integrar Social Inbox en todos los sitios estáticos generados en Jekyll, admitiendo así la publicación automática de contenido del sitio en el Fediverso.

  4. -

Sutty integra la funcionalidad completa en su CMS para sitios estáticos en Jekyll, permitiendo gestionarla desde una interfaz en continua mejora de su usabilidad.

Si todavía no estás familiarizade con estos nombres y conceptos, te invitamos a conocer más a continuación, en la sección “Para tecno-curioses”.

Qué significa

    -
  • Que si creás tu sitio web a través de Sutty, tenés nuevas posibilidades de difundir tus contenidos e interactuar en redes digitales.

  • -
  • Que tus artículos pueden ser publicados en las redes del Fediverso.

  • -
  • Que tu sitio tendrá un perfil o usuarie personalizable desde el panel en una instancia de Sutty propia.

  • -
  • Que les usuaries del Fediverso pueden seguir tus publicaciones.

  • -
  • Que les usuaries del Fediverso que te sigan podrán leer tus publicaciones, mencionarte y responderte.

  • -
  • Que podrás interactuar con les usuaries del Fediverse con las opciones de responderles y mencionarles.

  • -

Qué permite hacer

    -
  • Activar la publicación en el Fediverso para todos los sitios de Sutty.

  • -
  • Activar la publicación de los artículos que quieras en el Fediverso.

  • -
  • Responder comentarios desde los artículos de tu sitio en Sutty.

  • -
  • Personalizar la cuenta que Sutty crea automáticamente de tu sitio en el Fediverso.

  • -
  • Reportar o informar de usuaries o instancias abusivos mediante nuestro formulario de contacto.

  • -

Qué se viene

    -
  • Mejoras en la integración de las respuestas como comentarios en el sitio.

  • -
  • Incorporación de menciones desde el panel de Sutty.

  • -
  • Mejoras en la interfaz general del panel.

  • -
  • Nuevas implementaciones para una mejor moderación.

  • -
  • Mejor compatibilidad con diversas redes en el Fediverso (Mastodon+Glitch, Pleroma, Ktistec).

  • -
  • Mejoras que permitirán diferenciar el contenido a publicar en el Fediverso y en el sitio de Sutty.

  • -
  • La posibilidad de exportar tu cuenta a una instancia del Fediverso desde tu panel.

  • -
  • La posibilidad de que Sutty anuncie tu contenido y/o usuarie del Fediverse en forma automática para atraer seguidorxs. (Ahora, podés hacerlo a través de un formulario).

  • -
  • Acceder a la lista de seguidorxs y seguides desde tu panel.

  • -
  • Seguir, dejar de seguir, bloquear usuaries y/o instancias del Fediverso desde el panel.

  • -

¡Quiero usarlo!

Te invitamos a dar tus primeros pasos de la mano de nuestro tutorial.

Para tecno-curioses

Cómo funciona

Los sitios web y las redes sociales parecen ser especies distintas dentro del Universo de Internet. Al mismo tiempo, las redes sociales corporativas y concentradas como Instagram, Facebook, X (ex Twitter), entre otras, demostraron ser hostiles con algunos grupos o colectivos sociales en particular (censurando contenido, persiguiendo pezones, ocultando publicaciones por color de piel y de pelo, etc.) y con todes sus usuaries en general (vendiendo data en forma masiva, violando acuerdos de privacidad, eligiendo diseños de interfaz y uso que generan ansiedad y adicción, etc.). Pese a esto, siguen funcionando como espacios obligados a la hora de publicitar un emprendimiento o difundir noticias urgentes.

El Fediverso es una red federada, descentralizada y distribuida de redes sociales libres, cada una con sus características, preferencias, grupos de usuaries. Están diseñadas para facilitar el diálogo entre todas ellas. Es decir, para que los contenidos puedan ser visibles y se puedan generar respuestas entre usuaries, fomentando una cultura de participación y pluralidad de voces, basadas en estándares de desarrollo libre y que buscan ser éticos antes que con fines de lucro sin fin.

Los sitios web siguen siendo formatos para medios de comunicación que, debido a sus características, favorecen la difusión de contenidos como artículos multimedia. Permiten adecuar un estilo a una identidad visual del medio, mantener secciones y contenido institucional variado, entre otras cosas.

Las redes sociales se destacan por sus características de inmediatez, favoreciendo un flujo dialógico en tiempo real con otros tiempos de atención y características de navegación que lo hacen más breve, rápido, a veces efímero. Los medios de comunicación (personas o emprendimientos mediáticos) suelen utilizarlos para llamar la atención sobre contenidos publicados en sus sitios, apostando a la divulgación rápida y las discusiones que puedan darse entre usuaries.

La funcionalidad que desarrollamos en Sutty contempla los casos de uso en los que un contenido quiera ser compartido a más personas, en menor tiempo, con la posibilidad de generar diálogos. Las particularidades de nuestros sitios y redes sociales libres generan condiciones favorables para la libertad de expresión, que preferimos llamar Derecho a la Comunicación, evadiendo las variadas y cada vez más sofisticadas formas de censura de las plataformas corporativas tradicionales. Un contenido reproducido en varios lugares al mismo tiempo ayuda a su divulgación y es ideal para aquellas voces y discursos contrahegemónicos en la web y su supervivencia al paso del tiempo, preservando la memoria popular.

Cómo funciona el Fediverso en la moderación

El Fediverso intenta funcionar como comunidades en línea interconectadas que se autogobiernan en las formas de cuidados colectivos. Así, cada instancia podría ser algo así como un municipio que aloja diferentes cuentas/usuaries bajo unas reglas consensuadas y que pueden ser puestas en discusión si fuera necesario. De esta forma, es posible regular la circulación de contenidos fascistas y discursos de odio que puedan dañar no solamente la participación de diverses usuaries sino también su salud.

Para ello, cada instancia elige sus formas de moderación y puede excluir otras instancias con denuncias previas de contenidos antidemocráticos, odiantes o contrarios a los valores y cuidados de sus habitantes.

En Sutty en particular, nos interesan las estrategias y los mecanismos de cuidados colectivos, por lo que seguimos diseñando modelos que permitan sostenerlos en nuestras tecnologías. Podés revisar nuestros términos y condiciones, política de privacidad y acuerdos de convivencia para más información.

¿Te interesa participar?

Si sos parte de una organización social, grupo de activismo o colectivo social que pensás que podría beneficiarse de estas características, te invitamos a contactarnos a través de nuestro formulario. Estamos busando mejorar los usos de las tecnologías para ustedes y valoramos sus experiencias.

Otras posibilidades de integración de Social Inbox en sitios estáticos

Si te interesa incorporar esta funcionalidad para otros gestores de sitios estáticos, no dudes en contactarnos. Además, mantenete al tanto de las novedades que compartimos en https://dweb.sutty.nl y en nuestro blog https://sutty.nl/blog

Recomendado para saber más

- name: Lanzamiento de publicaciones distribuidas en el Fediverso a través de Sutty - contentMap: - es: | -

Estamos felices y orgulloses de anunciar el lanzamiento de la funcionalidad que permite la publicación en el Fediverso de los artículos de todos los sitios creados a través de Sutty.

Gracias al trabajo conjunto con Distributed Press, Hypha y apoyado por la Filecoin Foundation for the Distributed Web, Sutty hace posible que la seguridad de tu sitio estático se combine con la rápida difusión de tu contenido a través de las redes sociales libres y descentralizadas que constituyen el Fediverso.

Esto se logró a través del desarollo y la integración de dos componentes, trabajados en forma conjunta y colaborativa:

    -
  1. Social Inbox, desarrollado principalmente Distributed Press. Aporta la funcionalidad de recibir artículos, responder y mencionar otras cuentas en el Fediverso.

  2. -
  3. Jekyll Activity Pub Plugin, desarrollado principalmente por Sutty. Permite integrar Social Inbox en todos los sitios estáticos generados en Jekyll, admitiendo así la publicación automática de contenido del sitio en el Fediverso.

  4. -

Sutty integra la funcionalidad completa en su CMS para sitios estáticos en Jekyll, permitiendo gestionarla desde una interfaz en continua mejora de su usabilidad.

Si todavía no estás familiarizade con estos nombres y conceptos, te invitamos a conocer más a continuación, en la sección “Para tecno-curioses”.

Qué significa

    -
  • Que si creás tu sitio web a través de Sutty, tenés nuevas posibilidades de difundir tus contenidos e interactuar en redes digitales.

  • -
  • Que tus artículos pueden ser publicados en las redes del Fediverso.

  • -
  • Que tu sitio tendrá un perfil o usuarie personalizable desde el panel en una instancia de Sutty propia.

  • -
  • Que les usuaries del Fediverso pueden seguir tus publicaciones.

  • -
  • Que les usuaries del Fediverso que te sigan podrán leer tus publicaciones, mencionarte y responderte.

  • -
  • Que podrás interactuar con les usuaries del Fediverse con las opciones de responderles y mencionarles.

  • -

Qué permite hacer

    -
  • Activar la publicación en el Fediverso para todos los sitios de Sutty.

  • -
  • Activar la publicación de los artículos que quieras en el Fediverso.

  • -
  • Responder comentarios desde los artículos de tu sitio en Sutty.

  • -
  • Personalizar la cuenta que Sutty crea automáticamente de tu sitio en el Fediverso.

  • -
  • Reportar o informar de usuaries o instancias abusivos mediante nuestro formulario de contacto.

  • -

Qué se viene

    -
  • Mejoras en la integración de las respuestas como comentarios en el sitio.

  • -
  • Incorporación de menciones desde el panel de Sutty.

  • -
  • Mejoras en la interfaz general del panel.

  • -
  • Nuevas implementaciones para una mejor moderación.

  • -
  • Mejor compatibilidad con diversas redes en el Fediverso (Mastodon+Glitch, Pleroma, Ktistec).

  • -
  • Mejoras que permitirán diferenciar el contenido a publicar en el Fediverso y en el sitio de Sutty.

  • -
  • La posibilidad de exportar tu cuenta a una instancia del Fediverso desde tu panel.

  • -
  • La posibilidad de que Sutty anuncie tu contenido y/o usuarie del Fediverse en forma automática para atraer seguidorxs. (Ahora, podés hacerlo a través de un formulario).

  • -
  • Acceder a la lista de seguidorxs y seguides desde tu panel.

  • -
  • Seguir, dejar de seguir, bloquear usuaries y/o instancias del Fediverso desde el panel.

  • -

¡Quiero usarlo!

Te invitamos a dar tus primeros pasos de la mano de nuestro tutorial.

Para tecno-curioses

Cómo funciona

Los sitios web y las redes sociales parecen ser especies distintas dentro del Universo de Internet. Al mismo tiempo, las redes sociales corporativas y concentradas como Instagram, Facebook, X (ex Twitter), entre otras, demostraron ser hostiles con algunos grupos o colectivos sociales en particular (censurando contenido, persiguiendo pezones, ocultando publicaciones por color de piel y de pelo, etc.) y con todes sus usuaries en general (vendiendo data en forma masiva, violando acuerdos de privacidad, eligiendo diseños de interfaz y uso que generan ansiedad y adicción, etc.). Pese a esto, siguen funcionando como espacios obligados a la hora de publicitar un emprendimiento o difundir noticias urgentes.

El Fediverso es una red federada, descentralizada y distribuida de redes sociales libres, cada una con sus características, preferencias, grupos de usuaries. Están diseñadas para facilitar el diálogo entre todas ellas. Es decir, para que los contenidos puedan ser visibles y se puedan generar respuestas entre usuaries, fomentando una cultura de participación y pluralidad de voces, basadas en estándares de desarrollo libre y que buscan ser éticos antes que con fines de lucro sin fin.

Los sitios web siguen siendo formatos para medios de comunicación que, debido a sus características, favorecen la difusión de contenidos como artículos multimedia. Permiten adecuar un estilo a una identidad visual del medio, mantener secciones y contenido institucional variado, entre otras cosas.

Las redes sociales se destacan por sus características de inmediatez, favoreciendo un flujo dialógico en tiempo real con otros tiempos de atención y características de navegación que lo hacen más breve, rápido, a veces efímero. Los medios de comunicación (personas o emprendimientos mediáticos) suelen utilizarlos para llamar la atención sobre contenidos publicados en sus sitios, apostando a la divulgación rápida y las discusiones que puedan darse entre usuaries.

La funcionalidad que desarrollamos en Sutty contempla los casos de uso en los que un contenido quiera ser compartido a más personas, en menor tiempo, con la posibilidad de generar diálogos. Las particularidades de nuestros sitios y redes sociales libres generan condiciones favorables para la libertad de expresión, que preferimos llamar Derecho a la Comunicación, evadiendo las variadas y cada vez más sofisticadas formas de censura de las plataformas corporativas tradicionales. Un contenido reproducido en varios lugares al mismo tiempo ayuda a su divulgación y es ideal para aquellas voces y discursos contrahegemónicos en la web y su supervivencia al paso del tiempo, preservando la memoria popular.

Cómo funciona el Fediverso en la moderación

El Fediverso intenta funcionar como comunidades en línea interconectadas que se autogobiernan en las formas de cuidados colectivos. Así, cada instancia podría ser algo así como un municipio que aloja diferentes cuentas/usuaries bajo unas reglas consensuadas y que pueden ser puestas en discusión si fuera necesario. De esta forma, es posible regular la circulación de contenidos fascistas y discursos de odio que puedan dañar no solamente la participación de diverses usuaries sino también su salud.

Para ello, cada instancia elige sus formas de moderación y puede excluir otras instancias con denuncias previas de contenidos antidemocráticos, odiantes o contrarios a los valores y cuidados de sus habitantes.

En Sutty en particular, nos interesan las estrategias y los mecanismos de cuidados colectivos, por lo que seguimos diseñando modelos que permitan sostenerlos en nuestras tecnologías. Podés revisar nuestros términos y condiciones, política de privacidad y acuerdos de convivencia para más información.

¿Te interesa participar?

Si sos parte de una organización social, grupo de activismo o colectivo social que pensás que podría beneficiarse de estas características, te invitamos a contactarnos a través de nuestro formulario. Estamos busando mejorar los usos de las tecnologías para ustedes y valoramos sus experiencias.

Otras posibilidades de integración de Social Inbox en sitios estáticos

Si te interesa incorporar esta funcionalidad para otros gestores de sitios estáticos, no dudes en contactarnos. Además, mantenete al tanto de las novedades que compartimos en https://dweb.sutty.nl y en nuestro blog https://sutty.nl/blog

Recomendado para saber más

- attachment: - - type: Document - mediaType: image/png - url: https://sutty.nl/public/8r7b6ohqy6xzgngxbol6337q8jj9/milestone_2_activity_pub_2.png - name: Botones de colores para activar la "Web Disribuida" y el "Fediverso". diff --git a/db/seeds/remote_profile.yaml b/db/seeds/remote_profile.yaml deleted file mode 100644 index 1a670d6b..00000000 --- a/db/seeds/remote_profile.yaml +++ /dev/null @@ -1,106 +0,0 @@ ---- -"@context": -- https://www.w3.org/ns/activitystreams -- https://w3id.org/security/v1 -- manuallyApprovesFollowers: as:manuallyApprovesFollowers - toot: http://joinmastodon.org/ns# - featured: - "@id": toot:featured - "@type": "@id" - featuredTags: - "@id": toot:featuredTags - "@type": "@id" - alsoKnownAs: - "@id": as:alsoKnownAs - "@type": "@id" - movedTo: - "@id": as:movedTo - "@type": "@id" - schema: http://schema.org# - PropertyValue: schema:PropertyValue - value: schema:value - discoverable: toot:discoverable - Device: toot:Device - Ed25519Signature: toot:Ed25519Signature - Ed25519Key: toot:Ed25519Key - Curve25519Key: toot:Curve25519Key - EncryptedMessage: toot:EncryptedMessage - publicKeyBase64: toot:publicKeyBase64 - deviceId: toot:deviceId - claim: - "@type": "@id" - "@id": toot:claim - fingerprintKey: - "@type": "@id" - "@id": toot:fingerprintKey - identityKey: - "@type": "@id" - "@id": toot:identityKey - devices: - "@type": "@id" - "@id": toot:devices - messageFranking: toot:messageFranking - messageType: toot:messageType - cipherText: toot:cipherText - suspended: toot:suspended - focalPoint: - "@container": "@list" - "@id": toot:focalPoint -id: https://mastodon.mauve.moe/users/mauve -type: Person -following: https://mastodon.mauve.moe/users/mauve/following -followers: https://mastodon.mauve.moe/users/mauve/followers -inbox: https://mastodon.mauve.moe/users/mauve/inbox -outbox: https://mastodon.mauve.moe/users/mauve/outbox -featured: https://mastodon.mauve.moe/users/mauve/collections/featured -featuredTags: https://mastodon.mauve.moe/users/mauve/collections/tags -preferredUsername: mauve -name: "Mauve \U0001F441\U0001F49C" -summary: "

Occult Enby that's making local-first software with peer to peer - protocols, mesh networks, and the web.

Also exploring what a local-first - cyberspace might look like in my spare time.

" -url: https://mastodon.mauve.moe/@mauve -manuallyApprovesFollowers: false -discoverable: true -published: '2022-04-25T00:00:00Z' -devices: https://mastodon.mauve.moe/users/mauve/collections/devices -alsoKnownAs: -- https://infosec.exchange/users/RangerMauve -publicKey: - id: https://mastodon.mauve.moe/users/mauve#main-key - owner: https://mastodon.mauve.moe/users/mauve - publicKeyPem: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxjxu6bRQOjH4caQu7JgZ - umIWFeX0ZdbVnofElev2d9JByqcDoWhmaks3RYdW71RDPNrr0JxqZvUbIw9kQBng - 7iQ9YTcXTdJ/N9CQoB22msffYkEIw4ilehCDXdchNs4aoVAUwI8IhkM0p/itz6gK - 75C3CQv74Y7rHUJC8ob2p4KUwRUyhgzyhp8QWwCAn/RZ28wP8EbjWF9IskMRo9vq - WUX+Io6hpADRkSwZGoOSW2zxCEBVco6tRmABTte8I0WcAucLyMEyfGMlUvxRew4D - zAWoEBS8SyqM68vUabbZYLns6kya34tvsf1NkvajDGrfgU3D0LlGX++tOa6N9Pkf - XwIDAQAB - -----END PUBLIC KEY----- -tag: [] -attachment: -- type: PropertyValue - name: Pronouns - value: they/them/it -- type: PropertyValue - name: Email - value: mauve@mauve.moe -- type: PropertyValue - name: Matrix - value: @mauve:mauve.moe -- type: PropertyValue - name: Github/Twitter - value: "@RangerMauve" -endpoints: - sharedInbox: https://mastodon.mauve.moe/inbox -icon: - type: Image - mediaType: image/png - url: https://mastodon.mauve.moe/system/accounts/avatars/000/000/002/original/e4b910cee121b1b8.png -image: - type: Image - mediaType: image/png - url: https://mastodon.mauve.moe/system/accounts/headers/000/000/002/original/a96f990025091662.png diff --git a/db/structure.sql b/db/structure.sql index cb085f63..21cf04d0 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -473,6 +473,129 @@ CREATE SEQUENCE public.active_storage_variant_records_id_seq ALTER SEQUENCE public.active_storage_variant_records_id_seq OWNED BY public.active_storage_variant_records.id; +-- +-- Name: activity_pub_activities; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pub_activities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + activity_pub_id uuid NOT NULL, + type character varying NOT NULL, + uri character varying NOT NULL, + content jsonb DEFAULT '{}'::jsonb, + actor_id uuid +); + + +-- +-- Name: activity_pub_actors; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pub_actors ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + instance_id uuid NOT NULL, + uri character varying NOT NULL, + mention character varying +); + + +-- +-- Name: activity_pub_fediblocks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pub_fediblocks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + title character varying NOT NULL, + url character varying NOT NULL, + download_url character varying, + format character varying NOT NULL, + hostnames jsonb DEFAULT '[]'::jsonb +); + + +-- +-- Name: activity_pub_instances; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pub_instances ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + hostname character varying NOT NULL, + aasm_state character varying NOT NULL, + content jsonb DEFAULT '{}'::jsonb +); + + +-- +-- Name: activity_pub_objects; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pub_objects ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + type character varying NOT NULL, + uri character varying NOT NULL, + content jsonb DEFAULT '{}'::jsonb +); + + +-- +-- Name: activity_pub_remote_flags; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pub_remote_flags ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + site_id bigint, + actor_id uuid, + message text, + content jsonb, + aasm_state character varying DEFAULT 'waiting'::character varying NOT NULL +); + + +-- +-- Name: activity_pubs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.activity_pubs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + site_id bigint NOT NULL, + object_id uuid NOT NULL, + object_type character varying NOT NULL, + aasm_state character varying NOT NULL, + instance_id uuid, + actor_id uuid, + remote_flag_id uuid +); + + +-- +-- Name: actor_moderations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.actor_moderations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + site_id bigint, + actor_id uuid, + aasm_state character varying NOT NULL, + remote_flag_id uuid +); + + -- -- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: - -- @@ -759,7 +882,8 @@ CREATE TABLE public.deploys ( updated_at timestamp without time zone NOT NULL, site_id integer, type character varying, - "values" text + "values" text, + rol_id integer ); @@ -854,6 +978,20 @@ CREATE SEQUENCE public.distributed_press_publishers_id_seq ALTER SEQUENCE public.distributed_press_publishers_id_seq OWNED BY public.distributed_press_publishers.id; +-- +-- Name: fediblock_states; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.fediblock_states ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + site_id bigint, + fediblock_id uuid, + aasm_state character varying +); + + -- -- Name: indexed_posts; Type: TABLE; Schema: public; Owner: - -- @@ -876,6 +1014,39 @@ CREATE TABLE public.indexed_posts ( ); +-- +-- Name: instance_moderations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.instance_moderations ( + id bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + site_id bigint, + instance_id uuid, + aasm_state character varying DEFAULT 'paused'::character varying NOT NULL +); + + +-- +-- Name: instance_moderations_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.instance_moderations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: instance_moderations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.instance_moderations_id_seq OWNED BY public.instance_moderations.id; + + -- -- Name: licencias; Type: TABLE; Schema: public; Owner: - -- @@ -1440,6 +1611,13 @@ ALTER TABLE ONLY public.designs ALTER COLUMN id SET DEFAULT nextval('public.desi ALTER TABLE ONLY public.distributed_press_publishers ALTER COLUMN id SET DEFAULT nextval('public.distributed_press_publishers_id_seq'::regclass); +-- +-- Name: instance_moderations id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.instance_moderations ALTER COLUMN id SET DEFAULT nextval('public.instance_moderations_id_seq'::regclass); + + -- -- Name: licencias id; Type: DEFAULT; Schema: public; Owner: - -- @@ -1564,6 +1742,70 @@ ALTER TABLE ONLY public.active_storage_variant_records ADD CONSTRAINT active_storage_variant_records_pkey PRIMARY KEY (id); +-- +-- Name: activity_pub_activities activity_pub_activities_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pub_activities + ADD CONSTRAINT activity_pub_activities_pkey PRIMARY KEY (id); + + +-- +-- Name: activity_pub_actors activity_pub_actors_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pub_actors + ADD CONSTRAINT activity_pub_actors_pkey PRIMARY KEY (id); + + +-- +-- Name: activity_pub_fediblocks activity_pub_fediblocks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pub_fediblocks + ADD CONSTRAINT activity_pub_fediblocks_pkey PRIMARY KEY (id); + + +-- +-- Name: activity_pub_instances activity_pub_instances_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pub_instances + ADD CONSTRAINT activity_pub_instances_pkey PRIMARY KEY (id); + + +-- +-- Name: activity_pub_objects activity_pub_objects_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pub_objects + ADD CONSTRAINT activity_pub_objects_pkey PRIMARY KEY (id); + + +-- +-- Name: activity_pub_remote_flags activity_pub_remote_flags_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pub_remote_flags + ADD CONSTRAINT activity_pub_remote_flags_pkey PRIMARY KEY (id); + + +-- +-- Name: activity_pubs activity_pubs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.activity_pubs + ADD CONSTRAINT activity_pubs_pkey PRIMARY KEY (id); + + +-- +-- Name: actor_moderations actor_moderations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.actor_moderations + ADD CONSTRAINT actor_moderations_pkey PRIMARY KEY (id); + + -- -- Name: blazer_audits blazer_audits_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1652,6 +1894,14 @@ ALTER TABLE ONLY public.distributed_press_publishers ADD CONSTRAINT distributed_press_publishers_pkey PRIMARY KEY (id); +-- +-- Name: fediblock_states fediblock_states_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fediblock_states + ADD CONSTRAINT fediblock_states_pkey PRIMARY KEY (id); + + -- -- Name: indexed_posts indexed_posts_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1660,6 +1910,14 @@ ALTER TABLE ONLY public.indexed_posts ADD CONSTRAINT indexed_posts_pkey PRIMARY KEY (id); +-- +-- Name: instance_moderations instance_moderations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.instance_moderations + ADD CONSTRAINT instance_moderations_pkey PRIMARY KEY (id); + + -- -- Name: licencias licencias_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -1864,6 +2122,76 @@ CREATE UNIQUE INDEX index_active_storage_blobs_on_key_and_service_name ON public CREATE UNIQUE INDEX index_active_storage_variant_records_uniqueness ON public.active_storage_variant_records USING btree (blob_id, variation_digest); +-- +-- Name: index_activity_pub_activities_on_activity_pub_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_activity_pub_activities_on_activity_pub_id ON public.activity_pub_activities USING btree (activity_pub_id); + + +-- +-- Name: index_activity_pub_actors_on_instance_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_activity_pub_actors_on_instance_id ON public.activity_pub_actors USING btree (instance_id); + + +-- +-- Name: index_activity_pub_actors_on_uri; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_activity_pub_actors_on_uri ON public.activity_pub_actors USING btree (uri); + + +-- +-- Name: index_activity_pub_instances_on_hostname; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_activity_pub_instances_on_hostname ON public.activity_pub_instances USING btree (hostname); + + +-- +-- Name: index_activity_pub_objects_on_uri; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_activity_pub_objects_on_uri ON public.activity_pub_objects USING btree (uri); + + +-- +-- Name: index_activity_pub_remote_flags_on_actor_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_activity_pub_remote_flags_on_actor_id ON public.activity_pub_remote_flags USING btree (actor_id); + + +-- +-- Name: index_activity_pub_remote_flags_on_site_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_activity_pub_remote_flags_on_site_id ON public.activity_pub_remote_flags USING btree (site_id); + + +-- +-- Name: index_activity_pub_remote_flags_on_site_id_and_actor_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_activity_pub_remote_flags_on_site_id_and_actor_id ON public.activity_pub_remote_flags USING btree (site_id, actor_id); + + +-- +-- Name: index_actor_moderations_on_actor_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_actor_moderations_on_actor_id ON public.actor_moderations USING btree (actor_id); + + +-- +-- Name: index_actor_moderations_on_site_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_actor_moderations_on_site_id ON public.actor_moderations USING btree (site_id); + + -- -- Name: index_blazer_audits_on_query_id; Type: INDEX; Schema: public; Owner: - -- @@ -1955,6 +2283,27 @@ CREATE UNIQUE INDEX index_designs_on_gem ON public.designs USING btree (gem); CREATE UNIQUE INDEX index_designs_on_name ON public.designs USING btree (name); +-- +-- Name: index_fediblock_states_on_fediblock_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fediblock_states_on_fediblock_id ON public.fediblock_states USING btree (fediblock_id); + + +-- +-- Name: index_fediblock_states_on_site_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fediblock_states_on_site_id ON public.fediblock_states USING btree (site_id); + + +-- +-- Name: index_fediblock_states_on_site_id_and_fediblock_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_fediblock_states_on_site_id_and_fediblock_id ON public.fediblock_states USING btree (site_id, fediblock_id); + + -- -- Name: index_indexed_posts_on_front_matter; Type: INDEX; Schema: public; Owner: - -- @@ -1990,6 +2339,27 @@ CREATE INDEX index_indexed_posts_on_locale ON public.indexed_posts USING btree ( CREATE INDEX index_indexed_posts_on_site_id ON public.indexed_posts USING btree (site_id); +-- +-- Name: index_instance_moderations_on_instance_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_instance_moderations_on_instance_id ON public.instance_moderations USING btree (instance_id); + + +-- +-- Name: index_instance_moderations_on_site_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_instance_moderations_on_site_id ON public.instance_moderations USING btree (site_id); + + +-- +-- Name: index_instance_moderations_on_site_id_and_instance_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_instance_moderations_on_site_id_and_instance_id ON public.instance_moderations USING btree (site_id, instance_id); + + -- -- Name: index_licencias_on_name; Type: INDEX; Schema: public; Owner: - -- @@ -2318,6 +2688,37 @@ INSERT INTO "schema_migrations" (version) VALUES ('20230731195050'), ('20230829204127'), ('20230921155401'), -('20230927153926'); +('20230927153926'), +('20240216170202'), +('20240219153919'), +('20240219175839'), +('20240219204011'), +('20240219204224'), +('20240220161414'), +('20240221184007'), +('20240223170317'), +('20240226133022'), +('20240226134335'), +('20240227134845'), +('20240227142019'), +('20240228171335'), +('20240228202830'), +('20240229201155'), +('20240301181224'), +('20240301194154'), +('20240301202955'), +('20240305164653'), +('20240305184854'), +('20240307201510'), +('20240307203039'), +('20240313192134'), +('20240313204105'), +('20240314141536'), +('20240314153017'), +('20240314205923'), +('20240316203721'), +('20240318183846'), +('20240319124212'), +('20240319144735'); diff --git a/lib/tasks/activity_pub.rake b/lib/tasks/activity_pub.rake new file mode 100644 index 00000000..08c0f980 --- /dev/null +++ b/lib/tasks/activity_pub.rake @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +namespace :activity_pub do + desc 'Update Fediblocks' + task fediblocks: :environment do |_, args| + ActivityPub::FediblockFetchJob.perform_later + end +end diff --git a/monit.conf b/monit.conf index accd0e28..2b7e50a8 100644 --- a/monit.conf +++ b/monit.conf @@ -9,6 +9,11 @@ check program distributed_press_tokens_renew every "0 3 * * *" if status != 0 then alert +check program fediblocks + with path "/usr/bin/foreman run -f /srv/Procfile -d /srv fediblocks" as uid "rails" gid "www-data" + every "0 7 * * *" + if status != 0 then alert + check program access_logs with path "/srv/http/bin/access_logs" as uid "app" and gid "www-data" every "0 0 * * *"