Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

FEATURE: Allow Categories to follow remote Actors #43

Merged
merged 74 commits into from Dec 14, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
74 commits
Select commit Hold shift + click to select a range
aed2d3b
wip
angusmcleod Oct 30, 2023
6d3d8a7
move status label to follow btn
angusmcleod Oct 30, 2023
94b8a03
cleanup
angusmcleod Oct 30, 2023
7309649
Linting fixes
angusmcleod Oct 30, 2023
1bc0d23
More linting fixes
angusmcleod Oct 30, 2023
6ab41a2
Fix failing specs
angusmcleod Oct 30, 2023
23436a0
Move to table in followers route
angusmcleod Oct 31, 2023
ee63acb
Merge remote-tracking branch 'upstream/main' into add_category_banner
angusmcleod Oct 31, 2023
309cd5a
Add activity_pub_post_object_type to serialized category fields
angusmcleod Oct 31, 2023
a48d7b9
Improve actor list ordering
angusmcleod Oct 31, 2023
68402b6
Improve category_controller readability
angusmcleod Oct 31, 2023
bb63c01
Only add navitem in AP categories
angusmcleod Oct 31, 2023
a0a1122
Followers table UI tweak
angusmcleod Oct 31, 2023
13b7e2e
Add category enabled check to category controller
angusmcleod Oct 31, 2023
822c9df
Update and fix frontend tests
angusmcleod Nov 1, 2023
644875a
Add follow acceptance tests
angusmcleod Nov 1, 2023
6c1377b
Remove ember decorators from visibility dropdown
angusmcleod Nov 2, 2023
d17ce61
Update category nav text
angusmcleod Nov 2, 2023
11df0bb
Update banner in category federation page
pmusaraj Nov 3, 2023
8e926e0
Complete banner changes and tests
angusmcleod Nov 9, 2023
49b935b
Remove show handle setting
angusmcleod Nov 9, 2023
5f1a66d
Remove show status setting
angusmcleod Nov 9, 2023
7e24cfd
Serialize followers without users
angusmcleod Nov 14, 2023
3d32999
Convert helpers into glimmer components
angusmcleod Nov 14, 2023
154c56b
Fix flaky acceptance test
angusmcleod Nov 14, 2023
3e39109
Add leading @ to handles
angusmcleod Nov 14, 2023
4ff4267
Update config/locales/client.en.yml
angusmcleod Nov 14, 2023
6a2b2bb
Update config/locales/client.en.yml
angusmcleod Nov 14, 2023
26365d6
Add category follow backend
angusmcleod Nov 13, 2023
e6ae0aa
Ensure remote actor is available when we attempt to follow
angusmcleod Nov 13, 2023
236e306
Add webfinger handle class
angusmcleod Nov 14, 2023
4d3969a
Working follow creation
angusmcleod Nov 15, 2023
548b2a2
Working category follow
angusmcleod Nov 16, 2023
8bb520c
Move all plugin logic out of AP and into callbacks
angusmcleod Nov 17, 2023
1937256
Fixes to accommodate core style changes
angusmcleod Nov 17, 2023
fb61663
Convert properly addressed non-reply notes into topics
angusmcleod Nov 18, 2023
82f3917
Don't add … to parsed titles
angusmcleod Nov 18, 2023
4dddcaf
Working Discourse to Discourse topic creation
angusmcleod Nov 20, 2023
3c58f09
Fix failing tests
angusmcleod Nov 21, 2023
2dc27fc
Set published_at immediately prior to delivery
angusmcleod Nov 21, 2023
9d9550f
Merge remote-tracking branch 'upstream/main' into allow_categories_to…
angusmcleod Nov 21, 2023
87c5383
Add topic followers concept
angusmcleod Nov 21, 2023
f93baad
Fix imports in banner
angusmcleod Nov 21, 2023
8e27a6f
Add shared inbox for users and update addressing
angusmcleod Nov 23, 2023
2406b11
Minor fixes
angusmcleod Nov 23, 2023
a06755a
Allow create if target following parent (announce) actor
angusmcleod Nov 23, 2023
261e993
Ensure we have the right object converted to json
angusmcleod Nov 23, 2023
0c77f70
Ensure topic actors are present
angusmcleod Nov 23, 2023
6f058aa
Pass recipient ids instead of recipient objects
angusmcleod Nov 23, 2023
02f82b6
Use name attribute to handle topic title
angusmcleod Nov 23, 2023
1cb7cd5
Catch duplicate activities at the validation stage
angusmcleod Nov 23, 2023
8cb0045
Move addressing into the plugin's models
angusmcleod Nov 24, 2023
9dc2959
Add follows discovery qunit tests
angusmcleod Nov 24, 2023
a3aa2b2
ActivityPub Category frontend fixes
angusmcleod Nov 27, 2023
4843398
Add topic collection handling, shared user inbox and activity forwarding
angusmcleod Dec 2, 2023
a68006e
Merge remote-tracking branch 'upstream/main' into allow_categories_to…
angusmcleod Dec 2, 2023
c47daf8
DEV: now necessary to clear parent ids in category perf specs
angusmcleod Dec 2, 2023
88b0125
DEV: specs hitting NewPostManager now need require auto groups
angusmcleod Dec 2, 2023
11868b4
Tweak follow find input css
angusmcleod Dec 5, 2023
7250103
FIX: ensure post actions are delivered to contributors as well as fol…
angusmcleod Dec 5, 2023
71e781c
Also forward activities to collection contributors
angusmcleod Dec 5, 2023
b658cf6
Only deliver and forward to remote contributors
angusmcleod Dec 5, 2023
4345296
FIX: ensure activity forwarder does not set object model custom fields
angusmcleod Dec 5, 2023
2f2fb17
Fix flaky spec
angusmcleod Dec 5, 2023
4c6efa9
Remove unnecessary null guard
angusmcleod Dec 6, 2023
8929155
Fix spelling mistake
angusmcleod Dec 6, 2023
0a11d85
Improve webfinger method names
angusmcleod Dec 6, 2023
22aa576
Update app/controllers/discourse_activity_pub/ap/activities_controlle…
angusmcleod Dec 8, 2023
64bb558
Update spec/lib/discourse_activity_pub/follow_handler_spec.rb
angusmcleod Dec 8, 2023
97d601f
Update spec/lib/discourse_activity_pub/follow_handler_spec.rb
angusmcleod Dec 8, 2023
63432c4
Update spec/lib/discourse_activity_pub/ap/activity/announce_spec.rb
angusmcleod Dec 8, 2023
a40acf8
Remove removed Category cache class method
angusmcleod Dec 8, 2023
a8ecd94
Support incoming Announce > Note
angusmcleod Dec 13, 2023
2f1e693
Update db/migrate/20231213105108_add_attributed_to_to_discourse_activ…
angusmcleod Dec 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -189,7 +189,7 @@ def actor_from_key_id(key_id)
actor = DiscourseActivityPubActor.find_by(ap_id: ap_id)

