Skip to content

FEATURE: Add nodeinfo endpoint #228

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions app/controllers/discourse_activity_pub/nodeinfo_controller.rb
Original file line number Diff line number Diff line change
@@ -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, :redirect_to_login_if_required, :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
40 changes: 40 additions & 0 deletions app/serializers/discourse_activity_pub/nodeinfo_serializer.rb
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions lib/discourse_activity_pub/nodeinfo.rb
Original file line number Diff line number Diff line change
@@ -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
23 changes: 23 additions & 0 deletions lib/discourse_activity_pub/statistics.rb
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In core, we cache this data. Can we do that here as well? Avoids this route becoming an abuse vector.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added caching and rate limiting into the controller: angusmcleod@9ec7523


def active_users
valid_users.where("staged IS FALSE")
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think you need the staged clause here. Statistics.valid_users checks for activated users and I believe activating a user unstages them.

Copy link
Contributor Author

@angusmcleod angusmcleod Jun 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added this in because staged vs unstaged is the equivalent of remote vs local (for the purposes of activitypub) on the user model itself, i.e. it is a reflection of the distinction that nodeinfo is drawing in its own categorisation of users. It's essentially a clarity/surety measure. active and staged are independent values, so it may be possible to have an active staged user. I've updated the method name to local_users to clarify this, and to make it consistent with the local_posts method name: angusmcleod@293a81c

end

def local_posts
::Post.where("user_id NOT IN (SELECT id FROM users WHERE staged IS TRUE)")
end
end
end
6 changes: 6 additions & 0 deletions plugin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
188 changes: 188 additions & 0 deletions spec/fixtures/nodeinfo/2.1/schema.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading