diff --git a/Gemfile b/Gemfile index 03acee597..6decfbf46 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,7 @@ gem "diaspora_federation-rails", "0.0.3" gem "acts_as_api", "0.4.2" gem "json", "1.8.3" +gem "json-schema", "2.5.1" # Authentication diff --git a/Gemfile.lock b/Gemfile.lock index 0e6384508..6fc2deccd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -390,6 +390,8 @@ GEM multi_json (>= 1.3) rake json (1.8.3) + json-schema (2.5.1) + addressable (~> 2.3.7) jwt (1.5.0) kaminari (0.16.3) actionpack (>= 3.0.0) @@ -791,6 +793,7 @@ DEPENDENCIES js_image_paths (= 0.0.2) jshintrb (= 0.3.0) json (= 1.8.3) + json-schema (= 2.5.1) logging-rails (= 0.5.0) markerb (= 1.0.2) messagebus_ruby_api (= 1.0.3) @@ -872,4 +875,4 @@ DEPENDENCIES will_paginate (= 3.0.7) BUNDLED WITH - 1.10.5 + 1.10.6 diff --git a/app/controllers/node_info_controller.rb b/app/controllers/node_info_controller.rb new file mode 100644 index 000000000..689c8603b --- /dev/null +++ b/app/controllers/node_info_controller.rb @@ -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 diff --git a/app/presenters/node_info_presenter.rb b/app/presenters/node_info_presenter.rb new file mode 100644 index 000000000..705fdfbe6 --- /dev/null +++ b/app/presenters/node_info_presenter.rb @@ -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 diff --git a/app/presenters/statistics_presenter.rb b/app/presenters/statistics_presenter.rb index af88e6871..e6382850c 100644 --- a/app/presenters/statistics_presenter.rb +++ b/app/presenters/statistics_presenter.rb @@ -2,121 +2,48 @@ # licensed under the Affero General Public License version 3 or later. See # 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) .merge(post_counts) .merge(comment_counts) - .merge(all_services) - .merge(legacy_services) # Remove in 0.6 end def base_data { - 'name' => name, - 'network' => 'Diaspora', - 'version' => version, - 'registrations_open' => open_registrations?, - 'services' => available_services + "name" => name, + "network" => "Diaspora", + "version" => version, + "registrations_open" => open_registrations?, + "services" => available_services } 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 return {} unless expose_user_counts? { - 'total_users' => total_users, - 'active_users_monthly' => monthly_users, - 'active_users_halfyear' => halfyear_users + "total_users" => total_users, + "active_users_monthly" => monthly_users, + "active_users_halfyear" => halfyear_users } 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 return {} unless expose_posts_counts? { - 'local_posts' => local_posts + "local_posts" => local_posts } 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 return {} unless expose_comment_counts? { - 'local_comments' => local_comments + "local_comments" => local_comments } 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 diff --git a/config/routes.rb b/config/routes.rb index db625cfcb..b1d988d0f 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -235,6 +235,10 @@ Diaspora::Application.routes.draw do #Protocol Url 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 get :statistics, controller: :statistics diff --git a/lib/node_info.rb b/lib/node_info.rb new file mode 100644 index 000000000..1b53b0991 --- /dev/null +++ b/lib/node_info.rb @@ -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 diff --git a/spec/controllers/node_info_controller_spec.rb b/spec/controllers/node_info_controller_spec.rb new file mode 100644 index 000000000..089bdfe19 --- /dev/null +++ b/spec/controllers/node_info_controller_spec.rb @@ -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 diff --git a/spec/presenters/node_info_presenter_spec.rb b/spec/presenters/node_info_presenter_spec.rb new file mode 100644 index 000000000..d00e75d28 --- /dev/null +++ b/spec/presenters/node_info_presenter_spec.rb @@ -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 diff --git a/spec/presenters/statistics_presenter_spec.rb b/spec/presenters/statistics_presenter_spec.rb index 364525ec0..31273cdec 100644 --- a/spec/presenters/statistics_presenter_spec.rb +++ b/spec/presenters/statistics_presenter_spec.rb @@ -124,15 +124,5 @@ describe StatisticsPresenter do ) 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 diff --git a/vendor/nodeinfo/schemas/1.0.json b/vendor/nodeinfo/schemas/1.0.json new file mode 100644 index 000000000..de28dcb8e --- /dev/null +++ b/vendor/nodeinfo/schemas/1.0.json @@ -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 + } + } +}