blue_factory 0.2.1 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 85317663395ff94cb8ab867825021b522b84f99ce46d7175411438fb225fa9ac
4
- data.tar.gz: bb1edb042efcb2bb387f0fc1ef37c94169fbd7b579f8d7da5227f5029a488e75
3
+ metadata.gz: 137dfe791cea0735d1e273b584e5e9c0bc7bd475f55fa83480e6401b20d4f074
4
+ data.tar.gz: c191f1baf6c008233796c97eb67f6489b3cf43dcea2a99c5354bb51373410c53
5
5
  SHA512:
6
- metadata.gz: 7d98c5367c5a939cd3bc2c02a51a3513668bc73aa898141e40e1c2d2c12698b3dc653fb3e04bde7fd5b3d0b523c8f7f15507a50ffb264d3329c6bf91661fdff5
7
- data.tar.gz: f5604c93743f6790123a8ade93ed2c6984e6d198692c5b3c5a66b9508597e7f4ecff0d37bb520853f251fcf15afa437f6eb7f9e572a40630437683b9b3345829
6
+ metadata.gz: 9af6495afeae4b5698621433232432ee5deaaedcf55bab7090ab43ab37bdb0c12b47f5b0888d7fea747423f468ecf5378878a270187421c1f20b3fe345a05c2b
7
+ data.tar.gz: b6aa3624fb8c84a479611f143459dd4bfea1f835c59907fbf53ae23f2c8671f0605149fd835162d26940243d4e7975a3cc57cf638d293d93196e40484cf6dda0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [0.3.0] - 2026-02-15
2
+
3
+ - added YARD documentation
4
+ - added explicit `base64` dependency in gemspec
5
+ - added `MAX_LIMIT` (100) and `DEFAULT_LIMIT` (50) constants
6
+ - removed the length limit on feed rkeys, since it isn't actually limited to 15
7
+ - removed deprecated options `enable_unsafe_auth` and `validate_responses`
8
+ - fixed `rake:publish` not working with feed `description` is nil (thx @jthigpen)
9
+ - `rake:publish` shows a better error message when `createSession` requires a 2FA token
10
+ - added various additional checks:
11
+ * `Server` checks if `BlueFactory.hostname` and `BlueFactory.publisher_did` are set before launching
12
+ * `add_feed` checks if the key contains only valid characters
13
+ * `add_feed` checks if the feed class has a `#get_posts` method
14
+ * `getFeedSkeleton` ensures that the limit param is between 1 and 100 (so you don't need to check that)
15
+ * `raw_did` checks if the extracted payload contains an `iss` key
16
+
1
17
  ## [0.2.1] - 2025-12-07
2
18
 
3
19
  - updated gemspec so the gem can be used with Sinatra 4.x
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The zlib License
2
2
 
3
- Copyright (c) 2025 Jakub Suder
3
+ Copyright (c) 2026 Jakub Suder
4
4
 
5
5
  This software is provided 'as-is', without any express or implied
6
6
  warranty. In no event will the authors be held liable for any damages
data/README.md CHANGED
@@ -3,19 +3,21 @@
3
3
  A Ruby gem for hosting custom feeds for Bluesky.
4
4
 
5
5
  > [!NOTE]
6
- > ATProto Ruby gems collection: [skyfall](https://tangled.org/@mackuba.eu/skyfall) | [blue_factory](https://tangled.org/@mackuba.eu/blue_factory) | [minisky](https://tangled.org/@mackuba.eu/minisky) | [didkit](https://tangled.org/@mackuba.eu/didkit)
6
+ > Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
7
7
 
8
8
 
9
9
  ## What does it do
10
10
 
11
11
  BlueFactory is a Ruby library which helps you build a web service that hosts custom feeds a.k.a. "[feed generators](https://github.com/bluesky-social/feed-generator)" for the Bluesky social network. It implements a simple HTTP server based on [Sinatra](https://sinatrarb.com) which provides the required endpoints for the feed generator interface. You need to provide the content for the feed by making a query to your preferred local database.
12
12
 
13
- A feed server will usually be run together with a second piece of code that streams posts from the Bluesky "firehose" stream, runs them through some kind of filter and saves some or all of them to the database. To build that part, you can use my other Ruby gem [Skyfall](https://tangled.org/@mackuba.eu/skyfall).
13
+ A feed server will usually be run together with a second piece of code that streams posts from the Bluesky "firehose" stream, runs them through some kind of filter and saves some or all of them to the database. To build that part, you can use my other Ruby gem [Skyfall](https://tangled.org/mackuba.eu/skyfall).
14
14
 
15
15
 
16
16
  ## Installation
17
17
 
18
- Add this to your `Gemfile`:
18
+ BlueFactory should run on any somewhat recent version of Ruby (3.x/4.x), although it's recommended to use one that's still getting maintenance updates, ideally the latest one. In production, it's also recommended to install it with [YJIT support](https://shopify.engineering/ruby-yjit-is-production-ready) and with [jemalloc](https://scalingo.com/blog/improve-ruby-application-memory-jemalloc). A compatible version should be preinstalled on many Linux systems, otherwise you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
19
+
20
+ To use it in your app, add this to your `Gemfile`:
19
21
 
20
22
  gem 'blue_factory', '~> 0.2'
21
23
 
@@ -27,7 +29,7 @@ The server is configured through the `BlueFactory` module. The two required sett
27
29
  - `publisher_did` – DID identifier of the account that you will publish the feed on (the string that starts with `did:plc:...`)
28
30
  - `hostname` – the hostname on which the feed service will be run
29
31
 
30
- You also need to configure at least one feed by passing a feed key and a feed object. The key is the identifier (rkey) that will appear at the end of the feed URI – it must only contain characters that are valid in URLs (preferably all lowercase) and it can't be longer than 15 characters. The feed object is anything that implements the single required method `get_posts` (could be a class, a module or an instance).
32
+ You also need to configure at least one feed by passing a feed key and a feed object. The key is the identifier (rkey) that will appear at the end of the feed URI – it must only contain characters that are valid in URLs (preferably all lowercase) and it should be rather short. The feed object is anything that implements the single required method `get_posts` (could be a class, a module or an instance).
31
33
 
32
34
  So a simple setup could look like this:
33
35
 
@@ -335,7 +337,7 @@ You also need to republish the feed by running the same task again any time you
335
337
 
336
338
  ## Credits
337
339
 
338
- Copyright © 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).
340
+ Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
339
341
 
340
342
  The code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).
341
343
 
@@ -1,36 +1,43 @@
1
1
  require_relative 'modules/configurable'
2
2
  require_relative 'modules/interactions'
3
3
  require_relative 'modules/feeds'
4
+ require_relative 'errors'
4
5
 
5
6
  module BlueFactory
6
7
  extend Configurable
7
8
  extend Feeds
8
9
  extend Interactions
9
10
 
11
+ #
12
+ # The server's `did:web:` service DID built from the configured hostname.
13
+ # @return [String]
14
+ # @raise [ConfigurationError] if the hostname is not set
15
+ #
10
16
  def self.service_did
17
+ if hostname.nil?
18
+ raise ConfigurationError, "The `hostname` property is not set. Set it with: BlueFactory.set(:hostname, 'example.com')"
19
+ end
20
+
11
21
  'did:web:' + hostname
12
22
  end
13
23
 
24
+ #
25
+ # Current application environment, usually `:development` or `:production`. It's read from the
26
+ # env variables `APP_ENV` or `RACK_ENV`, or `:development` by default.
27
+ #
28
+ # @return [Symbol]
29
+ #
14
30
  def self.environment
15
31
  (ENV['APP_ENV'] || ENV['RACK_ENV'] || :development).to_sym
16
32
  end
17
33
 
18
- configurable :publisher_did, :hostname, :validate_responses, :enable_unsafe_auth
34
+ # @!method self.hostname
35
+ # The hostname on which the feed server runs. Configured through {#set}.
36
+ # @return [String, nil]
19
37
 
20
- def self.set(property, value)
21
- if property.to_sym == :enable_unsafe_auth
22
- puts "==="
23
- puts "WARNING: option :enable_unsafe_auth and old API get_posts(args, user_did) is deprecated and will be removed in version 0.3."
24
- puts "Switch to get_posts(args, context) instead and get the user DID from: context.user.raw_did."
25
- puts "==="
26
- @enable_unsafe_auth = value
27
- elsif property.to_sym == :validate_responses
28
- puts "==="
29
- puts "WARNING: option :validate_responses is deprecated and will be removed in version 0.3. " +
30
- "Responses are now always validated, also in production."
31
- puts "==="
32
- else
33
- super
34
- end
35
- end
38
+ # @!method self.publisher_did
39
+ # The DID of the account publishing the feeds. Configured through {#set}.
40
+ # @return [String, nil]
41
+
42
+ configurable :publisher_did, :hostname
36
43
  end
@@ -1,31 +1,77 @@
1
1
  module BlueFactory
2
+
3
+ #
4
+ # Raised during request processing if the authorization token can't be parsed; it can also be
5
+ # thrown from the user's {FeedHandler#get_posts} method to indicate that the given user is not
6
+ # authorized to access the requested feed, or that the feed requires authentication and it wasn't
7
+ # provided.
8
+ #
9
+ # The server intercepts this exception and returns a "401 Unauthorized" response to the AppView,
10
+ # which should result in the app displaying an error banner (along with the provided error
11
+ # message) in the feed view.
12
+ #
13
+
2
14
  class AuthorizationError < StandardError
15
+
16
+ # @return [String, nil] machine-readable error identifier
3
17
  attr_reader :error_type
4
18
 
5
- def initialize(message = "Authentication required", error_type = nil)
19
+ # @param message [String] human-readable error message
20
+ # @param error_type [String] machine-readable error code
21
+ #
22
+ def initialize(message = "Authentication required", error_type = "AuthenticationRequired")
6
23
  super(message)
7
24
  @error_type = error_type
8
25
  end
9
26
  end
10
27
 
11
- class InvalidKeyError < StandardError
28
+ #
29
+ # Raised when some required configuration is missing or invalid.
30
+ #
31
+
32
+ class ConfigurationError < StandardError
12
33
  end
13
34
 
35
+ #
36
+ # Raised during request processing if some of the parameters are invalid. The error is turned
37
+ # into a "400 Bad Request" response send to the AppView.
38
+ #
39
+
14
40
  class InvalidRequestError < StandardError
41
+
42
+ # @return [String, nil] machine-readable error identifier
15
43
  attr_reader :error_type
16
44
 
17
- def initialize(message, error_type = nil)
45
+ # @param message [String] human-readable error message
46
+ # @param error_type [String] machine-readable error code
47
+ #
48
+ def initialize(message, error_type = "InvalidRequest")
18
49
  super(message)
19
50
  @error_type = error_type
20
51
  end
21
52
  end
22
53
 
54
+ #
55
+ # Raised during request processing if the response returned by the user's class from the
56
+ # {FeedHandler#get_posts} method doesn't fully match the expected format. The error is turned
57
+ # into a "500 Internal Server Error" response returned to the AppView.
58
+ #
59
+
23
60
  class InvalidResponseError < StandardError
24
61
  end
25
62
 
63
+ #
64
+ # Raised during request processing if the user's provided class does not have expected API.
65
+ #
66
+
26
67
  class InvalidFeedClassError < StandardError
27
68
  end
28
69
 
70
+ #
71
+ # Raised during request processing if the `:feed` parameter points to a feed generator URI
72
+ # which is not on the list of feeds configured in the app.
73
+ #
74
+
29
75
  class UnsupportedAlgorithmError < StandardError
30
76
  end
31
77
  end
@@ -0,0 +1,153 @@
1
+ module BlueFactory
2
+
3
+ #
4
+ # @abstract This is not a real class, but it shows what *your* feed class should look like.
5
+ #
6
+ # Abstract interface describing the API that the feed class that will be handling the requests
7
+ # sent to `getFeedSkeleton`, which you pass to {BlueFactory::Feeds#add_feed}, is expected to have.
8
+ #
9
+ # All methods except {#get_posts} are only used briefly when submitting the feed as a record
10
+ # using the `bluesky:publish` rake task, so only {#get_posts} is really required during normal
11
+ # day to day operation.
12
+ #
13
+
14
+ class FeedHandler
15
+
16
+ # The main method required to serve a feed. It accepts a hash of query params and optionally
17
+ # a request context object, and is expected to return a hash of data with URIs of posts to
18
+ # be displayed.
19
+ #
20
+ # The response hash should include:
21
+ #
22
+ # - `:posts` *(Array<String, Hash>)* — an array of posts (see below)
23
+ # - `:cursor` *(String)* — optionally, a cursor to be passed when loading the next page
24
+ # - `:req_id` *(String)* — optionally, a request ID that will be passed with "interactions"
25
+ #
26
+ # A post in `:posts` can be either a string with the AT URI of the post, or a hash with fields:
27
+ #
28
+ # - `:post` *(String)* — the AT URI of the post
29
+ # - `:context` *(String)* — optionally, a context string that will be passed with "interactions"
30
+ # - `:reason` *(Hash)* — optionally, a reason why a post appears in the feed:
31
+ # - `{ :repost => repost_uri }` — the post is displayed because someone reposted it (the uri points to a repost record)
32
+ # - `{ :pin => true }` — the post is pinned at the top of the feed
33
+ #
34
+ # @example Simple posts response
35
+ # {
36
+ # posts: [
37
+ # "at://.../app.bsky.feed.post/...",
38
+ # "at://.../app.bsky.feed.post/...",
39
+ # "at://.../app.bsky.feed.post/...",
40
+ # ...
41
+ # ],
42
+ # cursor: "1760639159"
43
+ # }
44
+ #
45
+ # @example More complex response
46
+ # {
47
+ # posts: [
48
+ # {
49
+ # post: "at://.../app.bsky.feed.post/...",
50
+ # reason: { pin: true }
51
+ # },
52
+ # "at://.../app.bsky.feed.post/...",
53
+ # "at://.../app.bsky.feed.post/...",
54
+ # "at://.../app.bsky.feed.post/...",
55
+ # {
56
+ # post: "at://.../app.bsky.feed.post/...",
57
+ # reason: { repost: "at://.../app.bsky.feed.repost/..." },
58
+ # context: 'qweqweqwe'
59
+ # },
60
+ # "at://.../app.bsky.feed.post/...",
61
+ # ...
62
+ # ],
63
+ # cursor: "1760639159",
64
+ # req_id: "req2048"
65
+ # }
66
+ #
67
+ # @example User authorization
68
+ # def get_posts(params, context)
69
+ # if AUTHORIZED_USERS.include?(context.user.raw_did)
70
+ # # ...
71
+ # else
72
+ # raise BlueFactory::AuthorizationError, "You shall not pass!"
73
+ # end
74
+ # end
75
+ #
76
+ # @overload get_posts(params)
77
+ # Use this version if you don't need the context object.
78
+ #
79
+ # @param params [Hash] the received query params
80
+ #
81
+ # @option params [String] :feed
82
+ # the AT URI of the feed record
83
+ # @option params [String, nil] :cursor
84
+ # the cursor returned from the last response (the format is chosen by you)
85
+ # @option params [Integer, nil] :limit
86
+ # requested number of posts (between 1 and 100)
87
+ #
88
+ # @raise [InvalidRequestError] if the request is invalid for some reason (e.g. bad cursor format)
89
+ # @return [Hash] post data
90
+ #
91
+ # @overload get_posts(params, context)
92
+ # Use this version if you want to be passed the context object.
93
+ #
94
+ # @param params [Hash] the received query params
95
+ # @param context [RequestContext] request context
96
+ #
97
+ # @option params [String] :feed
98
+ # the AT URI of the feed record
99
+ # @option params [String, nil] :cursor
100
+ # the cursor returned from the last response (the format is chosen by you)
101
+ # @option params [Integer, nil] :limit
102
+ # requested number of posts (between 1 and 100)
103
+ #
104
+ # @raise [AuthorizationError] if the requesting user is not authorized to see this feed
105
+ # @raise [InvalidRequestError] if the request is invalid for some reason (e.g. bad cursor format)
106
+ # @return [Hash] post data
107
+
108
+ def get_posts(params, context = nil)
109
+ end
110
+
111
+ # Returns the name of the feed that will be shown to the user in the app.
112
+ #
113
+ # This name is displayed not only in the header and in link cards, but also in the pinned feeds
114
+ # tabs and the right sidebar on the desktop, so it should not be too long, ideally 1-3 words.
115
+ #
116
+ # @return [String]
117
+
118
+ def display_name
119
+ end
120
+
121
+ # (optional) Longer description of the feed, which explains how the feed works and what kind of
122
+ # content it serves.
123
+ #
124
+ # @return [String, nil]
125
+
126
+ def description
127
+ end
128
+
129
+ # (optional) Path of the image file which will be used as the feed's avatar (PNG or JPG).
130
+ #
131
+ # @return [String, nil]
132
+
133
+ def avatar_file
134
+ end
135
+
136
+ # (optional) Special feed type - return `:video` for video feeds.
137
+ #
138
+ # @return [String, nil]
139
+
140
+ def content_mode
141
+ end
142
+
143
+ # (optional) Return true to opt-in to receiving interactions from users viewing the feed.
144
+ #
145
+ # @see Interaction
146
+ # @return [Boolean]
147
+
148
+ def accepts_interactions
149
+ end
150
+
151
+ private_class_method :new
152
+ end
153
+ end
@@ -1,17 +1,74 @@
1
1
  module BlueFactory
2
+
3
+ #
4
+ # Represents a single feed interaction event.
5
+ #
6
+ # Interactions are various kinds of events registered while a user is browsing the feed,
7
+ # either automatically in response to their browsing (e.g. "post seen", "post liked") or as
8
+ # explicit actions taken consciously by the user, with the intention of passing information back
9
+ # to the feed operator (clicking "show more like this" / "show less like this" in the post context
10
+ # menu). Interactions are submitted through the `app.bsky.feed.sendInteractions` endpoint and may
11
+ # be batched.
12
+ #
13
+ # When that endpoint is called, BlueFactory wraps the submitted interaction objects in
14
+ # instances of {Interaction} and passes them to the configured handler block, which is set up
15
+ # through {Interactions#on_interactions} or {Interactions#interactions_handler=} on {BlueFactory}.
16
+ #
17
+ # Note: the {Interaction} objects include info about what interaction has been triggered and
18
+ # on which specific post (post AT URI), but they don't include info in which *feed* it has been
19
+ # triggered. If you have multiple feeds configured and need to know which feed an interaction
20
+ # is from, you need to include either a `context` field (assigned to a specific post) or a
21
+ # `req_id` field (assigned to the whole request) in the data response returned from
22
+ # {FeedHandler#get_posts}.
23
+ #
24
+
2
25
  class Interaction
26
+
27
+ #
28
+ # Mapping of interaction identifiers in the protocol to short code symbols.
29
+ #
30
+
3
31
  EVENTS = {
32
+ # user has liked a post in the feed
4
33
  'app.bsky.feed.defs#interactionLike' => :like,
34
+
35
+ # user has quoted a post in the feed
5
36
  'app.bsky.feed.defs#interactionQuote' => :quote,
37
+
38
+ # user has replied to a post in the feed
6
39
  'app.bsky.feed.defs#interactionReply' => :reply,
40
+
41
+ # user has reposted a post in the feed
7
42
  'app.bsky.feed.defs#interactionRepost' => :repost,
43
+
44
+ # user has scrolled down to the given post
8
45
  'app.bsky.feed.defs#interactionSeen' => :seen,
46
+
47
+ # user has clicked "show less like this" on a post
9
48
  'app.bsky.feed.defs#requestLess' => :request_less,
49
+
50
+ # user has clicked "show more like this" on a post
10
51
  'app.bsky.feed.defs#requestMore' => :request_more
11
52
  }
12
53
 
13
- attr_reader :item, :event, :context, :req_id, :type
54
+ # @return [String] the URI of the post
55
+ attr_reader :item
56
+
57
+ # @return [String] the protocol identifier of the interaction type
58
+ attr_reader :event
59
+
60
+ # @return [Symbol, nil] short code symbol of the interaction type
61
+ attr_reader :type
62
+
63
+ # @return [String, nil] optional post context from the original response
64
+ attr_reader :context
65
+
66
+ # @return [String, nil] optional request identifier from the original response
67
+ attr_reader :req_id
14
68
 
69
+ #
70
+ # @param data [Hash] interaction JSON data
71
+ #
15
72
  def initialize(data)
16
73
  @item = data['item']
17
74
  @event = data['event']
@@ -1,4 +1,12 @@
1
1
  module BlueFactory
2
+
3
+ #
4
+ # @api private
5
+ #
6
+ # Adds a helper for configuring supported properties to {BlueFactory}.
7
+ # Use these APIs through the main {BlueFactory} module, not directly.
8
+ #
9
+
2
10
  module Configurable
3
11
  def self.extended(target)
4
12
  target.instance_variable_set('@properties', [])
@@ -10,6 +18,14 @@ module BlueFactory
10
18
  singleton_class.attr_reader(*properties)
11
19
  end
12
20
 
21
+ #
22
+ # Sets a configurable property to the given value.
23
+ #
24
+ # @api public
25
+ # @param property [String, Symbol] configuration key
26
+ # @param value [Object] value to assign
27
+ # @raise [NoMethodError] if no such property is defined
28
+ #
13
29
  def set(property, value)
14
30
  if @properties.include?(property.to_sym)
15
31
  self.instance_variable_set("@#{property}", value)
@@ -19,5 +35,6 @@ module BlueFactory
19
35
  end
20
36
 
21
37
  private :configurable
38
+ private_class_method :extended
22
39
  end
23
40
  end
@@ -1,33 +1,92 @@
1
+ require_relative '../errors'
2
+
1
3
  module BlueFactory
4
+
5
+ #
6
+ # @api private
7
+ #
8
+ # Adds configuration for feeds and provides lookup of configured feeds to {BlueFactory}.
9
+ # Use these APIs through the main {BlueFactory} module, not directly.
10
+ #
11
+
2
12
  module Feeds
3
13
  def self.extended(target)
4
14
  target.instance_variable_set('@feeds', {})
5
15
  end
6
16
 
7
- def add_feed(key, feed_class)
17
+ RKEY_REGEXP = /\A[A-Za-z0-9\.\-_:~]+\z/
18
+
19
+ #
20
+ # Registers a feed handler for a given rkey. The full AT URI of the feed generator, which is
21
+ # listed in `describeFeedGenerator` and expected in the `feed` parameter to `getFeedSkeleton`
22
+ # will be: `"at://#{publisher_did}/app.bsky.feed.generator/#{key}"`. The key should be a string
23
+ # no longer than 15 characters and should not contain slashes, preferably only lowercase letters,
24
+ # digits and hyphens.
25
+ #
26
+ # The feed handler is expected to be an object which has a `#get_posts` method which accepts
27
+ # requests routed from the BlueFactory server and returns post data in a specified format.
28
+ # See the abstract {FeedHandler} class for a description of the expected API.
29
+ #
30
+ # @api public
31
+ # @param key [String] the feed rkey
32
+ # @param feed_handler [#get_posts] feed implementation object
33
+ # @raise [ConfigurationError] if the key has invalid format
34
+ #
35
+ def add_feed(key, feed_handler)
8
36
  validate_key(key)
9
- @feeds[key.to_s] = feed_class
37
+ validate_feed(key, feed_handler)
38
+
39
+ @feeds[key.to_s] = feed_handler
10
40
  end
11
41
 
42
+ #
43
+ # Lists all configured feed keys.
44
+ #
45
+ # @api public
46
+ # @return [Array<String>]
47
+ #
12
48
  def feed_keys
13
49
  @feeds.keys
14
50
  end
15
51
 
52
+ #
53
+ # Returns a feed handler configured for a given rkey.
54
+ #
55
+ # @api public
56
+ # @param key [String] feed key
57
+ # @return [#get_posts, nil] feed object, if registered
58
+ #
16
59
  def get_feed(key)
17
60
  @feeds[key.to_s]
18
61
  end
19
62
 
63
+ #
64
+ # Returns an array of all registered feed handler objects.
65
+ #
66
+ # @api public
67
+ # @return [Array<#get_posts>]
68
+ #
20
69
  def all_feeds
21
70
  @feeds.values
22
71
  end
23
72
 
73
+
24
74
  private
25
75
 
26
76
  def validate_key(key)
27
- raise InvalidKeyError, "Key must be a string" unless key.is_a?(String)
28
- raise InvalidKeyError, "Key must not be empty" if key == ''
29
- raise InvalidKeyError, "Key must not contain a slash" if key.include?('/')
30
- raise InvalidKeyError, "Key must not be longer than 15 characters" if key.length > 15
77
+ raise ConfigurationError, "Key must be a string (got: #{key.inspect})" unless key.is_a?(String)
78
+ raise ConfigurationError, "Key must not be empty" if key == ''
79
+ raise ConfigurationError, "Key #{key.inspect} contains invalid characters" if key !~ RKEY_REGEXP
31
80
  end
81
+
82
+ def validate_feed(key, feed)
83
+ get_posts = feed.method(:get_posts) rescue nil
84
+
85
+ if get_posts.nil?
86
+ raise InvalidFeedClassError, "The feed object for '#{key}' is missing required method `get_posts`"
87
+ end
88
+ end
89
+
90
+ private_class_method :extended
32
91
  end
33
92
  end
@@ -1,9 +1,47 @@
1
1
  module BlueFactory
2
+
3
+ #
4
+ # @api private
5
+ #
6
+ # Adds configuration for an interactions handler to {BlueFactory}.
7
+ # Use these APIs through the main {BlueFactory} module, not directly.
8
+ #
9
+
2
10
  module Interactions
11
+
12
+ #
13
+ # Returns the currently configured feed interactions handler.
14
+ #
15
+ # @api public
16
+ # @see Interaction
17
+ # @yieldparam interactions [Array<Interaction>] one or more received interactions
18
+ # @yieldparam context [RequestContext] HTTP request context, including e.g. user auth info
19
+ #
3
20
  def on_interactions(&block)
4
21
  @interactions_handler = block
5
22
  end
6
23
 
7
- attr_accessor :interactions_handler
24
+ #
25
+ # Returns the currently configured feed interactions handler.
26
+ #
27
+ # @api public
28
+ # @see Interaction
29
+ # @see #on_interactions
30
+ # @return [Proc, nil]
31
+ #
32
+ def interactions_handler
33
+ @interactions_handler
34
+ end
35
+
36
+ #
37
+ # Registers a callback for incoming feed interactions.
38
+ #
39
+ # @api public
40
+ # @see Interaction
41
+ # @see #on_interactions
42
+ #
43
+ def interactions_handler=(block)
44
+ @interactions_handler = block
45
+ end
8
46
  end
9
47
  end
@@ -1,6 +1,11 @@
1
1
  require_relative 'errors'
2
2
 
3
3
  module BlueFactory
4
+
5
+ #
6
+ # @private
7
+ #
8
+
4
9
  class OutputGenerator
5
10
  AT_URI_REGEXP = %r(^at://did:(plc:[a-z0-9]+|web:[a-z0-9\-]+(\.[a-z0-9\-]+)+)/app\.bsky\.feed\.post/[a-z0-9]+$)
6
11
 
@@ -1,21 +1,50 @@
1
1
  require_relative 'user_info'
2
2
 
3
3
  module BlueFactory
4
+
5
+ #
6
+ # An object which provides some metadata about the `getFeedSkeleton` request being processed.
7
+ # The context is passed to the {FeedHandler#get_posts} method of the provided feed handler object,
8
+ # if the method is made to accept two parameters.
9
+ #
10
+
4
11
  class RequestContext
5
- attr_accessor :request
6
12
 
13
+ # Returns the underlying request object.
14
+ #
15
+ # @return [Sinatra::Request]
16
+ # see {https://www.rubydoc.info/gems/sinatra/Sinatra/Request Sinatra::Request}
17
+ #
18
+ attr_reader :request
19
+
20
+ # @param request
21
+ # ({https://www.rubydoc.info/gems/sinatra/Sinatra/Request Sinatra::Request})
22
+ # &nbsp;&mdash; the original Sinatra/Rack request object
23
+ #
7
24
  def initialize(request)
8
25
  @request = request
9
26
  end
10
27
 
28
+ #
29
+ # The request environment hash.
30
+ # @return [Hash] Rack environment
31
+ #
11
32
  def env
12
33
  @request.env
13
34
  end
14
35
 
36
+ #
37
+ # Parsed user information derived from the authorization header.
38
+ # @return [UserInfo]
39
+ #
15
40
  def user
16
41
  UserInfo.new(env['HTTP_AUTHORIZATION'])
17
42
  end
18
43
 
44
+ #
45
+ # Indicates if an authorization header was provided at all or not.
46
+ # @return [Boolean] true when the authorization header is non-empty
47
+ #
19
48
  def has_auth?
20
49
  env['HTTP_AUTHORIZATION'] != nil
21
50
  end
@@ -8,7 +8,36 @@ require_relative 'output_generator'
8
8
  require_relative 'request_context'
9
9
 
10
10
  module BlueFactory
11
+
12
+ #
13
+ # Sinatra server implementing the required feed generator endpoints. Usually you will only
14
+ # interact with it in order to start the server in a startup script.
15
+ #
16
+ # @example Starting locally during development
17
+ # BlueFactory::Server.run!
18
+ #
19
+ # @example Starting from Rack config `config.ru` in production
20
+ # run BlueFactory::Server
21
+ #
22
+
11
23
  class Server < Sinatra::Base
24
+
25
+ # @private
26
+ @@config_checked = false
27
+
28
+ # Run the Sinatra app as a self-hosted server.
29
+ # See {https://www.rubydoc.info/gems/sinatra/Sinatra/Base#run!-class_method Sinatra::Base.run!}.
30
+ # @raise [ConfigurationError] if the {BlueFactory.hostname} or {BlueFactory.publisher_did} is not set
31
+
32
+ def self.run!(options = {}, &block)
33
+ verify_config
34
+ super
35
+ end
36
+
37
+ before do
38
+ Server.send(:verify_config) unless @@config_checked
39
+ end
40
+
12
41
  configure do
13
42
  disable :static
14
43
  enable :quiet
@@ -17,6 +46,10 @@ module BlueFactory
17
46
  end
18
47
 
19
48
  helpers do
49
+ #
50
+ # @api private
51
+ #
52
+
20
53
  def config
21
54
  BlueFactory
22
55
  end
@@ -43,7 +76,7 @@ module BlueFactory
43
76
  end
44
77
 
45
78
  if feed_uri !~ %r(^at://[\w\-\.\:]+/[\w\.]+/[\w\.\-]+$)
46
- raise InvalidRequestError, "Error: feed must be a valid at-uri"
79
+ raise InvalidRequestError, "Error: feed must be a valid AT URI"
47
80
  end
48
81
 
49
82
  feed_key = feed_uri.split('/').last
@@ -55,20 +88,26 @@ module BlueFactory
55
88
 
56
89
  feed
57
90
  end
91
+
92
+ def validate_limit(args)
93
+ if args[:limit]
94
+ args[:limit] = args[:limit].to_i.clamp(1, MAX_LIMIT)
95
+ end
96
+ end
58
97
  end
59
98
 
60
99
  get '/xrpc/app.bsky.feed.getFeedSkeleton' do
61
100
  begin
62
101
  feed = get_feed(params[:feed])
63
- get_posts = feed.method(:get_posts)
64
102
  args = params.slice(:feed, :cursor, :limit)
103
+ validate_limit(args)
65
104
 
66
- if config.enable_unsafe_auth
67
- context = RequestContext.new(request)
68
- response = feed.get_posts(args, context.user.raw_did)
69
- elsif get_posts.arity == 1
105
+ get_posts = feed.method(:get_posts)
106
+
107
+ case get_posts.arity
108
+ when 1
70
109
  response = feed.get_posts(args)
71
- elsif get_posts.arity == 2
110
+ when 2
72
111
  context = RequestContext.new(request)
73
112
  response = feed.get_posts(args, context)
74
113
  else
@@ -78,17 +117,17 @@ module BlueFactory
78
117
  json = OutputGenerator.new.generate(response)
79
118
  return json_response(json)
80
119
  rescue InvalidRequestError => e
81
- return json_error(e.error_type || "InvalidRequest", e.message)
120
+ return json_error(e.error_type, e.message)
82
121
  rescue AuthorizationError => e
83
- return json_error(e.error_type || "AuthenticationRequired", e.message, status: 401)
122
+ return json_error(e.error_type, e.message, status: 401)
84
123
  rescue UnsupportedAlgorithmError => e
85
124
  return json_error("UnsupportedAlgorithm", e.message)
86
125
  rescue InvalidResponseError => e
87
- if settings.development?
88
- return json_error("InvalidResponse", e.message, status: 500)
89
- else
126
+ if settings.production?
90
127
  request.logger&.<< "#{e.class}: #{e.message}\n"
91
128
  return json_error("InvalidResponse", "Feed response was invalid", status: 500)
129
+ else
130
+ return json_error("InvalidResponse", e.message, status: 500)
92
131
  end
93
132
  end
94
133
  end
@@ -126,5 +165,23 @@ module BlueFactory
126
165
  json_error('MethodNotImplemented', 'Method Not Implemented', status: 501)
127
166
  end
128
167
  end
168
+
169
+
170
+ private
171
+
172
+ def self.verify_config
173
+ if BlueFactory.hostname.nil?
174
+ raise ConfigurationError, "The `hostname` property is not set. Set it with: BlueFactory.set(:hostname, 'example.com')"
175
+ end
176
+
177
+ if BlueFactory.publisher_did.nil?
178
+ raise ConfigurationError,
179
+ "The `publisher_did` property is not set. Set it with: BlueFactory.set(:publisher_did, 'did:plc:qweqweqwe')"
180
+ end
181
+
182
+ @@config_checked = true
183
+ end
184
+
185
+ private_class_method :verify_config
129
186
  end
130
187
  end
@@ -87,10 +87,19 @@ namespace :bluesky do
87
87
  password = STDIN.noecho(&:gets).chomp
88
88
  puts
89
89
 
90
- json = BlueFactory::Net.post_request(pds_host, 'com.atproto.server.createSession', {
91
- identifier: publisher_did,
92
- password: password
93
- })
90
+ begin
91
+ json = BlueFactory::Net.post_request(pds_host, 'com.atproto.server.createSession', {
92
+ identifier: publisher_did,
93
+ password: password
94
+ })
95
+ rescue BlueFactory::Net::ResponseError => e
96
+ if e.response.code.to_i == 401 && e.response.body.include?('"error":"AuthFactorTokenRequired"')
97
+ puts "Error: this Rake task doesn't support logging in with 2FA. Please use an app password here if you have 2FA enabled."
98
+ exit 1
99
+ else
100
+ raise
101
+ end
102
+ end
94
103
 
95
104
  access_token = json['accessJwt']
96
105
 
@@ -104,15 +113,15 @@ namespace :bluesky do
104
113
  record = {
105
114
  did: BlueFactory.service_did,
106
115
  displayName: feed_display_name,
107
- description: feed_description,
108
116
  createdAt: Time.now.iso8601,
109
117
  }
110
118
 
111
119
  record[:avatar] = avatar_ref if avatar_ref
120
+ record[:description] = feed_description if feed_description
112
121
  record[:contentMode] = feed_content_mode if feed_content_mode
113
122
  record[:acceptsInteractions] = true if feed.respond_to?(:accepts_interactions) && feed.accepts_interactions
114
123
 
115
- json = BlueFactory::Net.post_request(pds_host, 'com.atproto.repo.putRecord', {
124
+ BlueFactory::Net.post_request(pds_host, 'com.atproto.repo.putRecord', {
116
125
  repo: publisher_did,
117
126
  collection: BlueFactory::FEED_GENERATOR_TYPE,
118
127
  rkey: feed_key,
@@ -3,8 +3,25 @@ require 'net/http'
3
3
  require 'uri'
4
4
 
5
5
  module BlueFactory
6
+
7
+ #
8
+ # @api private
9
+ # HTTP request helpers.
10
+ #
11
+
6
12
  module Net
7
- class ResponseError < StandardError; end
13
+
14
+ # @api private
15
+ # Thrown when a HTTP response is not 200 OK.
16
+ #
17
+ class ResponseError < StandardError
18
+ attr_reader :response
19
+
20
+ def initialize(response, message)
21
+ @response = response
22
+ super(message)
23
+ end
24
+ end
8
25
 
9
26
  def self.get_request(server, method = nil, params = nil, auth: nil)
10
27
  headers = {}
@@ -17,7 +34,7 @@ module BlueFactory
17
34
  end
18
35
 
19
36
  response = ::Net::HTTP.get_response(url, headers)
20
- raise ResponseError, "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
37
+ raise ResponseError.new(response, "Invalid response: #{response.code} #{response.body}") if response.code.to_i / 100 != 2
21
38
 
22
39
  JSON.parse(response.body)
23
40
  end
@@ -30,7 +47,7 @@ module BlueFactory
30
47
  body = data.is_a?(String) ? data : data.to_json
31
48
 
32
49
  response = ::Net::HTTP.post(URI("#{server}/xrpc/#{method}"), body, headers)
33
- raise ResponseError, "Invalid response: #{response.code} #{response.body}" if response.code.to_i / 100 != 2
50
+ raise ResponseError.new(response, "Invalid response: #{response.code} #{response.body}") if response.code.to_i / 100 != 2
34
51
 
35
52
  JSON.parse(response.body)
36
53
  end
@@ -4,11 +4,27 @@ require 'json'
4
4
  require_relative 'errors'
5
5
 
6
6
  module BlueFactory
7
+
8
+ #
9
+ # An object which provides info about the user making the request, based on the
10
+ # included authorization header (if any). Accessed through {RequestContext#user}.
11
+ #
12
+
7
13
  class UserInfo
14
+
15
+ #
16
+ # @param auth_header [String, nil] value of the "Authorization" HTTP header
17
+ #
8
18
  def initialize(auth_header)
9
19
  @auth = auth_header
10
20
  end
11
21
 
22
+ #
23
+ # The bearer token extracted from the authorization header.
24
+ #
25
+ # @return [String, nil]
26
+ # @raise [AuthorizationError] if the header is not a "Bearer" token
27
+ #
12
28
  def token
13
29
  @token ||= begin
14
30
  if @auth.nil? || @auth.strip.empty?
@@ -21,6 +37,17 @@ module BlueFactory
21
37
  end
22
38
  end
23
39
 
40
+ #
41
+ # Returns the user's (unverified) DID decoded from the JWT payload of the bearer token.
42
+ #
43
+ # Important: this method does not verify the signature of the token, which means
44
+ # the token can be fairly easily forged to impersonate another user, and this method
45
+ # would not detect that. Do not rely on it for use cases where it's important to be
46
+ # certain of the requesting user's identity.
47
+ #
48
+ # @return [String, nil] user DID decoded from the token
49
+ # @raise [AuthorizationError] when the token format is invalid
50
+ #
24
51
  def raw_did
25
52
  return nil if token.nil?
26
53
 
@@ -29,10 +56,15 @@ module BlueFactory
29
56
 
30
57
  begin
31
58
  payload = JSON.parse(Base64.decode64(parts[1]))
32
- payload['iss']
33
59
  rescue StandardError => e
34
60
  raise AuthorizationError.new("Invalid JWT format", "BadJwt")
35
61
  end
62
+
63
+ if did = payload['iss']
64
+ did
65
+ else
66
+ raise AuthorizationError.new("Invalid JWT format", "BadJwt")
67
+ end
36
68
  end
37
69
  end
38
70
  end
@@ -1,3 +1,3 @@
1
1
  module BlueFactory
2
- VERSION = "0.2.1"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/blue_factory.rb CHANGED
@@ -2,6 +2,36 @@ require_relative 'blue_factory/configuration'
2
2
  require_relative 'blue_factory/server'
3
3
  require_relative 'blue_factory/version'
4
4
 
5
+ #
6
+ # This is the main module of the library, through which you configure the feed service.
7
+ #
8
+ # @example Configuring the server and feeds
9
+ # require 'blue_factory'
10
+ #
11
+ # BlueFactory.set :publisher_did, 'did:plc:qwertyuiopasdf'
12
+ # BlueFactory.set :hostname, 'feeds.example.com'
13
+ #
14
+ # BlueFactory.add_feed 'photos', PhotographyFeed.new
15
+ #
16
+ # @example Handling interactions
17
+ # BlueFactory.on_interactions do |interactions, context|
18
+ # interactions.each do |i|
19
+ # unless i.type == :seen
20
+ # puts "[#{Time.now}] #{context.user.raw_did}: #{i.type} #{i.item}"
21
+ # end
22
+ # end
23
+ # end
24
+ #
25
+
5
26
  module BlueFactory
27
+
28
+ # The collection NSID of a Bluesky feed generator service.
6
29
  FEED_GENERATOR_TYPE = 'app.bsky.feed.generator'
30
+
31
+ # Maximum allowed value for the limit parameter in `getFeedSkeleton`.
32
+ MAX_LIMIT = 100
33
+
34
+ # Default value for the limit parameter in `getFeedSkeleton`. This value isn't used by the library
35
+ # (if no limit parameter is passed, it isn't added), but you can use it as a fallback in your code.
36
+ DEFAULT_LIMIT = 50
7
37
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blue_factory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
@@ -9,6 +9,20 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: sinatra
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -46,6 +60,7 @@ files:
46
60
  - lib/blue_factory.rb
47
61
  - lib/blue_factory/configuration.rb
48
62
  - lib/blue_factory/errors.rb
63
+ - lib/blue_factory/feed_handler.rb
49
64
  - lib/blue_factory/interaction.rb
50
65
  - lib/blue_factory/modules/configurable.rb
51
66
  - lib/blue_factory/modules/feeds.rb
@@ -59,13 +74,13 @@ files:
59
74
  - lib/blue_factory/user_info.rb
60
75
  - lib/blue_factory/version.rb
61
76
  - sig/blue_factory.rbs
62
- homepage: https://github.com/mackuba/blue_factory
77
+ homepage: https://ruby.sdk.blue
63
78
  licenses:
64
79
  - Zlib
65
80
  metadata:
66
- bug_tracker_uri: https://github.com/mackuba/blue_factory/issues
67
- changelog_uri: https://github.com/mackuba/blue_factory/blob/master/CHANGELOG.md
68
- source_code_uri: https://github.com/mackuba/blue_factory
81
+ bug_tracker_uri: https://tangled.org/mackuba.eu/blue_factory/issues
82
+ changelog_uri: https://tangled.org/mackuba.eu/blue_factory/blob/master/CHANGELOG.md
83
+ source_code_uri: https://tangled.org/mackuba.eu/blue_factory
69
84
  rdoc_options: []
70
85
  require_paths:
71
86
  - lib
@@ -80,7 +95,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
80
95
  - !ruby/object:Gem::Version
81
96
  version: '0'
82
97
  requirements: []
83
- rubygems_version: 3.6.9
98
+ rubygems_version: 4.0.3
84
99
  specification_version: 4
85
100
  summary: A Ruby gem for hosting custom feeds for Bluesky
86
101
  test_files: []