if !actor
ap_actor = AP::Actor.resolve_and_store(ap_id, stored: false)
ap_actor = AP::Actor.resolve_and_store(ap_id)
actor = ap_actor.stored if ap_actor
end
end
Expand Down
69 changes: 69 additions & 0 deletions app/controllers/discourse_activity_pub/actor_controller.rb
@@ -0,0 +1,69 @@
# frozen_string_literal: true

module DiscourseActivityPub
class ActorController < ApplicationController
before_action :ensure_admin
before_action :ensure_site_enabled
before_action :find_actor
before_action :find_target_actor, only: [:follow, :unfollow]

def follow
if !@actor.can_follow?(@target_actor)
return render_actor_error("cant_follow_target_actor", 401)
end

if FollowHandler.follow(@actor.id, @target_actor.id)
render json: success_json
else
render json: failed_json
end
end

def unfollow
if !@actor.following?(@target_actor)
return render_actor_error("not_following_target_actor", 404)
end

if FollowHandler.unfollow(@actor.id, @target_actor.id)
render json: success_json
else
render json: failed_json
end
end

def find_by_handle
params.require(:handle)

handle_actor = DiscourseActivityPubActor.find_by_handle(params[:handle])

if handle_actor
handle_actor_follow = handle_actor.follow_followers.find_by(follower_id: @actor.id)
handle_actor.followed_at = handle_actor_follow.followed_at if handle_actor_follow

render_serialized(handle_actor, DiscourseActivityPub::ActorSerializer)
else
render json: failed_json
end
end

protected

def find_actor
@actor = DiscourseActivityPubActor.find_by_id(params.require(:actor_id))
return render_actor_error("actor_not_found", 404) unless @actor.present?
end

def find_target_actor
@target_actor = DiscourseActivityPubActor.find_by_id(params[:target_actor_id])
return render_actor_error("target_actor_not_found", 404) unless @target_actor.present?
end

def ensure_site_enabled
render_actor_error("not_enabled", 403) unless DiscourseActivityPub.enabled
end

