Implement NodeInfo

This commit is contained in:
Jonne Haß 2015-07-23 16:26:52 +02:00 committed by Dennis Schubert
parent 2ae8b2f611
commit 487b0d90ca
11 changed files with 643 additions and 100 deletions

View file

@ -18,6 +18,7 @@ gem "diaspora_federation-rails", "0.0.3"
gem "acts_as_api", "0.4.2" gem "acts_as_api", "0.4.2"
gem "json", "1.8.3" gem "json", "1.8.3"
gem "json-schema", "2.5.1"
# Authentication # Authentication

View file

@ -390,6 +390,8 @@ GEM
multi_json (>= 1.3) multi_json (>= 1.3)
rake rake
json (1.8.3) json (1.8.3)
json-schema (2.5.1)
addressable (~> 2.3.7)
jwt (1.5.0) jwt (1.5.0)
kaminari (0.16.3) kaminari (0.16.3)
actionpack (>= 3.0.0) actionpack (>= 3.0.0)
@ -791,6 +793,7 @@ DEPENDENCIES
js_image_paths (= 0.0.2) js_image_paths (= 0.0.2)
jshintrb (= 0.3.0) jshintrb (= 0.3.0)
json (= 1.8.3) json (= 1.8.3)
json-schema (= 2.5.1)
logging-rails (= 0.5.0) logging-rails (= 0.5.0)
markerb (= 1.0.2) markerb (= 1.0.2)
messagebus_ruby_api (= 1.0.3) messagebus_ruby_api (= 1.0.3)
@ -872,4 +875,4 @@ DEPENDENCIES
will_paginate (= 3.0.7) will_paginate (= 3.0.7)
BUNDLED WITH BUNDLED WITH
1.10.5 1.10.6

View file

@ -0,0 +1,16 @@
class NodeInfoController < ApplicationController
respond_to :json
def jrd
render json: NodeInfo.jrd(CGI.unescape(node_info_url("123.123").sub("123.123", "%{version}")))
end
def document
if NodeInfo.supported_version?(params[:version])
document = NodeInfoPresenter.new(params[:version])
render json: document, content_type: document.content_type
else
head :not_found
end
end
end

View file

@ -0,0 +1,99 @@
class NodeInfoPresenter
delegate :as_json, :content_type, to: :document
def initialize(version)
@version = version
end
def document
@document ||= NodeInfo.build do |doc|
doc.version = @version
add_static_data doc
add_configuration doc
add_user_counts doc.usage.users
add_usage doc.usage
end
end
def add_configuration(doc)
doc.software.version = version
doc.services = available_services
doc.open_registrations = open_registrations?
doc.metadata["nodeName"] = name
end
def add_static_data(doc)
doc.software.name = "diaspora"
doc.protocols.inbound << "diaspora"
doc.protocols.outbound << "diaspora"
end
def add_user_counts(doc)
return unless expose_user_counts?
doc.total = total_users
doc.active_halfyear = halfyear_users
doc.active_month = monthly_users
end
def add_usage(doc)
doc.local_posts = local_posts if expose_posts_counts?
doc.local_comments = local_comments if expose_comment_counts?
end
def expose_user_counts?
AppConfig.privacy.statistics.user_counts?
end
def expose_posts_counts?
AppConfig.privacy.statistics.post_counts?
end
def expose_comment_counts?
AppConfig.privacy.statistics.comment_counts?
end
def name
AppConfig.settings.pod_name
end
def version
AppConfig.version_string
end
def open_registrations?
AppConfig.settings.enable_registrations?
end
def available_services
Configuration::KNOWN_SERVICES.select {|service|
AppConfig.show_service?(service, nil)
}.map(&:to_s)
end
def total_users
@total_users ||= User.active.count
end
def monthly_users
@monthly_users ||= User.monthly_actives.count
end
def halfyear_users
@halfyear_users ||= User.halfyear_actives.count
end
def local_posts
@local_posts ||= Post.where(type: "StatusMessage")
.joins(:author)
.where("owner_id IS NOT null")
.count
end
def local_comments
@local_comments ||= Comment.joins(:author)
.where("owner_id IS NOT null")
.count
end
end

View file

