diff --git a/app/controllers/discourse_activity_pub/nodeinfo_controller.rb b/app/controllers/discourse_activity_pub/nodeinfo_controller.rb new file mode 100644 index 00000000..b0c3351b --- /dev/null +++ b/app/controllers/discourse_activity_pub/nodeinfo_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DiscourseActivityPub + class NodeinfoController < ApplicationController + requires_plugin DiscourseActivityPub::PLUGIN_NAME + + include DiscourseActivityPub::EnabledVerification + + skip_before_action :preload_json, :check_xhr + + before_action :ensure_site_enabled + + def index + render json: Nodeinfo.index, content_type: "application/jrd+json" + end + + def show + nodeinfo = Nodeinfo.new(params[:version]) + raise Discourse::NotFound unless nodeinfo.supported_version? + render_serialized(nodeinfo, NodeinfoSerializer, root: false) + end + end +end diff --git a/app/serializers/discourse_activity_pub/nodeinfo_serializer.rb b/app/serializers/discourse_activity_pub/nodeinfo_serializer.rb new file mode 100644 index 00000000..025ff564 --- /dev/null +++ b/app/serializers/discourse_activity_pub/nodeinfo_serializer.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module DiscourseActivityPub + class NodeinfoSerializer < ActiveModel::Serializer + attributes :version, :software, :protocols, :services, :usage, :openRegistrations, :metadata + + def software + format(object.software).as_json + end + + def services + format(object.services).as_json + end + + def usage + format(object.usage).as_json + end + + def openRegistrations + object.open_registrations + end + + def metadata + format(object.metadata).as_json + end + + protected + + def format(hash) + hash.deep_transform_keys do |key| + case key + when :active_half_year + "activeHalfyear" + else + key.to_s.camelize(:lower) + end + end + end + end +end diff --git a/lib/discourse_activity_pub/nodeinfo.rb b/lib/discourse_activity_pub/nodeinfo.rb new file mode 100644 index 00000000..bd7e857e --- /dev/null +++ b/lib/discourse_activity_pub/nodeinfo.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module DiscourseActivityPub + class Nodeinfo + include ActiveModel::Serialization + + VERSION = "2.1" + SOFTWARE_NAME = "discourse" + + # See https://github.com/jhass/nodeinfo/blob/main/schemas/2.1/schema.json for supported enums. + SUPPORTED_PROTOCOLS = %w[activitypub] + SUPPORTED_INBOUND_SERVICES = %w[rss2.0 pop3] + SUPPORTED_OUTBOUND_SERVICES = %w[rss2.0 smtp] + + attr_reader :version + + def initialize(version) + @version = version.to_s + end + + def supported_version? + version == VERSION + end + + def software + { name: SOFTWARE_NAME, version: Discourse::VERSION::STRING } + end + + def protocols + SUPPORTED_PROTOCOLS + end + + def services + { inbound: SUPPORTED_INBOUND_SERVICES, outbound: SUPPORTED_OUTBOUND_SERVICES } + end + + def usage + { + users: { + total: ::Statistics.nodeinfo[:users_total], + active_month: ::Statistics.nodeinfo[:users_seen_month], + active_half_year: ::Statistics.nodeinfo[:users_seen_half_year], + }, + local_posts: ::Statistics.nodeinfo[:posts_local], + local_comments: ::Statistics.nodeinfo[:replies_local], + } + end + + def open_registrations + !SiteSetting.login_required + end + + # Compare https://mastodon.social/nodeinfo/2.0 + def metadata + { node_name: SiteSetting.title, node_description: SiteSetting.site_description } + end + + def self.index + { + links: [ + { + rel: "http://nodeinfo.diaspora.software/ns/schema/#{VERSION}", + href: "#{Discourse.base_url_no_prefix}/nodeinfo/#{VERSION}", + }, + ], + }.as_json + end + end +end diff --git a/lib/discourse_activity_pub/statistics.rb b/lib/discourse_activity_pub/statistics.rb new file mode 100644 index 00000000..8fa70322 --- /dev/null +++ b/lib/discourse_activity_pub/statistics.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true +module DiscourseActivityPub + module Statistics + # https://github.com/jhass/nodeinfo/blob/main/schemas/2.2/schema.json#usage + def nodeinfo + { + users_total: active_users.count, + users_seen_half_year: active_users.where("last_seen_at > ?", 180.days.ago).count, + users_seen_month: active_users.where("last_seen_at > ?", 30.days.ago).count, + posts_local: local_posts.where("reply_to_post_number IS NULL").count, + replies_local: local_posts.where("reply_to_post_number IS NOT NULL").count, + } + end + + def active_users + valid_users.where("staged IS FALSE") + end + + def local_posts + ::Post.where("user_id NOT IN (SELECT id FROM users WHERE staged IS TRUE)") + end + end +end diff --git a/plugin.rb b/plugin.rb index 3bacafc1..05d34d52 100644 --- a/plugin.rb +++ b/plugin.rb @@ -36,6 +36,11 @@ module ::DiscourseActivityPub mount ::DiscourseActivityPub::Engine, at: "ap" get ".well-known/webfinger" => "discourse_activity_pub/webfinger#index" + get ".well-known/nodeinfo" => "discourse_activity_pub/nodeinfo#index" + get "/nodeinfo/:version" => "discourse_activity_pub/nodeinfo#show", + :constraints => { + version: /[0-9\.]+/, + } post "/webfinger/handle/validate" => "discourse_activity_pub/webfinger/handle#validate", :defaults => { format: :json, @@ -195,6 +200,7 @@ module ::DiscourseActivityPub add_to_class(:post, field_name.to_sym) { custom_fields[field_name] } end PostAction.prepend DiscourseActivityPub::PostAction + Statistics.singleton_class.prepend DiscourseActivityPub::Statistics ## ## Discourse serialization diff --git a/spec/fixtures/nodeinfo/2.1/schema.json b/spec/fixtures/nodeinfo/2.1/schema.json new file mode 100644 index 00000000..e6daad92 --- /dev/null +++ b/spec/fixtures/nodeinfo/2.1/schema.json @@ -0,0 +1,188 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "id": "http://nodeinfo.diaspora.software/ns/schema/2.1#", + "description": "NodeInfo schema version 2.1.", + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "software", + "protocols", + "services", + "openRegistrations", + "usage", + "metadata" + ], + "properties": { + "version": { + "description": "The schema version, must be 2.1.", + "enum": [ + "2.1" + ] + }, + "software": { + "description": "Metadata about server software in use.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "version" + ], + "properties": { + "name": { + "description": "The canonical name of this server software.", + "type": "string", + "pattern": "^[a-z0-9-]+$" + }, + "version": { + "description": "The version of this server software.", + "type": "string" + }, + "repository": { + "description": "The url of the source code repository of this server software.", + "type": "string" + }, + "homepage": { + "description": "The url of the homepage of this server software.", + "type": "string" + } + } + }, + "protocols": { + "description": "The protocols supported on this server.", + "type": "array", + "minItems": 1, + "items": { + "enum": [ + "activitypub", + "buddycloud", + "dfrn", + "diaspora", + "libertree", + "ostatus", + "pumpio", + "tent", + "xmpp", + "zot" + ] + } + }, + "services": { + "description": "The third party sites this server can connect to via their application API.", + "type": "object", + "additionalProperties": false, + "required": [ + "inbound", + "outbound" + ], + "properties": { + "inbound": { + "description": "The third party sites this server can retrieve messages from for combined display with regular traffic.", + "type": "array", + "minItems": 0, + "items": { + "enum": [ + "atom1.0", + "gnusocial", + "imap", + "pnut", + "pop3", + "pumpio", + "rss2.0", + "twitter" + ] + } + }, + "outbound": { + "description": "The third party sites this server can publish messages to on the behalf of a user.", + "type": "array", + "minItems": 0, + "items": { + "enum": [ + "atom1.0", + "blogger", + "buddycloud", + "diaspora", + "dreamwidth", + "drupal", + "facebook", + "friendica", + "gnusocial", + "google", + "insanejournal", + "libertree", + "linkedin", + "livejournal", + "mediagoblin", + "myspace", + "pinterest", + "pnut", + "posterous", + "pumpio", + "redmatrix", + "rss2.0", + "smtp", + "tent", + "tumblr", + "twitter", + "wordpress", + "xmpp" + ] + } + } + } + }, + "openRegistrations": { + "description": "Whether this server allows open self-registration.", + "type": "boolean" + }, + "usage": { + "description": "Usage statistics for this server.", + "type": "object", + "additionalProperties": false, + "required": [ + "users" + ], + "properties": { + "users": { + "description": "statistics about the users of this server.", + "type": "object", + "additionalProperties": false, + "properties": { + "total": { + "description": "The total amount of on this server registered users.", + "type": "integer", + "minimum": 0 + }, + "activeHalfyear": { + "description": "The amount of users that signed in at least once in the last 180 days.", + "type": "integer", + "minimum": 0 + }, + "activeMonth": { + "description": "The amount of users that signed in at least once in the last 30 days.", + "type": "integer", + "minimum": 0 + } + } + }, + "localPosts": { + "description": "The amount of posts that were made by users that are registered on this server.", + "type": "integer", + "minimum": 0 + }, + "localComments": { + "description": "The amount of comments that were made by users that are registered on this server.", + "type": "integer", + "minimum": 0 + } + } + }, + "metadata": { + "description": "Free form key value pairs for software specific values. Clients should not rely on any specific key present.", + "type": "object", + "minProperties": 0, + "additionalProperties": true + } + } +} \ No newline at end of file diff --git a/spec/requests/discourse_activity_pub/nodeinfo_controller_spec.rb b/spec/requests/discourse_activity_pub/nodeinfo_controller_spec.rb new file mode 100644 index 00000000..fcc62b7d --- /dev/null +++ b/spec/requests/discourse_activity_pub/nodeinfo_controller_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "json_schemer" + +RSpec.describe DiscourseActivityPub::NodeinfoController do + describe "#index" do + context "without activity pub enabled" do + before { SiteSetting.activity_pub_enabled = false } + + it "returns a not enabled error" do + get "/.well-known/nodeinfo" + expect(response.status).to eq(404) + end + end + + context "with activity pub enabled" do + before { SiteSetting.activity_pub_enabled = true } + + it "returns a JRD" do + get "/.well-known/nodeinfo" + expect(response.status).to eq(200) + + body = JSON.parse(response.body) + expect(body["links"][0]["rel"]).to eq( + "http://nodeinfo.diaspora.software/ns/schema/#{DiscourseActivityPub::Nodeinfo::VERSION}", + ) + expect(body["links"][0]["href"]).to eq( + "http://test.localhost/nodeinfo/#{DiscourseActivityPub::Nodeinfo::VERSION}", + ) + end + end + end + + describe "#show" do + context "without activity pub enabled" do + before { SiteSetting.activity_pub_enabled = false } + + it "returns a not enabled error" do + get "/nodeinfo/#{DiscourseActivityPub::Nodeinfo::VERSION}" + expect(response.status).to eq(404) + end + end + + context "with activity pub enabled" do + let!(:version_schema) do + ## See https://github.com/jhass/nodeinfo/blob/main/schemas/2.1/schema.json + JSON.parse( + File.open( + File.join( + File.expand_path("../..", __dir__), + "fixtures", + "nodeinfo", + DiscourseActivityPub::Nodeinfo::VERSION, + "schema.json", + ), + ).read, + ).with_indifferent_access + end + + before do + SiteSetting.activity_pub_enabled = true + 2.times do + post = Fabricate(:post, user: Fabricate(:active_user, last_seen_at: 2.days.ago)) + Fabricate( + :post, + user: Fabricate(:active_user, last_seen_at: 33.days.ago), + reply_to_post_number: post.post_number, + ) + end + 2.times { Fabricate(:post, user: Fabricate(:user, staged: true)) } + 2.times { Fabricate(:post, user: Fabricate(:active_user, last_seen_at: 200.days.ago)) } + end + + it "returns nodeinfo" do + get "/nodeinfo/#{DiscourseActivityPub::Nodeinfo::VERSION}" + expect(response.status).to eq(200) + + json = response.parsed_body + schemer = ::JSONSchemer.schema(version_schema) + expect(schemer.valid?(json)).to eq(true) + + expect(json["version"]).to eq(DiscourseActivityPub::Nodeinfo::VERSION) + expect(json["software"]).to eq( + { + name: DiscourseActivityPub::Nodeinfo::SOFTWARE_NAME, + version: Discourse::VERSION::STRING, + }.as_json, + ) + expect(json["protocols"]).to eq(DiscourseActivityPub::Nodeinfo::SUPPORTED_PROTOCOLS) + expect(json["services"]).to eq( + { + inbound: DiscourseActivityPub::Nodeinfo::SUPPORTED_INBOUND_SERVICES, + outbound: DiscourseActivityPub::Nodeinfo::SUPPORTED_OUTBOUND_SERVICES, + }.as_json, + ) + expect(json["usage"]["users"]).to eq( + { total: 6, activeMonth: 2, activeHalfyear: 4 }.as_json, + ) + expect(json["usage"]["localPosts"]).to eq(4) + expect(json["usage"]["localComments"]).to eq(2) + expect(json["openRegistrations"]).to eq(!SiteSetting.login_required) + expect(json["metadata"]).to eq( + { nodeName: SiteSetting.title, nodeDescription: SiteSetting.site_description }.as_json, + ) + end + end + end +end