node_info 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4690a424588b5c969cb683f7654b483309d77f4e2777c05a21e3ee4ff951b013
4
+ data.tar.gz: 1546ca79fdc1328aa67959c3c669b01293369517e289ca2d484f45cdf651686e
5
+ SHA512:
6
+ metadata.gz: 00a2b5e77d6a1c0a7ed548115577741270818bce97e0224177fcefaa8b7413d5f14204fa82827d3276bd3d4aa467064138d02a0910907c7391ccd898ae22393e
7
+ data.tar.gz: e2ee65ca80208387a34a230bfeca423066724405b7a6f5a8c7375278665a8ab20e5e3de6bbfde05a1b17c2689bb83a0b7875de0b2492720556e3b7f5cd93f765
data/CHANGELOG.md ADDED
@@ -0,0 +1,19 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this gem uses to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0] - 2026-02-17
11
+
12
+ ### Added
13
+
14
+ - Initial release
15
+ - NodeInfo 2.1 client implementation
16
+ - NodeInfo 2.1 server implementation
17
+ - Support for well-known discovery
18
+ - Pretty thorough test suite
19
+ - Documentation and examples
data/README.md ADDED
@@ -0,0 +1,364 @@
1
+ # NodeInfo
2
+
3
+ [NodeInfo](https://nodeinfo.diaspora.software)
4
+ is a standardized way for Fediverse servers to expose metadata about themselves,
5
+ including software information, supported protocols, usage statistics, and more.
6
+
7
+ A pure Ruby implementation of the NodeInfo protocol for the Fediverse,
8
+ providing both client and server functionality.
9
+ This gem implements NodeInfo 2.1 as specified in
10
+ [FEP-f1d5](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md).
11
+
12
+ ## Features
13
+
14
+ - Pure Ruby - Works with any Ruby framework or plain scripts
15
+ - Client - Discover and fetch `NodeInfo` from any Fediverse server
16
+ - Server - Serve your own `NodeInfo` documents
17
+ - Dynamic Stats - Support for static values or dynamic procs for usage statistics
18
+
19
+ ## Installation
20
+
21
+ Add this line to your application's Gemfile:
22
+
23
+ ```ruby
24
+ gem 'node_info'
25
+ ```
26
+
27
+ And then execute:
28
+
29
+ ```ruby
30
+ bundle install
31
+ ```
32
+
33
+ Or install it yourself as:
34
+
35
+ ```ruby
36
+ gem install node_info
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ### Client
42
+
43
+ Fetch NodeInfo from any Fediverse server:
44
+
45
+ ```ruby
46
+ require 'node_info'
47
+
48
+ # Create a client
49
+ client = NodeInfo::Client.new
50
+
51
+ # Fetch NodeInfo from a server
52
+ info = client.fetch 'mastodon.social'
53
+
54
+ # Access the information
55
+ puts info.software.name # => 'mastodon'
56
+ puts info.software.version # => '4.2.0'
57
+ puts info.protocols # => ['activitypub']
58
+ puts info.open_registrations # => true
59
+
60
+ # Access usage statistics
61
+ puts info.usage.users[:total] # => 1000000
62
+ puts info.usage.users[:activeMonth] # => 50000
63
+ puts info.usage.local_posts # => 5000000
64
+ ```
65
+
66
+ #### Discovery and Fetching Separately
67
+
68
+ ```ruby
69
+ # Discover the NodeInfo URL
70
+ url = client.discover 'mastodon.social'
71
+ # => 'https://mastodon.social/nodeinfo/2.1'
72
+
73
+ # Fetch the NodeInfo document
74
+ info = client.fetch_document url
75
+ ```
76
+
77
+ #### Client Options
78
+
79
+ ```ruby
80
+ # Custom timeout (default: 10 seconds)
81
+ client = NodeInfo::Client.new timeout: 5
82
+
83
+ # Disable redirect following (default: true)
84
+ client = NodeInfo::Client.new follow_redirects: false
85
+ ```
86
+
87
+ ### Server
88
+
89
+ Serve NodeInfo documents from your application:
90
+
91
+ ```ruby
92
+ require 'node_info'
93
+
94
+ # Create a server with configuration
95
+ server = NodeInfo::Server.new do |config|
96
+ config.software_name = 'example_app'
97
+ config.software_version = '1.0.0'
98
+ config.software_repository = 'https://github.com/xoengineering/example'
99
+ config.software_homepage = 'https://example.com'
100
+
101
+ config.protocols = ['activitypub']
102
+ config.services_inbound = ['atom1.0']
103
+ config.services_outbound = ['rss2.0', 'atom1.0']
104
+ config.open_registrations = true
105
+
106
+ config.metadata = {
107
+ nodeName: 'An example instance',
108
+ nodeDescription: 'An example place for exemplar people'
109
+ }
110
+ end
111
+
112
+ # Generate the well-known response: /.well-known/nodeinfo
113
+ server.well_known_json 'https://example.com'
114
+ # => {
115
+ # "links": [
116
+ # {
117
+ # "rel": "http://nodeinfo.diaspora.software/ns/schema/2.1",
118
+ # "href": "https://example.com/nodeinfo/2.1"
119
+ # }
120
+ # ]
121
+ # }
122
+
123
+ # Generate the NodeInfo document (/nodeinfo/2.1)
124
+ server.to_json
125
+ # => Full NodeInfo 2.1 JSON document
126
+ ```
127
+
128
+ #### Static Usage Statistics
129
+
130
+ ```ruby
131
+ server = NodeInfo::Server.new do |config|
132
+ config.software_name = 'example_app'
133
+ config.software_version = '1.0.0'
134
+ config.protocols = ['activitypub']
135
+
136
+ # Static values
137
+ config.usage_users = { total: 100, activeMonth: 50, activeHalfyear: 75 }
138
+ config.usage_local_posts = 1000
139
+ config.usage_local_comments = 500
140
+ end
141
+ ```
142
+
143
+ #### Dynamic Usage Statistics
144
+
145
+ For production applications, you’ll want to compute statistics dynamically:
146
+
147
+ ```ruby
148
+ server = NodeInfo::Server.new do |config|
149
+ config.software_name = 'example_app'
150
+ config.software_version = '1.0.0'
151
+ config.protocols = ['activitypub']
152
+
153
+ # Use procs to compute values dynamically
154
+ config.usage_users = -> { User.count }
155
+ config.usage_users_active_month = -> { User.active_last_month.count }
156
+ config.usage_users_active_halfyear = -> { User.active_last_six_months.count }
157
+ config.usage_local_posts = -> { Post.local.count }
158
+ config.usage_local_comments = -> { Comment.local.count }
159
+ end
160
+
161
+ # Stats are computed fresh each time
162
+ server.to_json # Calls all the procs to get current values
163
+ ```
164
+
165
+ #### Alternative Proc Syntax
166
+
167
+ ```ruby
168
+ config.usage_users = {
169
+ total: -> { User.count },
170
+ activeMonth: -> { User.active_last_month.count },
171
+ activeHalfyear: -> { User.active_last_six_months.count }
172
+ }
173
+ ```
174
+
175
+ ### Framework Integration
176
+
177
+ #### Sinatra
178
+
179
+ ```ruby
180
+ require 'sinatra'
181
+ require 'node_info'
182
+
183
+ # Configure your server (perhaps in a config file or initializer)
184
+ NODE_INFO_SERVER = NodeInfo::Server.new do |config|
185
+ config.software_name = 'example_app'
186
+ config.software_version = '1.0.0'
187
+ config.protocols = ['activitypub']
188
+ config.base_url = 'https://example.com'
189
+ config.usage_users = -> { User.count }
190
+ end
191
+
192
+ # Well-known endpoint
193
+ get '/.well-known/nodeinfo' do
194
+ content_type :json
195
+ NODE_INFO_SERVER.well_known_json
196
+ end
197
+
198
+ # NodeInfo document endpoint
199
+ get '/nodeinfo/2.1' do
200
+ content_type :json
201
+ NODE_INFO_SERVER.to_json
202
+ end
203
+ ```
204
+
205
+ #### Rails
206
+
207
+ ```ruby
208
+ # config/routes.rb
209
+ Rails.application.routes.draw do
210
+ get '/.well-known/nodeinfo', to: 'node_info#well_known'
211
+ get '/nodeinfo/2.1', to: 'node_info#show'
212
+ end
213
+
214
+ # app/controllers/node_info_controller.rb
215
+ class NodeInfoController < ApplicationController
216
+ def well_known
217
+ render json: server.well_known(request.base_url)
218
+ end
219
+
220
+ def show
221
+ render json: server.to_json
222
+ end
223
+
224
+ private
225
+
226
+ def server
227
+ @server ||= NodeInfo::Server.new do |config|
228
+ config.software_name = 'example_app'
229
+ config.software_version = Rails.application.config.version
230
+ config.protocols = ['activitypub']
231
+ config.open_registrations = Rails.application.config.open_registrations
232
+ config.usage_users = -> { User.count }
233
+ config.usage_users_active_month = -> { User.active_last_month.count }
234
+ config.usage_local_posts = -> { Post.local.count }
235
+ end
236
+ end
237
+ end
238
+ ```
239
+
240
+ #### Hanami
241
+
242
+ ```ruby
243
+ # config/routes.rb
244
+ get '/.well-known/nodeinfo', to: 'node_info.well_known'
245
+ get '/nodeinfo/2.1', to: 'node_info.show'
246
+
247
+ # app/actions/node_info/well_known.rb
248
+ module ExampleApp
249
+ module Actions
250
+ module NodeInfo
251
+ class WellKnown < ExampleApp::Action
252
+ def handle request, response
253
+ server = build_server
254
+ response.format = :json
255
+ response.body = server.well_known_json request.base_url
256
+ end
257
+
258
+ private
259
+
260
+ def build_server
261
+ NodeInfo::Server.new do |config|
262
+ config.software_name = 'example_app'
263
+ config.software_version = '1.0.0'
264
+ config.protocols = ['activitypub']
265
+ end
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
271
+ ```
272
+
273
+ ## Configuration Options
274
+
275
+ ### Server Configuration
276
+
277
+ | Option | Type | Required | Description |
278
+ |--------|------|----------|-------------|
279
+ | `protocols` | Array | Yes | Supported protocols (e.g., `['activitypub']`) |
280
+ | `software_name` | String | Yes | Name of your software |
281
+ | `software_version` | String | Yes | Version of your software |
282
+ | `software_repository` | String | No | URL to source code repository |
283
+ | `software_homepage` | String | No | URL to software homepage |
284
+ | `services_inbound` | Array | No | Inbound services (e.g., `['atom1.0']`) |
285
+ | `services_outbound` | Array | No | Outbound services (e.g., `['rss2.0']`) |
286
+ | `open_registrations` | Boolean | No | Whether registrations are open (default: `false`) |
287
+ | `usage_users` | Hash/Proc | No | User statistics |
288
+ | `usage_users_active_month` | Integer/Proc | No | Active users in last month |
289
+ | `usage_users_active_halfyear` | Integer/Proc | No | Active users in last 6 months |
290
+ | `usage_local_posts` | Integer/Proc | No | Number of local posts |
291
+ | `usage_local_comments` | Integer/Proc | No | Number of local comments |
292
+ | `metadata` | Hash | No | Custom metadata |
293
+ | `base_url` | String | No | Base URL for well-known response |
294
+
295
+ ## Error Handling
296
+
297
+ The gem defines several error classes:
298
+
299
+ ```ruby
300
+ NodeInfo::Error # Base error class
301
+ NodeInfo::DiscoveryError # Discovery failed
302
+ NodeInfo::FetchError # Fetching document failed
303
+ NodeInfo::ParseError # Parsing document failed
304
+ NodeInfo::ValidationError # Validation failed
305
+ NodeInfo::HTTPError # HTTP request failed
306
+ ```
307
+
308
+ Example error handling:
309
+
310
+ ```ruby
311
+ begin
312
+ info = client.fetch 'example.com'
313
+ rescue NodeInfo::DiscoveryError => e
314
+ puts "Could not discover NodeInfo: #{e.message}"
315
+ rescue NodeInfo::FetchError => e
316
+ puts "Could not fetch NodeInfo: #{e.message}"
317
+ rescue NodeInfo::ParseError => e
318
+ puts "Could not parse NodeInfo: #{e.message}"
319
+ rescue NodeInfo::Error => e
320
+ puts "NodeInfo error: #{e.message}"
321
+ end
322
+ ```
323
+
324
+ ## Development
325
+
326
+ After checking out the repo, run `bin/setup` to install dependencies.
327
+ Then, run `rake spec` to run the tests.
328
+ You can also run `bin/console` for an interactive prompt that will allow you to experiment.
329
+
330
+ To install this gem onto your local machine, run `bundle exec rake install`.
331
+
332
+ ### Running Tests
333
+
334
+ ```ruby
335
+ bundle exec rspec
336
+ ```
337
+
338
+ ### Running RuboCop
339
+
340
+ ```ruby
341
+ bundle exec rubocop
342
+ ```
343
+
344
+ ### Running All Checks
345
+
346
+ ```ruby
347
+ bundle exec rake
348
+ ```
349
+
350
+ ## Contributing
351
+
352
+ Bug reports and pull requests are welcome on GitHub at the
353
+ https://github.com/xoengineering/node_info repo.
354
+
355
+ ## License
356
+
357
+ The gem is available as open source under the terms of the
358
+ [MIT License](https://opensource.org/licenses/MIT).
359
+
360
+ ## References
361
+
362
+ - [NodeInfo Specification](https://nodeinfo.diaspora.software/)
363
+ - [FEP-f1d5: NodeInfo in the Fediverse](https://codeberg.org/fediverse/fep/src/branch/main/fep/f1d5/fep-f1d5.md)
364
+ - [ActivityPub](https://www.w3.org/TR/activitypub/)
@@ -0,0 +1,111 @@
1
+ require 'http'
2
+ require 'json'
3
+ require 'uri'
4
+
5
+ module NodeInfo
6
+ # Client for discovering and fetching NodeInfo from Fediverse servers
7
+ #
8
+ # @example Fetch NodeInfo from a server
9
+ # client = NodeInfo::Client.new
10
+ # info = client.fetch("mastodon.social")
11
+ # puts info.software.name
12
+ class Client
13
+ WELL_KNOWN_PATH = '/.well-known/nodeinfo'.freeze
14
+
15
+ SUPPORTED_SCHEMAS = [
16
+ 'http://nodeinfo.diaspora.software/ns/schema/2.1',
17
+ 'http://nodeinfo.diaspora.software/ns/schema/2.0'
18
+ ].freeze
19
+
20
+ attr_reader :timeout, :follow_redirects
21
+
22
+ # Initialize a new client
23
+ # @param timeout [Integer] HTTP timeout in seconds (default: 10)
24
+ # @param follow_redirects [Boolean] Whether to follow HTTP redirects (default: true)
25
+ def initialize timeout: 10, follow_redirects: true
26
+ @timeout = timeout
27
+ @follow_redirects = follow_redirects
28
+ end
29
+
30
+ # Fetch NodeInfo from a server
31
+ # @param domain [String] The domain to fetch from (e.g., "mastodon.social")
32
+ # @return [NodeInfo::Document]
33
+ # @raise [NodeInfo::DiscoveryError] If discovery fails
34
+ # @raise [NodeInfo::FetchError] If fetching fails
35
+ # @raise [NodeInfo::ParseError] If parsing fails
36
+ def fetch domain
37
+ url = discover(domain)
38
+ fetch_document(url)
39
+ end
40
+
41
+ # Discover NodeInfo URL for a domain
42
+ # @param domain [String] The domain to discover
43
+ # @return [String] The NodeInfo document URL
44
+ # @raise [NodeInfo::DiscoveryError] If discovery fails
45
+ def discover domain
46
+ url = normalize_url domain, WELL_KNOWN_PATH
47
+
48
+ response = http_client.get url
49
+
50
+ raise DiscoveryError, "HTTP #{response.code}" unless response.status.success?
51
+
52
+ links = parse_well_known response.body.to_s
53
+ find_nodeinfo_url links
54
+ rescue HTTP::Error => e
55
+ raise DiscoveryError, "HTTP request failed: #{e.message}"
56
+ end
57
+
58
+ # Fetch NodeInfo document from URL
59
+ # @param url [String] The NodeInfo document URL
60
+ # @return [NodeInfo::Document]
61
+ # @raise [NodeInfo::FetchError] If fetching fails
62
+ def fetch_document url
63
+ response = http_client.get url
64
+
65
+ raise FetchError, "HTTP #{response.code}" unless response.status.success?
66
+
67
+ Document.parse response.body.to_s
68
+ rescue HTTP::Error => e
69
+ raise FetchError, "HTTP request failed: #{e.message}"
70
+ end
71
+
72
+ private
73
+
74
+ def http_client
75
+ client = HTTP.timeout timeout
76
+ client = client.follow if follow_redirects
77
+ client
78
+ end
79
+
80
+ def normalize_url domain, path
81
+ url = URI.parse domain
82
+ url = URI.parse("https://#{domain}") unless domain.start_with? 'http'
83
+ host = url.host
84
+
85
+ URI::HTTPS.build(host: host, path: path).to_s
86
+ end
87
+
88
+ def parse_well_known body
89
+ data = JSON.parse body
90
+ links = data['links']
91
+
92
+ raise DiscoveryError, 'No links found in well-known document' unless links
93
+ raise DiscoveryError, 'Links must be an array' unless links.is_a? Array
94
+
95
+ links
96
+ rescue JSON::ParserError => e
97
+ raise DiscoveryError, "Invalid JSON in well-known document: #{e.message}"
98
+ end
99
+
100
+ def find_nodeinfo_url links
101
+ # Try to find a supported schema, preferring 2.1 over 2.0
102
+ SUPPORTED_SCHEMAS.each do |schema|
103
+ link = links.find { it['rel'] == schema }
104
+
105
+ return link['href'] if link && link['href']
106
+ end
107
+
108
+ raise DiscoveryError, 'No supported NodeInfo schema found'
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,170 @@
1
+ require 'json'
2
+
3
+ module NodeInfo
4
+ # Represents a NodeInfo 2.1 document
5
+ class Document
6
+ attr_reader :version, :software, :protocols, :services, :open_registrations, :usage, :metadata
7
+
8
+ # Software information
9
+ class Software
10
+ attr_reader :name, :version, :repository, :homepage
11
+
12
+ def initialize name:, version:, repository: nil, homepage: nil
13
+ @name = name
14
+ @version = version
15
+ @repository = repository
16
+ @homepage = homepage
17
+ end
18
+
19
+ def to_h
20
+ {
21
+ name: name,
22
+ version: version,
23
+ repository: repository,
24
+ homepage: homepage
25
+ }.compact
26
+ end
27
+ end
28
+
29
+ # Services information
30
+ class Services
31
+ attr_reader :inbound, :outbound
32
+
33
+ def initialize inbound: [], outbound: []
34
+ @inbound = inbound
35
+ @outbound = outbound
36
+ end
37
+
38
+ def to_h = { inbound: inbound, outbound: outbound }
39
+ end
40
+
41
+ # Usage statistics
42
+ class Usage
43
+ attr_reader :users, :local_posts, :local_comments
44
+
45
+ def initialize users: {}, local_posts: nil, local_comments: nil
46
+ @users = users
47
+ @local_posts = local_posts
48
+ @local_comments = local_comments
49
+ end
50
+
51
+ def to_h
52
+ {
53
+ users: users,
54
+ localPosts: local_posts,
55
+ localComments: local_comments
56
+ }.compact
57
+ end
58
+ end
59
+
60
+ # Parse a NodeInfo document from JSON
61
+ # @param json [String, Hash] JSON string or hash
62
+ # @return [NodeInfo::Document]
63
+ def self.parse json
64
+ data = json.is_a?(String) ? JSON.parse(json) : json
65
+ data = deep_stringify_keys data
66
+
67
+ metadata = data['metadata'] || {}
68
+ metadata = metadata.transform_keys &:to_sym
69
+
70
+ new version: data['version'],
71
+ software: parse_software(data['software']),
72
+ protocols: data['protocols'],
73
+ services: parse_services(data['services']),
74
+ open_registrations: data['openRegistrations'],
75
+ usage: parse_usage(data['usage']),
76
+ metadata: metadata
77
+ rescue JSON::ParserError => e
78
+ raise ParseError, "Invalid JSON: #{e.message}"
79
+ rescue StandardError => e
80
+ raise ParseError, "Failed to parse NodeInfo document: #{e.message}"
81
+ end
82
+
83
+ # Initialize a new NodeInfo document
84
+ def initialize software:, protocols:, open_registrations: false, metadata: nil, services: nil, usage: nil, version: '2.1'
85
+ @software = software
86
+ @protocols = protocols
87
+
88
+ @metadata = metadata || {}
89
+ @open_registrations = open_registrations
90
+ @services = services || Services.new
91
+ @usage = usage || Usage.new
92
+ @version = version
93
+
94
+ validate!
95
+ end
96
+
97
+ # Convert to hash representation
98
+ # @return [Hash]
99
+ def to_h
100
+ {
101
+ version: version,
102
+ software: software.to_h,
103
+ protocols: protocols,
104
+ services: services.to_h,
105
+ openRegistrations: open_registrations,
106
+ usage: usage.to_h,
107
+ metadata: metadata
108
+ }
109
+ end
110
+
111
+ # Convert to JSON string
112
+ # @return [String]
113
+ def to_json(*) = to_h.to_json(*)
114
+
115
+ class << self
116
+ private
117
+
118
+ def deep_stringify_keys obj
119
+ case obj
120
+ when Hash
121
+ obj.each_with_object({}) { |(k, v), h| h[k.to_s] = deep_stringify_keys(v) }
122
+ when Array
123
+ obj.map { deep_stringify_keys it }
124
+ else
125
+ obj
126
+ end
127
+ end
128
+
129
+ def parse_software data
130
+ return nil unless data
131
+
132
+ Software.new name: data['name'],
133
+ version: data['version'],
134
+ repository: data['repository'],
135
+ homepage: data['homepage']
136
+ end
137
+
138
+ def parse_services data
139
+ return Services.new unless data
140
+
141
+ Services.new inbound: data['inbound'] || [],
142
+ outbound: data['outbound'] || []
143
+ end
144
+
145
+ def parse_usage data
146
+ return Usage.new unless data
147
+
148
+ users = data['users'] || {}
149
+ users = users.transform_keys(&:to_sym)
150
+
151
+ Usage.new users: users,
152
+ local_posts: data['localPosts'],
153
+ local_comments: data['localComments']
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def validate!
160
+ raise ValidationError, 'version is required' if version.nil? || version.empty?
161
+ raise ValidationError, 'software is required' if software.nil?
162
+ raise ValidationError, 'software.name is required' if software.name.nil? || software.name.empty?
163
+ raise ValidationError, 'software.version is required' if software.version.nil? || software.version.empty?
164
+ raise ValidationError, 'protocols is required' if protocols.nil?
165
+
166
+ raise ValidationError, 'protocols must be an array' unless protocols.is_a?(Array)
167
+ raise ValidationError, 'openRegistrations must be a boolean' unless [true, false].include?(open_registrations)
168
+ end
169
+ end
170
+ end
@@ -0,0 +1,26 @@
1
+ module NodeInfo
2
+ # Base error class for all NodeInfo errors
3
+ class Error < StandardError; end
4
+
5
+ # Raised when NodeInfo discovery fails
6
+ class DiscoveryError < Error; end
7
+
8
+ # Raised when fetching NodeInfo document fails
9
+ class FetchError < Error; end
10
+
11
+ # Raised when parsing NodeInfo document fails
12
+ class ParseError < Error; end
13
+
14
+ # Raised when validating NodeInfo document fails
15
+ class ValidationError < Error; end
16
+
17
+ # Raised when HTTP request fails
18
+ class HTTPError < Error
19
+ attr_reader :response
20
+
21
+ def initialize message, response = nil
22
+ super(message)
23
+ @response = response
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,172 @@
1
+ require 'json'
2
+
3
+ module NodeInfo
4
+ # Server for generating NodeInfo documents and well-known responses
5
+ #
6
+ # @example Basic usage
7
+ # server = NodeInfo::Server.new do |config|
8
+ # config.software_name = "myapp"
9
+ # config.software_version = "1.0.0"
10
+ # config.protocols = ["activitypub"]
11
+ # end
12
+ # server.to_json # => NodeInfo document
13
+ #
14
+ # @example With dynamic usage stats
15
+ # server = NodeInfo::Server.new do |config|
16
+ # config.software_name = "myapp"
17
+ # config.software_version = "1.0.0"
18
+ # config.protocols = ["activitypub"]
19
+ # config.usage_users = -> { User.count }
20
+ # config.usage_users_active_month = -> { User.active.count }
21
+ # end
22
+ class Server
23
+ # Configuration for NodeInfo server
24
+ class Config
25
+ attr_accessor :base_url, :metadata, :open_registrations, :protocols,
26
+ :services_inbound, :services_outbound,
27
+ :software_homepage, :software_version, :software_name, :software_repository,
28
+ :usage_local_comments, :usage_local_posts,
29
+ :usage_users, :usage_users_active_halfyear, :usage_users_active_month
30
+
31
+ def initialize
32
+ @metadata = {}
33
+ @open_registrations = false
34
+ @protocols = []
35
+ @services_inbound = []
36
+ @services_outbound = []
37
+ @usage_users = {}
38
+ end
39
+
40
+ # Get usage users hash, evaluating procs if necessary
41
+ def users_hash
42
+ users = usage_users.is_a?(Proc) ? usage_users.call : usage_users
43
+
44
+ if users.is_a? Hash
45
+ result = {}
46
+ result[:total] = evaluate_value(users[:total]) if users[:total]
47
+ result[:activeMonth] = evaluate_value(users[:activeMonth] || usage_users_active_month)
48
+ result[:activeHalfyear] = evaluate_value(users[:activeHalfyear] || usage_users_active_halfyear)
49
+ else
50
+ result = { total: evaluate_value(users) }
51
+ result[:activeMonth] = evaluate_value(usage_users_active_month)
52
+ result[:activeHalfyear] = evaluate_value(usage_users_active_halfyear)
53
+ end
54
+
55
+ result.compact
56
+ end
57
+
58
+ private
59
+
60
+ def evaluate_value value
61
+ value.is_a?(Proc) ? value.call : value
62
+ end
63
+ end
64
+
65
+ attr_reader :config
66
+
67
+ # Initialize a new server
68
+ # @yield [config] Configuration block
69
+ def initialize
70
+ @config = Config.new
71
+ yield(config) if block_given?
72
+ validate_config!
73
+ end
74
+
75
+ # Generate the well-known nodeinfo response
76
+ # @param base_url [String] Base URL of the server (e.g., "https://example.com")
77
+ # @return [Hash] Well-known response
78
+ def well_known base_url = nil
79
+ url = base_url || config.base_url
80
+ raise ArgumentError, 'base_url is required' unless url
81
+
82
+ {
83
+ links: [
84
+ {
85
+ rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
86
+ href: "#{url}/nodeinfo/2.1"
87
+ }
88
+ ]
89
+ }
90
+ end
91
+
92
+ # Generate the well-known nodeinfo response as JSON
93
+ # @param base_url [String] Base URL of the server
94
+ # @return [String] JSON string
95
+ def well_known_json base_url = nil
96
+ well_known(base_url).to_json
97
+ end
98
+
99
+ # Generate the NodeInfo document
100
+ # @return [NodeInfo::Document]
101
+ def document
102
+ Document.new version: '2.1',
103
+ software: build_software,
104
+ protocols: config.protocols,
105
+ services: build_services,
106
+ open_registrations: config.open_registrations,
107
+ usage: build_usage,
108
+ metadata: config.metadata
109
+ end
110
+
111
+ # Generate the NodeInfo document as a hash
112
+ # @return [Hash]
113
+ def to_h
114
+ document.to_h
115
+ end
116
+
117
+ # Generate the NodeInfo document as JSON
118
+ # @return [String]
119
+ def to_json(*)
120
+ document.to_json(*)
121
+ end
122
+
123
+ private
124
+
125
+ def validate_config!
126
+ raise ValidationError, 'software_name is required' unless config.software_name
127
+ raise ValidationError, 'software_version is required' unless config.software_version
128
+ raise ValidationError, 'protocols is required' unless config.protocols
129
+ raise ValidationError, 'protocols must be an array' unless config.protocols.is_a?(Array)
130
+ end
131
+
132
+ def build_software
133
+ Document::Software.new(
134
+ name: config.software_name,
135
+ version: config.software_version,
136
+ repository: config.software_repository,
137
+ homepage: config.software_homepage
138
+ )
139
+ end
140
+
141
+ def build_services
142
+ Document::Services.new(
143
+ inbound: config.services_inbound,
144
+ outbound: config.services_outbound
145
+ )
146
+ end
147
+
148
+ def build_usage
149
+ usage_hash = {
150
+ users: config.users_hash
151
+ }
152
+
153
+ if config.usage_local_posts
154
+ usage_hash[:local_posts] = if config.usage_local_posts.is_a?(Proc)
155
+ config.usage_local_posts.call
156
+ else
157
+ config.usage_local_posts
158
+ end
159
+ end
160
+
161
+ if config.usage_local_comments
162
+ usage_hash[:local_comments] = if config.usage_local_comments.is_a?(Proc)
163
+ config.usage_local_comments.call
164
+ else
165
+ config.usage_local_comments
166
+ end
167
+ end
168
+
169
+ Document::Usage.new(**usage_hash)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,3 @@
1
+ module NodeInfo
2
+ VERSION = '0.1.0'.freeze
3
+ end
data/lib/node_info.rb ADDED
@@ -0,0 +1,37 @@
1
+ require_relative 'node_info/version'
2
+ require_relative 'node_info/client'
3
+ require_relative 'node_info/server'
4
+ require_relative 'node_info/document'
5
+ require_relative 'node_info/errors'
6
+
7
+ # NodeInfo protocol implementation for the Fediverse
8
+ #
9
+ # This gem provides both client and server functionality for the NodeInfo protocol
10
+ # as specified in FEP-f1d5. NodeInfo is a standardized way for Fediverse servers
11
+ # to expose metadata about themselves.
12
+ #
13
+ # @example Client usage
14
+ # client = NodeInfo::Client.new
15
+ # info = client.fetch("mastodon.social")
16
+ # puts info.software.name # => "mastodon"
17
+ #
18
+ # @example Server usage
19
+ # server = NodeInfo::Server.new do |config|
20
+ # config.software_name = "myapp"
21
+ # config.software_version = "1.0.0"
22
+ # config.protocols = ["activitypub"]
23
+ # config.open_registrations = true
24
+ # end
25
+ # json = server.to_json
26
+ module NodeInfo
27
+ class << self
28
+ # Create a new NodeInfo client
29
+ # @return [NodeInfo::Client]
30
+ def client = Client.new
31
+
32
+ # Create a new NodeInfo server
33
+ # @yield [config] Configuration block
34
+ # @return [NodeInfo::Server]
35
+ def server(&) = Server.new(&)
36
+ end
37
+ end
metadata ADDED
@@ -0,0 +1,67 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: node_info
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Shane Becker
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: http
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '5.0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '5.0'
26
+ description: |
27
+ A pure Ruby implementation of the NodeInfo protocol (FEP-f1d5) for the Fediverse,
28
+ providing both client and server functionality.
29
+ email:
30
+ - veganstraightedge@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - README.md
37
+ - lib/node_info.rb
38
+ - lib/node_info/client.rb
39
+ - lib/node_info/document.rb
40
+ - lib/node_info/errors.rb
41
+ - lib/node_info/server.rb
42
+ - lib/node_info/version.rb
43
+ homepage: https://github.com/xoengineering/node_info
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ source_code_uri: https://github.com/xoengineering/node_info
48
+ changelog_uri: https://github.com/xoengineering/node_info/blob/main/CHANGELOG.md
49
+ rubygems_mfa_required: 'true'
50
+ rdoc_options: []
51
+ require_paths:
52
+ - lib
53
+ required_ruby_version: !ruby/object:Gem::Requirement
54
+ requirements:
55
+ - - ">="
56
+ - !ruby/object:Gem::Version
57
+ version: 4.0.0
58
+ required_rubygems_version: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - ">="
61
+ - !ruby/object:Gem::Version
62
+ version: '0'
63
+ requirements: []
64
+ rubygems_version: 4.0.3
65
+ specification_version: 4
66
+ summary: NodeInfo protocol client and server implementation
67
+ test_files: []