@ -2,121 +2,48 @@
# licensed under the Affero General Public License version 3 or later. See # licensed under the Affero General Public License version 3 or later. See
# the COPYRIGHT file. # the COPYRIGHT file.
class StatisticsPresenter # TODO: Drop after 0.6
class StatisticsPresenter < NodeInfoPresenter
def initialize
super("1.0")
end
def as_json options={} def as_json(_options={})
base_data.merge(user_counts) base_data.merge(user_counts)
.merge(post_counts) .merge(post_counts)
.merge(comment_counts) .merge(comment_counts)
.merge(all_services)
.merge(legacy_services) # Remove in 0.6
end end
def base_data def base_data
{ {
'name' => name, "name" => name,
'network' => 'Diaspora', "network" => "Diaspora",
'version' => version, "version" => version,
'registrations_open' => open_registrations?, "registrations_open" => open_registrations?,
'services' => available_services "services" => available_services
} }
end end
def name
AppConfig.settings.pod_name
end
def version
AppConfig.version_string
end
def open_registrations?
AppConfig.settings.enable_registrations?
end
def user_counts def user_counts
return {} unless expose_user_counts? return {} unless expose_user_counts?
{ {
'total_users' => total_users, "total_users" => total_users,
'active_users_monthly' => monthly_users, "active_users_monthly" => monthly_users,
'active_users_halfyear' => halfyear_users "active_users_halfyear" => halfyear_users
} }
end end
def expose_user_counts?
AppConfig.privacy.statistics.user_counts?
end
def total_users
@total_users ||= User.active.count
end
def monthly_users
@monthly_users ||= User.monthly_actives.count
end
def halfyear_users
@halfyear_users ||= User.halfyear_actives.count
end
def post_counts def post_counts
return {} unless expose_posts_counts? return {} unless expose_posts_counts?
{ {
'local_posts' => local_posts "local_posts" => local_posts
} }
end end
def local_posts
@local_posts ||= Post.where(type: "StatusMessage")
.joins(:author)
.where("owner_id IS NOT null")
.count
end
def expose_posts_counts?
AppConfig.privacy.statistics.post_counts?
end
def comment_counts def comment_counts
return {} unless expose_comment_counts? return {} unless expose_comment_counts?
{ {
'local_comments' => local_comments "local_comments" => local_comments
} }
end end
def expose_comment_counts?
AppConfig.privacy.statistics.comment_counts?
end
def local_comments
@local_comments ||= Comment.joins(:author)
.where("owner_id IS NOT null")
.count
end
def all_services_helper
result = {}
Configuration::KNOWN_SERVICES.each {|service, options|
result[service.to_s] = AppConfig.show_service?(service, nil)
}
result
end
def all_services
@all_services ||= all_services_helper
end
def available_services
Configuration::KNOWN_SERVICES.select {|service|
AppConfig.show_service?(service, nil)
}.map(&:to_s)
end
def legacy_services
Configuration::KNOWN_SERVICES.each_with_object({}) {|service, result|
result[service.to_s] = AppConfig.show_service?(service, nil)
}
end
end end

View file

@ -235,6 +235,10 @@ Diaspora::Application.routes.draw do
#Protocol Url #Protocol Url
get 'protocol' => redirect("http://wiki.diasporafoundation.org/Federation_Protocol_Overview") get 'protocol' => redirect("http://wiki.diasporafoundation.org/Federation_Protocol_Overview")
# NodeInfo
get ".well-known/nodeinfo", to: "node_info#jrd"
get "nodeinfo/:version", to: "node_info#document", as: "node_info", constraints: {version: /\d+\.\d+/}
#Statistics #Statistics
get :statistics, controller: :statistics get :statistics, controller: :statistics

147
lib/node_info.rb Normal file
View file