def render_actor_error(key, status)
render_json_error I18n.t("discourse_activity_pub.actor.error.#{key}"), status: status
end
end
end
Expand Up @@ -2,6 +2,7 @@

class DiscourseActivityPub::AP::ActivitiesController < DiscourseActivityPub::AP::ObjectsController
before_action :ensure_activity_exists
before_action :ensure_can_access_base_object

def show
render json: @activity.ap.json
Expand All @@ -10,6 +11,14 @@ def show
protected

def ensure_activity_exists
render_activity_pub_error("not_found", 404) unless @activity = DiscourseActivityPubActivity.find_by(ap_key: params[:key])
unless @activity = DiscourseActivityPubActivity.find_by(ap_key: params[:key])
render_activity_pub_error("not_found", 404)
end
end

def ensure_can_access_base_object
if @activity.base_object.nil? || !guardian.can_see?(@activity.base_object.model)
render_activity_pub_error("not_available", 401)
end
end
end
@@ -0,0 +1,22 @@
# frozen_string_literal: true

class DiscourseActivityPub::AP::CollectionsController < DiscourseActivityPub::AP::ObjectsController
before_action :ensure_collection_exists
before_action :ensure_can_access_collection

def show
render json: @collection.ap.json
end

protected

def ensure_collection_exists
unless @collection = DiscourseActivityPubCollection.find_by(ap_key: params[:key], local: true)
render_activity_pub_error("not_found", 404)
end
end

def ensure_can_access_collection
render_activity_pub_error("not_available", 401) unless guardian.can_see?(@collection.model)
end
end
Expand Up @@ -20,6 +20,6 @@ def rate_limit
end

def process_json
Jobs.enqueue(:discourse_activity_pub_process, json: @json)
Jobs.enqueue(:discourse_activity_pub_process, json: @json, delivered_to: @actor.ap_id)
end
end
@@ -0,0 +1,25 @@
# frozen_string_literal: true

class DiscourseActivityPub::AP::SharedInboxesController < DiscourseActivityPub::AP::ObjectsController
def create
@json = validate_json_ld(request.body.read)

if @json
process_json
head 202
else
render_activity_pub_error("json_not_valid", 422)
end
end

protected

def rate_limit
limit = SiteSetting.activity_pub_rate_limit_post_to_inbox_per_minute
RateLimiter.new(nil, "activity-pub-inbox-post-min-#{request.remote_ip}", limit, 1.minute).performed!
end

def process_json
Jobs.enqueue(:discourse_activity_pub_process, json: @json)
end
end
64 changes: 50 additions & 14 deletions app/controllers/discourse_activity_pub/category_controller.rb
Expand Up @@ -12,31 +12,67 @@ class CategoryController < ApplicationController
def index
end

def follows
guardian.ensure_can_edit!(@category)

actors.each do |actor|
actor.followed_at = actor.follow_followers&.first.followed_at
end

render_actors
end

def followers
guardian.ensure_can_see!(@category)

followers = @category
.activity_pub_followers
.joins(:follow_follows)
.where(follow_follows: { followed_id: @category.activity_pub_actor.id })
.left_joins(:user)
.order("#{order_table}.#{order} #{params[:asc] ? "ASC" : "DESC"} NULLS LAST")
actors.each do |actor|
actor.followed_at = actor.follow_follows&.first.followed_at
end

render_actors
end

limit = fetch_limit_from_params(default: PAGE_SIZE, max: PAGE_SIZE)
page = fetch_int_from_params(:page, default: 0)
total = followers.count
followers = followers.limit(limit).offset(limit * page).to_a
protected

def render_actors
render_json_dump(
followers: serialize_data(followers, FollowerSerializer, root: false),
actors: serialize_data(actors, ActorSerializer, root: false),
meta: {
total: total,
load_more_url: load_more_url(page),
total: @total,
load_more_url: load_more_url(@page),
}
)
end

protected
def actors
@actors ||= begin
actors = self.send("#{action_name}_actors")
.left_joins(:user)
.order("#{order_table}.#{order} #{params[:asc] ? "ASC" : "DESC"} NULLS LAST")

limit = fetch_limit_from_params(default: PAGE_SIZE, max: PAGE_SIZE)
@page = fetch_int_from_params(:page, default: 0)
@total = actors.count

actors.limit(limit).offset(limit * @page).to_a
end
end

def follows_actors
@follows_actors ||=
@category
.activity_pub_follows
.joins(:follow_followers)
.where(follow_followers: { follower_id: @category.activity_pub_actor.id })
end

