5
0
Fork 0
mirror of https://0xacab.org/sutty/sutty synced 2024-11-22 13:56:22 +00:00

Merge branch 'issue-15109-1' into 'rails'

activitypub

Closes #15549, #15548, #15547, #15546, #15545, #15544, #15543, #15542, #15541, #15540, #15539, #15538, #15537, #15536, #15535, #15534, #15533, #15532, #15531, #15530, #15529, #15528, #15527, #15526, #15525, #15524, #15523, #15522, #15521, #15520, #15519, #15518, #15517, #15516, #15515, #15514, #15513, #15512, #15511, #15510, #15509, #15508, #15507, #15506, #15505, #15504, #15503, #15502, #15501, #15500, #15499, #15498, #15497, #15496, #15495, #15494, #15493, #15492, #15491, #15490, #15489, #15488, #15487, #15486, #15485, #15484, #15483, #15482, #15481, #15480, #15479, #15478, #15477, #15473, #15472, #15471, #15470, #15469, #15468, #15467, #15466, #15465, #15464, #15463, #15462, #15461, #15460, #15459, #15458, #15457, #15456, #15455, #15454, #15453, #15452, #15451, #15450, #15449, #15448, #15447, #15446, #15445, #15444, #15443, #15442, #15441, #15440, #15439, #15438, #15437, #15436, #15435, #15434, #15433, #15432, #15431, #15430, #15429, #15428, #15427, #15426, #15425, #15424, #15423, #15422, #15421, #15420, #15419, #15418, #15417, #15416, #15415, #15414, #15413, #15412, #15411, #15410, #15409, #15408, #15407, #15406, #15405, #15404, #15403, #15402, #15401, #15400, #15399, #15398, #15397, #15396, #15395, #15394, #15393, #15392, #15391, #15390, #15389, #15388, #15387, #15386, #15385, #15384, #15383, #15664, #15663, #15662, #15661, #15660, #15658, #15657, #15656, #15737, #15738, #15739, #15759, #15758, #15757, #15756, #15755, #15754, #15753, #15752, #15751, #15750, #15749, #15748, #15747, #15746, #15745, #15744, #15743, #15742, #15741, #15740, #15764, #15765, #15766, #15767, #15770, #15771, #15772, #15773, #15775, #15789, #15791, #15788, #15818, #15817, #15816, #15815, #15814, #15813, #15812, #15811, #15810, #15809, #15808, #15807, #15806, #15805, #15804, #15803, #15802, #15801, #15800, #15799, #15798, #15797, #15796, #15795, #15794, #15793, #15792, #15882, #15839, #15838, #15832, #15831, #15830, #15829, #15828, #15827, #15824, #15776, #15736, #15735, #15731, #15730, #15729, #15623, #15622, #15621, #15618, #15612, #15352, #15351, #15382, #15381, #15380, #15379, #15378, #15377, #15376, #15375, #15374, #15373, #15372, #15371, #15370, #15369, #15368, #15367, #15366, #15553, #15554, #15363, #15564, #15567, #15560, #15559, #15558, #15557, #15556, #15555, #15593, #15708, #15707, #15706, #15705, #15704, #15702, #15701, #15700, #15699, #15698, #15697, #15696, #15695, #15694, #15693, #15692, #15691, #15690, #15689, #15688, #15687, #15686, #15685, #15684, #15683, #15682, #15681, #15680, #15679, #15678, #15677, #15676, #15675, #15674, #15673, #15672, #15671, #15670, #15669, #15668, #15667, #15666, and #15665

See merge request sutty/sutty!253
This commit is contained in:
fauno 2024-05-02 19:06:23 +00:00
commit 239b43153d
180 changed files with 4408 additions and 1164 deletions

2
.env
View file

@ -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

10
Gemfile
View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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;
}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,16 +24,6 @@ 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
@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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);
}
}

View file

@ -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;
});
}
}

View file

@ -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

View file

@ -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<String>]
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<String>]
# @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

View file

@ -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

View file

@ -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

View file

@ -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<String>]
# @return [Array<String>]
def wildcardize(hostnames)
hostnames.map do |hostname|
"@*@#{hostname}"
end
end
end
end

View file

@ -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<Integer>]
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

152
app/models/activity_pub.rb Normal file
View file

@ -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<Hash> o
# Array<String> 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

View file

@ -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

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ActivityPub
class Activity
# Boost
class Announce < ActivityPub::Activity; end
end
end

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ActivityPub
class Activity
class Create < ActivityPub::Activity; end
end
end

View file

@ -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

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ActivityPub
class Activity
class Flag < ActivityPub::Activity; end
end
end

View file

@ -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

View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class ActivityPub
class Activity
class Generic < ActivityPub::Activity; end
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class ActivityPub
class Activity
# Like
class Like < ActivityPub::Activity; end
end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Article =
#
# Representa artículos
class ActivityPub
class Object
class Article < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Audio =
#
# Representa artículos
class ActivityPub
class Object
class Audio < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Document =
#
# Representa artículos
class ActivityPub
class Object
class Document < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Event =
#
# Representa artículos
class ActivityPub
class Object
class Event < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
# = Generic =
class ActivityPub
class Object
class Generic < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Group =
class ActivityPub
class Object
class Group < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Image =
#
# Representa artículos
class ActivityPub
class Object
class Image < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Page =
#
# Representa artículos
class ActivityPub
class Object
class Page < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Place =
#
# Representa artículos
class ActivityPub
class Object
class Place < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Profile =
#
# Representa artículos
class ActivityPub
class Object
class Profile < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Relationship =
#
# Representa artículos
class ActivityPub
class Object
class Relationship < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Service =
class ActivityPub
class Object
class Service < ActivityPub::Object
include Concerns::ActorTypeConcern
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Tombstone =
#
# Representa artículos
class ActivityPub
class Object
class Tombstone < ActivityPub::Object; end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
# = Video =
#
# Representa artículos
class ActivityPub
class Object
class Video < ActivityPub::Object; end
end
end

View file

@ -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

View file

@ -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

View file

@ -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<Symbol>]
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<Symbol>]
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<Symbol>]
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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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<String>]
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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,3 @@
# frozen_string_literal: true
ModerationQueue = Struct.new(:site)

5
app/models/que_job.rb Normal file
View file

@ -0,0 +1,5 @@
# frozen_string_literal: true
require 'que/active_record/model'
class QueJob < Que::ActiveRecord::Model; end

View file

@ -11,6 +11,7 @@ class Rol < ApplicationRecord
belongs_to :usuarie
belongs_to :site
has_many :deploys
validates_inclusion_of :rol, in: ROLES

View file

@ -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

View file

@ -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

View file

@ -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

108
app/models/social_inbox.rb Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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']

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more