@ -0,0 +1,147 @@
require "pathname"
require "json-schema"
module NodeInfo
VERSIONS = %w(1.0)
SCHEMAS = {}
private_constant :VERSIONS, :SCHEMAS
Document = Struct.new(:version, :software, :protocols, :services, :open_registrations, :usage, :metadata) do
Software = Struct.new(:name, :version) do
def initialize(name=nil, version=nil)
super(name, version)
end
def version_10_hash
{
"name" => name,
"version" => version
}
end
end
Protocols = Struct.new(:inbound, :outbound) do
def initialize(inbound=[], outbound=[])
super(inbound, outbound)
end
def version_10_hash
{
"inbound" => inbound,
"outbound" => outbound
}
end
end
Usage = Struct.new(:users, :local_posts, :local_comments) do
Users = Struct.new(:total, :active_halfyear, :active_month) do
def initialize(total=nil, active_halfyear=nil, active_month=nil)
super(total, active_halfyear, active_month)
end
def version_10_hash
{
"total" => total,
"activeHalfyear" => active_halfyear,
"activeMonth" => active_month
}
end
end
def initialize(local_posts=nil, local_comments=nil)
super(Users.new, local_posts, local_comments)
end
def version_10_hash
{
"users" => users.version_10_hash,
"localPosts" => local_posts,
"localComments" => local_comments
}
end
end
def self.build
new.tap do |doc|
yield doc
doc.validate
end
end
def initialize(version=nil, services=[], open_registrations=nil, metadata={})
super(version, Software.new, Protocols.new, services, open_registrations, Usage.new, metadata)
end
def as_json(_options={})
case version
when "1.0"
version_10_hash
end
end
def content_type
"application/json; profile=http://nodeinfo.diaspora.software/ns/schema/#{version}#"
end
def schema
NodeInfo.schema version
end
def validate
assert NodeInfo.supported_version?(version), "Unknown version #{version}"
JSON::Validator.validate!(schema, as_json)
end
private
def assert(condition, message)
raise ArgumentError, message unless condition
end
def version_10_hash
deep_compact(
"version" => "1.0",
"software" => software.version_10_hash,
"protocols" => protocols.version_10_hash,
"services" => services.empty? ? nil : services,
"openRegistrations" => open_registrations,
"usage" => usage.version_10_hash,
"metadata" => metadata
)
end
def deep_compact(hash)
hash.tap do |hash|
hash.reject! {|_, value|
deep_compact value if value.is_a? Hash
value.nil?
}
end
end
end
def self.schema(version)
SCHEMAS[version] ||= JSON.parse(
Pathname.new(__dir__).join("..", "vendor", "nodeinfo", "schemas", "#{version}.json").expand_path.read
)
end
def self.build(&block)
Document.build(&block)
end
def self.jrd(endpoint)
{
"links" => VERSIONS.map {|version|
{
"rel" => "http://nodeinfo.diaspora.software/ns/schema/#{version}",
"href" => endpoint % {version: version}
}
}
}
end
def self.supported_version?(version)
VERSIONS.include? version
end
end

View file

@ -0,0 +1,54 @@
require "spec_helper"
describe NodeInfoController do
describe "#jrd" do
it "responds to JSON" do
get :jrd, format: :json
expect(response).to be_success
end
it "returns a JRD" do
expect(NodeInfo).to receive(:jrd).with(include("%{version}")).and_call_original
get :jrd, format: :json
jrd = JSON.parse(response.body)
expect(jrd).to include "links" => [{
"rel" => "http://nodeinfo.diaspora.software/ns/schema/1.0",
"href" => node_info_url("1.0")
}]
end
end
describe "#document" do
context "invalid version" do
it "responds with not found" do
get :document, version: "0.0", format: :json
expect(response.code).to eq "404"
end
end
context "version 1.0" do
it "responds to JSON" do
get :document, version: "1.0", format: :json
expect(response).to be_success
end
it "calls NodeInfoPresenter" do
expect(NodeInfoPresenter).to receive(:new).with("1.0")
.and_return(double(as_json: {}, content_type: "application/json"))
get :document, version: "1.0", format: :json
end
it "notes the schema in the content type" do
get :document, version: "1.0", format: :json
expect(response.content_type).to eq "application/json; profile=http://nodeinfo.diaspora.software/ns/schema/1.0#"
end
end
end
end

View file

