blue_factory 0.1.6 → 0.2.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +138 -38
- data/lib/blue_factory/configuration.rb +18 -1
- data/lib/blue_factory/errors.rb +3 -0
- data/lib/blue_factory/interaction.rb +23 -0
- data/lib/blue_factory/modules/configurable.rb +1 -1
- data/lib/blue_factory/modules/interactions.rb +9 -0
- data/lib/blue_factory/output_generator.rb +83 -0
- data/lib/blue_factory/request_context.rb +23 -0
- data/lib/blue_factory/server.rb +43 -62
- data/lib/blue_factory/tasks/publish.rake +1 -0
- data/lib/blue_factory/user_info.rb +38 -0
- data/lib/blue_factory/version.rb +1 -1
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a6c7d1d14d831467efed09597e81ee9e1b15a1c6581c06ec4853cc800f966da7
|
|
4
|
+
data.tar.gz: f4e819cdcd5877d97af4bae043859844426f5e43432688df7a4afddce87df0ce
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4bb21855d3f9803637d352581717c7e8720dfa82a55f6908eaf50852ca357ddca1c2f193bff5188dcb51d2b6bce0d15196c1e0799189a7c2fea5f8a01ccee559
|
|
7
|
+
data.tar.gz: b23b812347edb6bdc140293044fafe56e73ff457466fd91512e669eef42501c863cadd700231e2e1234b3962513b86249b6fb408674df18f0859f594be499eec
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,19 @@
|
|
|
1
|
+
## [0.2.0] - 2025-10-16
|
|
2
|
+
|
|
3
|
+
Breaking changes / deprecations:
|
|
4
|
+
|
|
5
|
+
- changed `get_posts` API to (optionally) pass a `context` as the second param instead of `current_user`; context includes the user DID as `context.user.raw_did`, and will also include a verified DID in a future version
|
|
6
|
+
- `enable_unsafe_auth` option is deprecated – for now, `get_posts` will still work with `(params, current_user)` if that option is set
|
|
7
|
+
- `validate_responses` option is deprecated – responses are now always validated, also in production
|
|
8
|
+
|
|
9
|
+
Also:
|
|
10
|
+
|
|
11
|
+
- `get_posts` response can now return each post either as an AT URI string as before, or as a hash with URI in the `:post` field
|
|
12
|
+
- added support for post reasons (repost, pin) via `:reason` field in the post hash
|
|
13
|
+
- added support for post context info via `:context` field in the post hash and request ID info via `:req_id` in the response hash
|
|
14
|
+
- added `accepts_interactions` property to the feed API which lets it accept user interactions feedback ("show more/less" etc.)
|
|
15
|
+
- added support for accepting interactions sent to `app.bsky.feed.sendInteractions` via `on_interactions` handler
|
|
16
|
+
|
|
1
17
|
## [0.1.6] - 2025-07-17
|
|
2
18
|
|
|
3
19
|
- detect PDS hostname automatically in the `bluesky:publish` Rake task
|
data/README.md
CHANGED
|
@@ -3,29 +3,31 @@
|
|
|
3
3
|
A Ruby gem for hosting custom feeds for Bluesky.
|
|
4
4
|
|
|
5
5
|
> [!NOTE]
|
|
6
|
-
> ATProto Ruby gems collection: [skyfall](https://
|
|
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)
|
|
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://
|
|
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
|
-
|
|
18
|
+
Add this to your `Gemfile`:
|
|
19
|
+
|
|
20
|
+
gem 'blue_factory', '~> 0.2'
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
## Usage
|
|
22
24
|
|
|
23
25
|
The server is configured through the `BlueFactory` module. The two required settings are:
|
|
24
26
|
|
|
25
|
-
- `publisher_did`
|
|
26
|
-
- `hostname`
|
|
27
|
+
- `publisher_did` – DID identifier of the account that you will publish the feed on (the string that starts with `did:plc:...`)
|
|
28
|
+
- `hostname` – the hostname on which the feed service will be run
|
|
27
29
|
|
|
28
|
-
You also need to configure at least one feed by passing a feed key and a feed object. The key is the identifier that will appear at the end of the feed URI
|
|
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).
|
|
29
31
|
|
|
30
32
|
So a simple setup could look like this:
|
|
31
33
|
|
|
@@ -39,31 +41,85 @@ BlueFactory.add_feed 'starwars', StarWarsFeed.new
|
|
|
39
41
|
```
|
|
40
42
|
|
|
41
43
|
|
|
42
|
-
|
|
44
|
+
## The feed API
|
|
43
45
|
|
|
44
46
|
The `get_posts` method of the feed object should:
|
|
45
47
|
|
|
46
48
|
- accept a `params` argument which is a hash with fields: `:feed`, `:cursor` and `:limit` (the last two are optional)
|
|
47
|
-
- optionally, accept a second `
|
|
48
|
-
- return a hash with
|
|
49
|
+
- optionally, it can accept a second `context` argument with additional info like the authenticated user's DID (see "[Authentication](#authentication)")
|
|
50
|
+
- return a response hash with the posts data, with at least one key `:posts`
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
### Parameters
|
|
49
54
|
|
|
50
|
-
The `:feed` is the `at://` URI of the feed.
|
|
55
|
+
The `:feed` is the `at://` URI of the feed.
|
|
56
|
+
|
|
57
|
+
The `:cursor` param, if included, should be a cursor returned earlier by your feed from one of the previous requests, so it should be in the format used by the same function – but anyone can call the endpoint with any params, so you should validate it. The cursor is used for pagination to provide more pages further down in the feed (the first request to load the top of the feed doesn't include a cursor).
|
|
51
58
|
|
|
52
59
|
The `:limit`, if included, should be a numeric value specifying the number of posts to return, and you should return at most that many posts in response. According to the spec, the maximum allowed value for the limit is 100, but again, you should verify this. The default limit is 50.
|
|
53
60
|
|
|
54
|
-
|
|
61
|
+
### Response
|
|
62
|
+
|
|
63
|
+
The `:posts` in the response hash that you return should be an array of URIs of posts. You only return the URI of a post to the Bluesky server, not all contents of the post like text and embed data – the server will "hydrate" the posts with all the other data from its own database.
|
|
64
|
+
|
|
65
|
+
The posts in the `get_posts` response from your feed object can be either:
|
|
66
|
+
|
|
67
|
+
- strings with the `at://` URI of a post
|
|
68
|
+
- hashes with the URI in the `:post` field and additional metadata
|
|
69
|
+
|
|
70
|
+
A combination of both is also allowed – some posts can be returned as URI strings, and some as hashes.
|
|
71
|
+
|
|
72
|
+
A response hash should also include a `:cursor`, which is some kind of string that encodes the offset in the feed, which will be passed back to you in a request for the next page. The structure of the cursor is something for you to decide, and it could possibly be a very long string (the actual length limit is uncertain). See the readme of the official [feed-generator repo](https://github.com/bluesky-social/feed-generator#pagination) for some guidelines on how to construct cursor strings. In practice, it's usually some combination of a timestamp in some form and/or an internal record ID, possibly with some separator like `:` or `-`.
|
|
73
|
+
|
|
74
|
+
The response can also include a `:req_id`, which is a "request ID" assigned to this specific request (again, the form of which is decided by you), which may be useful for processing [interactions](#handling-feed-interactions).
|
|
75
|
+
|
|
76
|
+
#### Post metadata:
|
|
55
77
|
|
|
56
|
-
|
|
78
|
+
If the post entry in `:posts` array is a hash, apart from the `:post` field with the URI it can include:
|
|
57
79
|
|
|
58
|
-
|
|
80
|
+
* `:context` – some kind of internal metadata about this specific post in this specific response, e.g. identifying how this post ended up in that response, used for processing [interactions](#handling-feed-interactions)
|
|
81
|
+
* `:reason` – information about why this post is being displayed, which can be shown to the user; currently supported reasons are:
|
|
82
|
+
- `{ :repost => repost_uri }` – the post is displayed because someone reposted it (the uri points to a `app.bsky.feed.repost` record)
|
|
83
|
+
- `{ :pin => true }` – the post is pinned at the top of the feed
|
|
59
84
|
|
|
60
|
-
|
|
85
|
+
So the complete structure of your reponse in full form may look something like this:
|
|
86
|
+
|
|
87
|
+
```rb
|
|
88
|
+
{
|
|
89
|
+
posts: [
|
|
90
|
+
{
|
|
91
|
+
post: "at://.../app.bsky.feed.post/...",
|
|
92
|
+
reason: { pin: true }
|
|
93
|
+
},
|
|
94
|
+
"at://.../app.bsky.feed.post/...",
|
|
95
|
+
"at://.../app.bsky.feed.post/...",
|
|
96
|
+
"at://.../app.bsky.feed.post/...",
|
|
97
|
+
{
|
|
98
|
+
post: "at://.../app.bsky.feed.post/...",
|
|
99
|
+
reason: { repost: "at://.../app.bsky.feed.repost/..." },
|
|
100
|
+
context: 'qweqweqwe'
|
|
101
|
+
},
|
|
102
|
+
"at://.../app.bsky.feed.post/...",
|
|
103
|
+
...
|
|
104
|
+
],
|
|
105
|
+
cursor: "1760639159",
|
|
106
|
+
req_id: "req2048"
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Error handling
|
|
111
|
+
|
|
112
|
+
If you determine that the request is somehow invalid (e.g. the cursor doesn't match what you expect), you can also raise a `BlueFactory::InvalidRequestError` error, which will return a JSON error message with status 400. The `message` of the exception might be shown to the user in an error banner.
|
|
113
|
+
|
|
114
|
+
### Example code
|
|
115
|
+
|
|
116
|
+
A simple example implementation could look like this:
|
|
61
117
|
|
|
62
118
|
```rb
|
|
63
119
|
require 'time'
|
|
64
120
|
|
|
65
121
|
class StarWarsFeed
|
|
66
|
-
def get_posts(params
|
|
122
|
+
def get_posts(params)
|
|
67
123
|
limit = check_query_limit(params)
|
|
68
124
|
query = Post.select('uri, time').order('time DESC').limit(limit)
|
|
69
125
|
|
|
@@ -90,7 +146,7 @@ class StarWarsFeed
|
|
|
90
146
|
end
|
|
91
147
|
```
|
|
92
148
|
|
|
93
|
-
|
|
149
|
+
## Running the server
|
|
94
150
|
|
|
95
151
|
The server itself is run using the `BlueFactory::Server` class, which is a subclass of `Sinatra::Base` and is used as described in the [Sinatra documentation](https://sinatrarb.com/intro.html) (as a "modular application").
|
|
96
152
|
|
|
@@ -128,24 +184,20 @@ server {
|
|
|
128
184
|
|
|
129
185
|
## Authentication
|
|
130
186
|
|
|
131
|
-
Feeds are authenticated using a technology called [JSON Web Tokens](https://jwt.io). If a user is logged in, when they open, refresh or scroll down a feed in their app, requests are made to the feed service from the Bluesky network's IP address with user's authentication token in the `Authorization` HTTP header. (This is not the same kind of token as the access token that you use to make API calls
|
|
187
|
+
Feeds are authenticated using a technology called [JSON Web Tokens](https://jwt.io). If a user is logged in, when they open, refresh or scroll down a feed in their app, requests are made to the feed service from the Bluesky network's IP address with user's authentication token in the `Authorization` HTTP header. (This is not the same kind of token as the access token that you use to make API calls – it does not let you perform any actions on user's behalf.)
|
|
132
188
|
|
|
133
|
-
At the moment, Blue Factory handles authentication in a very simplified way
|
|
189
|
+
At the moment, Blue Factory handles authentication in a very simplified way – it extracts the user's DID from the authentication header, but it does not verify the signature. This means that anyone with some programming knowledge can trivially prepare a fake token and make requests to the `getFeedSkeleton` endpoint as a different user.
|
|
134
190
|
|
|
135
191
|
As such, this authentication should not be used for anything critical. It may be used for things like logging, analytics, or as "security by obscurity" to just discourage others from accessing the feed in the app. You can also use this to build personalized feeds, as long as it's not a problem that the user DID may be fake.
|
|
136
192
|
|
|
137
|
-
To use this simple authentication,
|
|
138
|
-
|
|
139
|
-
```rb
|
|
140
|
-
BlueFactory.set :enable_unsafe_auth, true
|
|
141
|
-
```
|
|
193
|
+
To use this simple authentication, make a `get_posts` method that accepts two arguments: the second argument is a `context`, from which you can get user info via `context.user.raw_did`. `context.user.token` returns the whole Base64-encoded JWT token.
|
|
142
194
|
|
|
143
|
-
|
|
195
|
+
So this way you could, for example, return an empty list when the user is not authorized to use it:
|
|
144
196
|
|
|
145
197
|
```rb
|
|
146
198
|
class HiddenFeed
|
|
147
|
-
def get_posts(params,
|
|
148
|
-
if AUTHORIZED_USERS.include?(
|
|
199
|
+
def get_posts(params, context)
|
|
200
|
+
if AUTHORIZED_USERS.include?(context.user.raw_did)
|
|
149
201
|
# ...
|
|
150
202
|
else
|
|
151
203
|
{ posts: [] }
|
|
@@ -158,8 +210,8 @@ Alternatively, you can raise a `BlueFactory::AuthorizationError` with an optiona
|
|
|
158
210
|
|
|
159
211
|
```rb
|
|
160
212
|
class HiddenFeed
|
|
161
|
-
def get_posts(params,
|
|
162
|
-
if AUTHORIZED_USERS.include?(
|
|
213
|
+
def get_posts(params, context)
|
|
214
|
+
if AUTHORIZED_USERS.include?(context.user.raw_did)
|
|
163
215
|
# ...
|
|
164
216
|
else
|
|
165
217
|
raise BlueFactory::AuthorizationError, "You shall not pass!"
|
|
@@ -173,14 +225,14 @@ end
|
|
|
173
225
|
|
|
174
226
|
### Unauthenticated access
|
|
175
227
|
|
|
176
|
-
Please note that
|
|
228
|
+
Please note that there might not be any user information in the context – this will happen if the authentication header is not set at all. Since the [bsky.app](https://bsky.app) website can be accessed while logged out, people can also access your feeds this way. In that case, `context.user` will exist, but `context.user.token` and `context.user.raw_did` will be nil. You can also use the `context.has_auth?` method as a shortcut.
|
|
177
229
|
|
|
178
|
-
If you want the feed to only be available to logged in users (even if it's a non-personalized feed), simply raise an `AuthorizationError` if
|
|
230
|
+
If you want the feed to only be available to logged in users (even if it's a non-personalized feed), simply raise an `AuthorizationError` if there's no authentication info:
|
|
179
231
|
|
|
180
232
|
```rb
|
|
181
233
|
class RestrictedFeed
|
|
182
|
-
def get_posts(params,
|
|
183
|
-
if
|
|
234
|
+
def get_posts(params, context)
|
|
235
|
+
if !context.has_auth?
|
|
184
236
|
raise BlueFactory::AuthorizationError, "Log in to see this feed"
|
|
185
237
|
end
|
|
186
238
|
|
|
@@ -190,6 +242,51 @@ end
|
|
|
190
242
|
```
|
|
191
243
|
|
|
192
244
|
|
|
245
|
+
## Handling feed interactions
|
|
246
|
+
|
|
247
|
+
If that makes sense in your feed, you can opt in to receiving "feed interaction" events from the users who view it. Interactions are either explicit actions that the user takes – they can press the "Show more like this" or "Show less like this" buttons in the context menu on a post they see in your feed – or implicit events that get sent automatically.
|
|
248
|
+
|
|
249
|
+
To receive interactions, your feed needs to opt in to that (the "Show more/less" buttons are only displayed in your feed if you do). This is done by setting an `acceptsInteractions` field in the feed generator record – in BlueFactory, you need to add an `accepts_interactions` property or method to your feed object and return `true` from it (and re-publish the feed if it was already live).
|
|
250
|
+
|
|
251
|
+
The interactions are sent to your feed by making a `POST` request to the `app.bsky.feed.sendInteractions` endpoint. BlueFactory passes these to you using a handler which you configure this way:
|
|
252
|
+
|
|
253
|
+
```rb
|
|
254
|
+
BlueFactory.on_interactions do |interactions, context|
|
|
255
|
+
interactions.each do |i|
|
|
256
|
+
unless i.type == :seen
|
|
257
|
+
puts "[#{Time.now}] #{context.user.raw_did}: #{i.type} #{i.item}"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
or, alternatively:
|
|
264
|
+
|
|
265
|
+
```rb
|
|
266
|
+
BlueFactory.interactions_handler = proc { ... }
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
There is one shared handler for all the feeds you're hosting – to find out what a given interaction is about, you need to add the fields `:req_id` and/or `:context` to the feed response (see "[Feed API – Response](#response)").
|
|
270
|
+
|
|
271
|
+
An `Interaction` has such properties:
|
|
272
|
+
|
|
273
|
+
- `item` – at:// URI of a post the interaction is about
|
|
274
|
+
- `event` – name of the interaction type as specified in the lexicon, e.g. `app.bsky.feed.defs#requestLess`
|
|
275
|
+
- `context` – the context that was assigned in your response to this specific post
|
|
276
|
+
- `req_id` – the request ID that was assigned in your response to the request
|
|
277
|
+
- `type` – a short symbolic code of the interaction type
|
|
278
|
+
|
|
279
|
+
Currently enabled interaction types are:
|
|
280
|
+
|
|
281
|
+
- `:request_more` – user asked to see more posts like this
|
|
282
|
+
- `:request_less` – user asked to see fewer posts like this
|
|
283
|
+
- `:like` – user pressed like on the post
|
|
284
|
+
- `:repost` – user reposted the post
|
|
285
|
+
- `:reply` – user replied to the post
|
|
286
|
+
- `:quote` – user quoted the post
|
|
287
|
+
- `:seen` – user has seen the post (scrolled down to it)
|
|
288
|
+
|
|
289
|
+
|
|
193
290
|
## Additional configuration & customizing
|
|
194
291
|
|
|
195
292
|
You can use the [Sinatra API](https://sinatrarb.com/intro.html#configuration) to do any additional configuration, like changing the server port, enabling/disabling logging and so on.
|
|
@@ -204,7 +301,7 @@ You can also add additional routes, e.g. to make a redirect or print something o
|
|
|
204
301
|
|
|
205
302
|
```rb
|
|
206
303
|
BlueFactory::Server.get '/' do
|
|
207
|
-
redirect 'https://
|
|
304
|
+
redirect 'https://welcome.example.com'
|
|
208
305
|
end
|
|
209
306
|
```
|
|
210
307
|
|
|
@@ -221,17 +318,20 @@ You also need to load your `BlueFactory` configuration and your feed classes her
|
|
|
221
318
|
|
|
222
319
|
To publish the feed, you will need to provide some additional info about the feed, like its public name, through a few more methods in the feed object (the same one that responds to `#get_posts`):
|
|
223
320
|
|
|
224
|
-
- `display_name` (required)
|
|
225
|
-
- `description` (optional)
|
|
226
|
-
- `avatar_file` (optional)
|
|
227
|
-
- `content_mode` (optional)
|
|
321
|
+
- `display_name` (required) – the publicly visible name of your feed, e.g. "Cat Pics" (should be something short)
|
|
322
|
+
- `description` (optional) – a longer (~1-2 lines) description of what the feed does, displayed on the feed page as the "bio"
|
|
323
|
+
- `avatar_file` (optional) – path to an avatar image from the project's root (PNG or JPG)
|
|
324
|
+
- `content_mode` (optional) – return `:video` to create a video feed, which is displayed with a special layout
|
|
325
|
+
- `accepts_interactions` (optional) – return `true` to opt in to receiving [interactions](#handling-feed-interactions)
|
|
228
326
|
|
|
229
|
-
When you're ready, run the rake task passing the feed key (you will be asked for the uploader
|
|
327
|
+
When you're ready, run the rake task passing the feed key (you will be asked for the uploader's account password or app password):
|
|
230
328
|
|
|
231
329
|
```
|
|
232
330
|
bundle exec rake bluesky:publish KEY=wwdc
|
|
233
331
|
```
|
|
234
332
|
|
|
333
|
+
You also need to republish the feed by running the same task again any time you make changes to these properties and you want them to take effect.
|
|
334
|
+
|
|
235
335
|
|
|
236
336
|
## Credits
|
|
237
337
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
require_relative 'modules/configurable'
|
|
2
|
+
require_relative 'modules/interactions'
|
|
2
3
|
require_relative 'modules/feeds'
|
|
3
4
|
|
|
4
5
|
module BlueFactory
|
|
5
6
|
extend Configurable
|
|
6
7
|
extend Feeds
|
|
8
|
+
extend Interactions
|
|
7
9
|
|
|
8
10
|
def self.service_did
|
|
9
11
|
'did:web:' + hostname
|
|
@@ -15,5 +17,20 @@ module BlueFactory
|
|
|
15
17
|
|
|
16
18
|
configurable :publisher_did, :hostname, :validate_responses, :enable_unsafe_auth
|
|
17
19
|
|
|
18
|
-
set
|
|
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
|
|
19
36
|
end
|
data/lib/blue_factory/errors.rb
CHANGED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module BlueFactory
|
|
2
|
+
class Interaction
|
|
3
|
+
EVENTS = {
|
|
4
|
+
'app.bsky.feed.defs#interactionLike' => :like,
|
|
5
|
+
'app.bsky.feed.defs#interactionQuote' => :quote,
|
|
6
|
+
'app.bsky.feed.defs#interactionReply' => :reply,
|
|
7
|
+
'app.bsky.feed.defs#interactionRepost' => :repost,
|
|
8
|
+
'app.bsky.feed.defs#interactionSeen' => :seen,
|
|
9
|
+
'app.bsky.feed.defs#requestLess' => :request_less,
|
|
10
|
+
'app.bsky.feed.defs#requestMore' => :request_more
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
attr_reader :item, :event, :context, :req_id, :type
|
|
14
|
+
|
|
15
|
+
def initialize(data)
|
|
16
|
+
@item = data['item']
|
|
17
|
+
@event = data['event']
|
|
18
|
+
@context = data['feedContext']
|
|
19
|
+
@req_id = data['reqId']
|
|
20
|
+
@type = EVENTS[@event]
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require_relative 'errors'
|
|
2
|
+
|
|
3
|
+
module BlueFactory
|
|
4
|
+
class OutputGenerator
|
|
5
|
+
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
|
+
|
|
7
|
+
def generate(response)
|
|
8
|
+
output = {}
|
|
9
|
+
|
|
10
|
+
raise InvalidResponseError, ":posts key is missing" unless response.has_key?(:posts)
|
|
11
|
+
raise InvalidResponseError, ":posts should be an array" unless response[:posts].is_a?(Array)
|
|
12
|
+
|
|
13
|
+
output[:feed] = response[:posts].map { |x| process_post_element(x) }
|
|
14
|
+
|
|
15
|
+
if cursor = response[:cursor]
|
|
16
|
+
output[:cursor] = cursor.to_s
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
if req_id = response[:req_id]
|
|
20
|
+
output[:reqId] = req_id.to_s
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
output
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def process_post_element(object)
|
|
27
|
+
if object.is_a?(String)
|
|
28
|
+
validate_uri(object)
|
|
29
|
+
{ post: object }
|
|
30
|
+
elsif object.is_a?(Hash)
|
|
31
|
+
process_post_hash(object)
|
|
32
|
+
else
|
|
33
|
+
raise InvalidResponseError, "Invalid post entry, expected string or hash: #{object.inspect}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def process_post_hash(object)
|
|
38
|
+
post = {}
|
|
39
|
+
|
|
40
|
+
if object[:post]
|
|
41
|
+
validate_uri(object[:post])
|
|
42
|
+
post[:post] = object[:post]
|
|
43
|
+
else
|
|
44
|
+
raise InvalidResponseError, "Post hash is missing a :post key"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
if object[:reason]
|
|
48
|
+
post[:reason] = process_post_reason(object[:reason])
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if object[:context]
|
|
52
|
+
post[:feedContext] = object[:context].to_s
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
post
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def process_post_reason(reason)
|
|
59
|
+
raise InvalidResponseError, "Invalid post reason: #{reason.inspect}" unless reason.is_a?(Hash)
|
|
60
|
+
|
|
61
|
+
if reason[:repost]
|
|
62
|
+
{
|
|
63
|
+
"$type" => "app.bsky.feed.defs#skeletonReasonRepost",
|
|
64
|
+
"repost" => reason[:repost]
|
|
65
|
+
}
|
|
66
|
+
elsif reason[:pin]
|
|
67
|
+
{
|
|
68
|
+
"$type" => "app.bsky.feed.defs#skeletonReasonPin"
|
|
69
|
+
}
|
|
70
|
+
else
|
|
71
|
+
raise InvalidResponseError, "Invalid post reason: #{reason.inspect}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_uri(uri)
|
|
76
|
+
if !uri.is_a?(String)
|
|
77
|
+
raise InvalidResponseError, "Post URI should be a string: #{uri.inspect}"
|
|
78
|
+
elsif uri !~ AT_URI_REGEXP
|
|
79
|
+
raise InvalidResponseError, "Invalid post URI: #{uri.inspect}"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require_relative 'user_info'
|
|
2
|
+
|
|
3
|
+
module BlueFactory
|
|
4
|
+
class RequestContext
|
|
5
|
+
attr_accessor :request
|
|
6
|
+
|
|
7
|
+
def initialize(request)
|
|
8
|
+
@request = request
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def env
|
|
12
|
+
@request.env
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def user
|
|
16
|
+
UserInfo.new(env['HTTP_AUTHORIZATION'])
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def has_auth?
|
|
20
|
+
env['HTTP_AUTHORIZATION'] != nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/blue_factory/server.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
require 'base64'
|
|
2
1
|
require 'json'
|
|
3
2
|
require 'sinatra/base'
|
|
4
3
|
|
|
5
4
|
require_relative 'configuration'
|
|
6
5
|
require_relative 'errors'
|
|
6
|
+
require_relative 'interaction'
|
|
7
|
+
require_relative 'output_generator'
|
|
8
|
+
require_relative 'request_context'
|
|
7
9
|
|
|
8
10
|
module BlueFactory
|
|
9
11
|
class Server < Sinatra::Base
|
|
10
|
-
AT_URI_REGEXP = %r(^at://did:plc:[a-z0-9]+/app\.bsky\.feed\.post/[a-z0-9]+$)
|
|
11
|
-
|
|
12
12
|
configure do
|
|
13
13
|
disable :static
|
|
14
14
|
enable :quiet
|
|
@@ -37,83 +37,46 @@ module BlueFactory
|
|
|
37
37
|
[status, JSON.generate({ error: name, message: message })]
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
def get_feed
|
|
41
|
-
if
|
|
42
|
-
raise
|
|
40
|
+
def get_feed(feed_uri)
|
|
41
|
+
if feed_uri.to_s.empty?
|
|
42
|
+
raise InvalidRequestError, "Error: Params must have the property \"feed\""
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
-
if
|
|
46
|
-
raise
|
|
45
|
+
if feed_uri !~ %r(^at://[\w\-\.\:]+/[\w\.]+/[\w\.\-]+$)
|
|
46
|
+
raise InvalidRequestError, "Error: feed must be a valid at-uri"
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
feed_key =
|
|
49
|
+
feed_key = feed_uri.split('/').last
|
|
50
50
|
feed = config.get_feed(feed_key)
|
|
51
51
|
|
|
52
|
-
if feed.nil? || feed_uri(feed_key) !=
|
|
52
|
+
if feed.nil? || feed_uri(feed_key) != feed_uri
|
|
53
53
|
raise UnsupportedAlgorithmError, "Unsupported algorithm"
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
feed
|
|
57
57
|
end
|
|
58
|
-
|
|
59
|
-
def parse_did_from_token
|
|
60
|
-
auth = env['HTTP_AUTHORIZATION']
|
|
61
|
-
|
|
62
|
-
if auth.to_s.strip.empty?
|
|
63
|
-
return nil
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
if !auth.start_with?('Bearer ')
|
|
67
|
-
raise AuthorizationError, "Unsupported authorization method"
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
token = auth.gsub(/^Bearer /, '')
|
|
71
|
-
parts = token.split('.')
|
|
72
|
-
raise AuthorizationError.new("Invalid JWT format", "BadJwt") unless parts.length == 3
|
|
73
|
-
|
|
74
|
-
begin
|
|
75
|
-
payload = JSON.parse(Base64.decode64(parts[1]))
|
|
76
|
-
payload['iss']
|
|
77
|
-
rescue StandardError => e
|
|
78
|
-
raise AuthorizationError.new("Invalid JWT format", "BadJwt")
|
|
79
|
-
end
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def validate_response(response)
|
|
83
|
-
cursor = response[:cursor]
|
|
84
|
-
raise InvalidResponseError, ":cursor key is missing" unless response.has_key?(:cursor)
|
|
85
|
-
raise InvalidResponseError, ":cursor should be a string or nil" unless cursor.nil? || cursor.is_a?(String)
|
|
86
|
-
|
|
87
|
-
posts = response[:posts]
|
|
88
|
-
raise InvalidResponseError, ":posts key is missing" unless response.has_key?(:posts)
|
|
89
|
-
raise InvalidResponseError, ":posts should be an array of strings" unless posts.is_a?(Array)
|
|
90
|
-
raise InvalidResponseError, ":posts should be an array of strings" unless posts.all? { |x| x.is_a?(String) }
|
|
91
|
-
|
|
92
|
-
if bad_uri = posts.detect { |x| x !~ AT_URI_REGEXP }
|
|
93
|
-
raise InvalidResponseError, "Invalid post URI: #{bad_uri}"
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
58
|
end
|
|
97
59
|
|
|
98
60
|
get '/xrpc/app.bsky.feed.getFeedSkeleton' do
|
|
99
61
|
begin
|
|
100
|
-
feed = get_feed
|
|
62
|
+
feed = get_feed(params[:feed])
|
|
63
|
+
get_posts = feed.method(:get_posts)
|
|
101
64
|
args = params.slice(:feed, :cursor, :limit)
|
|
102
65
|
|
|
103
66
|
if config.enable_unsafe_auth
|
|
104
|
-
|
|
105
|
-
response = feed.get_posts(args,
|
|
106
|
-
|
|
67
|
+
context = RequestContext.new(request)
|
|
68
|
+
response = feed.get_posts(args, context.user.raw_did)
|
|
69
|
+
elsif get_posts.arity == 1
|
|
107
70
|
response = feed.get_posts(args)
|
|
71
|
+
elsif get_posts.arity == 2
|
|
72
|
+
context = RequestContext.new(request)
|
|
73
|
+
response = feed.get_posts(args, context)
|
|
74
|
+
else
|
|
75
|
+
raise InvalidFeedClassError, "get_posts method has invalid API (arity #{get_posts.arity})"
|
|
108
76
|
end
|
|
109
77
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
output = {}
|
|
113
|
-
output[:feed] = response[:posts].map { |s| { post: s }}
|
|
114
|
-
output[:cursor] = response[:cursor] if response[:cursor]
|
|
115
|
-
|
|
116
|
-
return json_response(output)
|
|
78
|
+
json = OutputGenerator.new.generate(response)
|
|
79
|
+
return json_response(json)
|
|
117
80
|
rescue InvalidRequestError => e
|
|
118
81
|
return json_error(e.error_type || "InvalidRequest", e.message)
|
|
119
82
|
rescue AuthorizationError => e
|
|
@@ -121,19 +84,24 @@ module BlueFactory
|
|
|
121
84
|
rescue UnsupportedAlgorithmError => e
|
|
122
85
|
return json_error("UnsupportedAlgorithm", e.message)
|
|
123
86
|
rescue InvalidResponseError => e
|
|
124
|
-
|
|
87
|
+
if settings.development?
|
|
88
|
+
return json_error("InvalidResponse", e.message, status: 500)
|
|
89
|
+
else
|
|
90
|
+
request.logger&.<< "#{e.class}: #{e.message}\n"
|
|
91
|
+
return json_error("InvalidResponse", "Feed response was invalid", status: 500)
|
|
92
|
+
end
|
|
125
93
|
end
|
|
126
94
|
end
|
|
127
95
|
|
|
128
96
|
get '/xrpc/app.bsky.feed.describeFeedGenerator' do
|
|
129
|
-
|
|
97
|
+
json_response({
|
|
130
98
|
did: config.service_did,
|
|
131
99
|
feeds: config.feed_keys.map { |f| { uri: feed_uri(f) }}
|
|
132
100
|
})
|
|
133
101
|
end
|
|
134
102
|
|
|
135
103
|
get '/.well-known/did.json' do
|
|
136
|
-
|
|
104
|
+
json_response({
|
|
137
105
|
'@context': ['https://www.w3.org/ns/did/v1'],
|
|
138
106
|
id: config.service_did,
|
|
139
107
|
service: [
|
|
@@ -145,5 +113,18 @@ module BlueFactory
|
|
|
145
113
|
]
|
|
146
114
|
})
|
|
147
115
|
end
|
|
116
|
+
|
|
117
|
+
post '/xrpc/app.bsky.feed.sendInteractions' do
|
|
118
|
+
if config.interactions_handler
|
|
119
|
+
json = JSON.parse(request.body.read)
|
|
120
|
+
interactions = json['interactions'].map { |x| Interaction.new(x) }
|
|
121
|
+
context = RequestContext.new(request)
|
|
122
|
+
|
|
123
|
+
config.interactions_handler.call(interactions, context)
|
|
124
|
+
status 200
|
|
125
|
+
else
|
|
126
|
+
json_error('MethodNotImplemented', 'Method Not Implemented', status: 501)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
148
129
|
end
|
|
149
130
|
end
|
|
@@ -110,6 +110,7 @@ namespace :bluesky do
|
|
|
110
110
|
|
|
111
111
|
record[:avatar] = avatar_ref if avatar_ref
|
|
112
112
|
record[:contentMode] = feed_content_mode if feed_content_mode
|
|
113
|
+
record[:acceptsInteractions] = true if feed.respond_to?(:accepts_interactions) && feed.accepts_interactions
|
|
113
114
|
|
|
114
115
|
json = BlueFactory::Net.post_request(pds_host, 'com.atproto.repo.putRecord', {
|
|
115
116
|
repo: publisher_did,
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
require 'base64'
|
|
2
|
+
require 'json'
|
|
3
|
+
|
|
4
|
+
require_relative 'errors'
|
|
5
|
+
|
|
6
|
+
module BlueFactory
|
|
7
|
+
class UserInfo
|
|
8
|
+
def initialize(auth_header)
|
|
9
|
+
@auth = auth_header
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def token
|
|
13
|
+
@token ||= begin
|
|
14
|
+
if @auth.nil? || @auth.strip.empty?
|
|
15
|
+
nil
|
|
16
|
+
elsif !@auth.start_with?('Bearer ')
|
|
17
|
+
raise AuthorizationError, "Unsupported authorization method"
|
|
18
|
+
else
|
|
19
|
+
@auth.gsub(/^Bearer /, '')
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def raw_did
|
|
25
|
+
return nil if token.nil?
|
|
26
|
+
|
|
27
|
+
parts = token.split('.')
|
|
28
|
+
raise AuthorizationError.new("Invalid JWT format", "BadJwt") unless parts.length == 3
|
|
29
|
+
|
|
30
|
+
begin
|
|
31
|
+
payload = JSON.parse(Base64.decode64(parts[1]))
|
|
32
|
+
payload['iss']
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
raise AuthorizationError.new("Invalid JWT format", "BadJwt")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/blue_factory/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Kuba Suder
|
|
@@ -40,12 +40,17 @@ files:
|
|
|
40
40
|
- lib/blue_factory.rb
|
|
41
41
|
- lib/blue_factory/configuration.rb
|
|
42
42
|
- lib/blue_factory/errors.rb
|
|
43
|
+
- lib/blue_factory/interaction.rb
|
|
43
44
|
- lib/blue_factory/modules/configurable.rb
|
|
44
45
|
- lib/blue_factory/modules/feeds.rb
|
|
46
|
+
- lib/blue_factory/modules/interactions.rb
|
|
47
|
+
- lib/blue_factory/output_generator.rb
|
|
45
48
|
- lib/blue_factory/rake.rb
|
|
49
|
+
- lib/blue_factory/request_context.rb
|
|
46
50
|
- lib/blue_factory/server.rb
|
|
47
51
|
- lib/blue_factory/tasks/publish.rake
|
|
48
52
|
- lib/blue_factory/tasks/support.rb
|
|
53
|
+
- lib/blue_factory/user_info.rb
|
|
49
54
|
- lib/blue_factory/version.rb
|
|
50
55
|
- sig/blue_factory.rbs
|
|
51
56
|
homepage: https://github.com/mackuba/blue_factory
|