def followers_actors
@followers_actors ||=
@category
.activity_pub_followers
.joins(:follow_follows)
.where(follow_follows: { followed_id: @category.activity_pub_actor.id })
end

def permitted_order
@permitted_order ||= ORDER.find { |attr| attr == params[:order] }
Expand Down
@@ -0,0 +1,15 @@
# frozen_string_literal: true

module DiscourseActivityPub
class Webfinger
class HandleController < WebfingerController
def validate
params.require(:handle)

handle = Webfinger::Handle.new(handle: params[:handle])

render json: { valid: handle.valid? }
end
end
end
end
Expand Up @@ -5,7 +5,7 @@ class WebfingerController < ApplicationController
skip_before_action :preload_json, :redirect_to_login_if_required, :check_xhr

before_action :ensure_site_enabled
before_action :find_resource
before_action :find_resource, only: [:index]

def index
# TODO: is this Cache Control correct for webfinger?
Expand Down
57 changes: 40 additions & 17 deletions app/jobs/discourse_activity_pub_deliver.rb
Expand Up @@ -11,25 +11,27 @@ class DiscourseActivityPubDeliver < ::Jobs::Base
def execute(args)
@args = args
return unless perform_request?
before_perform_request
perform_request
after_perform_request
end

private

def perform_request
@performed = false
@delivered = false
retry_count = @args[:retry_count] || 0

# TODO (future): use request in a Request Pool
request = DiscourseActivityPub::Request.new(
actor_id: from_actor.id,
uri: to_actor.inbox,
body: delivery_object.ap.json
uri: @args[:send_to],
body: delivery_json
)

# TODO (future): raise redirects from Request and resolve with FinalDestination
if request&.post_json_ld
@performed = true
@delivered = true
else
retry_count += 1
return if retry_count > MAX_RETRY_COUNT
Expand All @@ -38,10 +40,11 @@ def perform_request
::Jobs.enqueue_in(delay.minutes, :discourse_activity_pub_deliver, @args.merge(retry_count: retry_count))
end
ensure
if @performed
if @delivered
log_success
failure_tracker.track_success
object.after_deliver
else
log_failure
failure_tracker.track_failure
end
end
Expand All @@ -55,27 +58,26 @@ def perform_request?
end

def has_required_args?
%i[object_id object_type from_actor_id to_actor_id].all? { |s| @args.key? s }
%i[from_actor_id send_to].all? { |key| @args[key].present? }
end

def failure_tracker
@failure_tracker ||= DiscourseActivityPub::DeliveryFailureTracker.new(to_actor.inbox)
@failure_tracker ||= DiscourseActivityPub::DeliveryFailureTracker.new(@args[:send_to])
end

def object
@object ||= @args[:object_type].constantize.find_by(id: @args[:object_id])
@object ||= begin
return nil unless @args[:object_type] && @args[:object_id]
@args[:object_type].constantize.find_by(id: @args[:object_id])
end
end

def from_actor
@from_actor ||= DiscourseActivityPubActor.find_by(id: @args[:from_actor_id])
end

def to_actor
@to_actor ||= DiscourseActivityPubActor.find_by(id: @args[:to_actor_id])
end

def actors_ready?
from_actor&.ready? && to_actor&.ready?
from_actor&.ready?
end

def object_ready?
Expand All @@ -93,7 +95,7 @@ def delivery_object
if announcing?
begin
object.announce!(from_actor.id)
object.announcement
object
rescue PG::UniqueViolation, ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
log_failure(e.message)
end
Expand All @@ -103,10 +105,31 @@ def delivery_object
end
end

def log_failure(message)
def delivery_json
@delivery_json ||= begin
final_object = announcing? ? delivery_object.announcement : delivery_object
final_object.ap.json
end
end

def log_failure(message = "Failed to POST")
return false unless SiteSetting.activity_pub_verbose_logging
prefix = "#{from_actor.ap_id} failed to deliver #{object&.ap_id}"
prefix = "#{from_actor.ap_id} failed to deliver #{JSON.generate(delivery_json)}"
Rails.logger.warn("[Discourse Activity Pub] #{prefix}: #{message}")
end

def log_success
return false unless SiteSetting.activity_pub_verbose_logging
prefix = "JSON delivered to #{@args[:send_to]}"
Rails.logger.warn("[Discourse Activity Pub] #{prefix}: #{JSON.generate(delivery_json)}")
end

def before_perform_request
object.before_deliver if object.present?
end

def after_perform_request
object.after_deliver(@delivered) if object.present?
end
end
end