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 +7 -0
- data/CHANGELOG.md +19 -0
- data/README.md +364 -0
- data/lib/node_info/client.rb +111 -0
- data/lib/node_info/document.rb +170 -0
- data/lib/node_info/errors.rb +26 -0
- data/lib/node_info/server.rb +172 -0
- data/lib/node_info/version.rb +3 -0
- data/lib/node_info.rb +37 -0
- metadata +67 -0
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
|
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: []
|