@ -0,0 +1,120 @@
require "spec_helper"
describe NodeInfoPresenter do
let(:presenter) { NodeInfoPresenter.new("1.0") }
let(:hash) { presenter.as_json.as_json }
describe "#as_json" do
it "works" do
expect(hash).to be_present
expect(presenter.to_json).to be_a String
end
end
describe "node info contents" do
before do
AppConfig.privacy.statistics.user_counts = false
AppConfig.privacy.statistics.post_counts = false
AppConfig.privacy.statistics.comment_counts = false
end
it "provides generic pod data in json" do
expect(hash).to eq(
"version" => "1.0",
"software" => {
"name" => "diaspora",
"version" => AppConfig.version_string
},
"protocols" => {
"inbound" => ["diaspora"],
"outbound" => ["diaspora"]
},
"services" => ["facebook"],
"openRegistrations" => AppConfig.settings.enable_registrations?,
"usage" => {
"users" => {}
},
"metadata" => {
"nodeName" => AppConfig.settings.pod_name
}
)
end
context "when services are enabled" do
before do
AppConfig.services = {
"facebook" => {
"enable" => true,
"authorized" => true
},
"twitter" => {"enable" => true},
"wordpress" => {"enable" => false},
"tumblr" => {
"enable" => true,
"authorized" => false
}
}
end
it "provides services" do
expect(hash).to include "services" => %w(twitter facebook)
end
end
context "when some services are set to username authorized" do
before do
AppConfig.services = {
"facebook" => {
"enable" => true,
"authorized" => "bob"
},
"twitter" => {"enable" => true},
"wordpress" => {
"enable" => true,
"authorized" => "alice"
},
"tumblr" => {
"enable" => true,
"authorized" => false
}
}
end
it "it doesn't list those" do
expect(hash).to include "services" => ["twitter"]
end
end
context "when counts are enabled" do
before do
AppConfig.privacy.statistics.user_counts = true
AppConfig.privacy.statistics.post_counts = true
AppConfig.privacy.statistics.comment_counts = true
end
it "provides generic pod data and counts in json" do
expect(hash).to include(
"usage" => {
"users" => {
"total" => User.active.count,
"activeHalfyear" => User.halfyear_actives.count,
"activeMonth" => User.monthly_actives.count
},
"localPosts" => presenter.local_posts,
"localComments" => presenter.local_comments
}
)
end
end
context "when registrations are closed" do
before do
AppConfig.settings.enable_registrations = false
end
it "should mark openRegistrations to be false" do
expect(presenter.open_registrations?).to be false
end
end
end
end

View file

@ -124,15 +124,5 @@ describe StatisticsPresenter do
) )
end end
end end
context "when registrations are closed" do
before do
AppConfig.settings.enable_registrations = false
end
it "should mark open_registrations to be false" do
expect(@presenter.open_registrations?).to be false
end
end
end end
end end

182
vendor/nodeinfo/schemas/1.0.json vendored Normal file
View file

@ -0,0 +1,182 @@
{
"$schema": "http://json-schema.org/draft-04/schema#",
"id": "http://nodeinfo.diaspora.software/ns/schema/1.0#",
"description": "NodeInfo schema version 1.0.",
"type": "object",
"additionalProperties": false,
"required": [
"version",
"software",
"protocols",
"openRegistrations",
"usage",
"metadata"
],
"properties": {
"version": {
"description": "The schema version, must be 1.0.",
"enum": [
"1.0"
]
},
"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.",
"enum": [
"diaspora",
"friendica",
"redmatrix"
]
},
"version": {
"description": "The version of this server software.",
"type": "string"
}
}
},
"protocols": {
"description": "The protocols supported on this server.",
"type": "object",
"additionalProperties": false,
"required": [
"inbound",
"outbound"
],
"properties": {
"inbound": {
"description": "The protocols this server can receive traffic for.",
"type": "array",
"minItems": 1,
"items": {
"enum": [
"buddycloud",
"diaspora",
"friendica",
"gnusocial",
"libertree",
"mediagoblin",
"pumpio",
"redmatrix",
"smtp",
"tent"
]
}
},
"outbound": {
"description": "The protocols this server can generate traffic for.",
"type": "array",
"minItems": 1,
"items": {
"enum": [
"buddycloud",
"diaspora",
"friendica",
"gnusocial",
"libertree",
"mediagoblin",
"pumpio",
"redmatrix",
"smtp",
"tent"
]
}
}
}
},
"services": {
"description": "The third party sites this servers allows to publish messages to.",
"type": "array",
"minItems": 0,
"items": {
"enum": [
"appnet",
"blogger",
"buddycloud",
"diaspora",
"dreamwidth",
"drupal",
"facebook",
"friendica",
"gnusocial",
"google",
"insanejournal",
"libertree",
"linkedin",
"livejournal",
"mediagoblin",
"myspace",
"pinterest",
"posterous",
"pumpio",
"redmatrix",
"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
}
}
}