{"id":17024059,"url":"https://github.com/mackuba/blue_factory","last_synced_at":"2025-04-12T11:18:47.503Z","repository":{"id":174823491,"uuid":"652724179","full_name":"mackuba/blue_factory","owner":"mackuba","description":"A simple Ruby server using Sinatra that serves Bluesky custom feeds","archived":false,"fork":false,"pushed_at":"2025-03-30T20:05:15.000Z","size":49,"stargazers_count":25,"open_issues_count":3,"forks_count":0,"subscribers_count":2,"default_branch":"master","last_synced_at":"2025-04-11T19:24:01.698Z","etag":null,"topics":["atproto","bluesky","bluesky-feed","ruby","sinatra"],"latest_commit_sha":null,"homepage":"https://blue.mackuba.eu","language":"Ruby","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"zlib","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/mackuba.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null}},"created_at":"2023-06-12T17:00:11.000Z","updated_at":"2025-04-05T22:18:49.000Z","dependencies_parsed_at":null,"dependency_job_id":"6142db2e-57be-46b4-886c-fc110c75ba1d","html_url":"https://github.com/mackuba/blue_factory","commit_stats":null,"previous_names":["mackuba/blue_factory"],"tags_count":5,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mackuba%2Fblue_factory","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mackuba%2Fblue_factory/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mackuba%2Fblue_factory/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/mackuba%2Fblue_factory/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/mackuba","download_url":"https://codeload.github.com/mackuba/blue_factory/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248465326,"owners_count":21108244,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["atproto","bluesky","bluesky-feed","ruby","sinatra"],"created_at":"2024-10-14T07:24:18.032Z","updated_at":"2025-04-12T11:18:47.496Z","avatar_url":"https://github.com/mackuba.png","language":"Ruby","readme":"# BlueFactory 🏭\n\nA Ruby gem for hosting custom feeds for Bluesky.\n\n\u003e [!NOTE]\n\u003e ATProto Ruby gems collection: [skyfall](https://github.com/mackuba/skyfall) | [blue_factory](https://github.com/mackuba/blue_factory) | [minisky](https://github.com/mackuba/minisky) | [didkit](https://github.com/mackuba/didkit)\n\n\n## What does it do\n\nBlueFactory 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.\n\nA 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://github.com/mackuba/skyfall).\n\n\n## Installation\n\n    gem install blue_factory\n\n\n## Usage\n\nThe server is configured through the `BlueFactory` module. The two required settings are:\n\n- `publisher_did` - DID identifier of the account that you will publish the feed on (the string that starts with `did:plc:...`)\n- `hostname` - the hostname on which the feed service will be run\n\nYou 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 - it must only contain characters that are valid in URLs (preferably all lowercase) and it can't be longer than 15 characters. The object is anything that implements the single required method `get_posts` (could be a class, a module or an instance).\n\nSo a simple setup could look like this:\n\n```rb\nrequire 'blue_factory'\n\nBlueFactory.set :publisher_did, 'did:plc:loremipsumqwerty'\nBlueFactory.set :hostname, 'feeds.example.com'\n\nBlueFactory.add_feed 'starwars', StarWarsFeed.new\n```\n\n\n### The feed object\n\nThe `get_posts` method of the feed object should:\n\n- accept a `params` argument which is a hash with fields: `:feed`, `:cursor` and `:limit` (the last two are optional)\n- optionally, accept a second `current_user` argument which is a string with the authenticated user's DID (depends on authentication config - [see below](#authentication))\n- return a hash with two fields: `:cursor` and `:posts`\n\nThe `:feed` is the `at://` URI of the feed. The `:cursor` param, if included, should be a cursor returned 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).\n\nThe `: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.\n\nThe `:cursor` that you return is some kind of string that encodes the offset in the feed for 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.\n\nAnd finally, the `:posts` value should be an array of posts, returned as `at://` URI strings only. The Bluesky server that makes the request for the feed will provide all the other data for the posts based on the URIs you return.\n\nIf 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. \n\nAn example implementation could look like this:\n\n```rb\nrequire 'time'\n\nclass StarWarsFeed\n  def get_posts(params, current_user = nil)\n    limit = check_query_limit(params)\n    query = Post.select('uri, time').order('time DESC').limit(limit)\n\n    if params[:cursor].to_s != \"\"\n      time = Time.at(params[:cursor].to_i)\n      query = query.where(\"time \u003c ?\", time)\n    end\n\n    posts = query.to_a\n    last = posts.last\n    cursor = last \u0026\u0026 last.time.to_i.to_s\n\n    { cursor: cursor, posts: posts.map(\u0026:uri) }\n  end\n\n  def check_query_limit(params)\n    if params[:limit]\n      limit = params[:limit].to_i\n      (limit \u003c 0) ? 0 : (limit \u003e MAX_LIMIT ? MAX_LIMIT : limit)\n    else\n      DEFAULT_LIMIT\n    end\n  end\nend\n```\n\n### Starting the server\n\nThe 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\").\n\nIn development, you can launch it using:\n\n```rb\nBlueFactory::Server.run!\n```\n\nIn production, you will probably want to create a `config.ru` file that instead runs it from the Rack interface:\n\n```rb\nrun BlueFactory::Server\n```\n\nThen, you would configure your preferred Ruby app server like Passenger, Unicorn or Puma to run the server using that config file and configure the main HTTP server (Nginx, Apache) to route requests on the given hostname to that app server.\n\nAs an example, an Nginx configuration for a site that runs the server via Passenger could look something like this:\n\n```\nserver {\n  server_name feeds.example.com;\n  listen 443 ssl;\n\n  passenger_enabled on;\n  root /var/www/feeds/current/public;\n\n  ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;\n  ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;\n\n  access_log /var/log/nginx/feeds-access.log combined buffer=16k flush=10s;\n  error_log /var/log/nginx/feeds-error.log;\n}\n```\n\n## Authentication\n\nFeeds 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.)\n\nAt 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.\n\nAs 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.\n\nTo use this simple authentication, set the `enable_unsafe_auth` option:\n\n```rb\nBlueFactory.set :enable_unsafe_auth, true\n```\n\nThe user's DID extracted from the token is passed as a second argument to `#get_posts`. You may, for example, return an empty list when the user is not authorized to use it:\n\n```rb\nclass HiddenFeed\n  def get_posts(params, current_user)\n    if AUTHORIZED_USERS.include?(current_user)\n      # ...\n    else\n      { posts: [] }\n    end\n  end\nend\n```\n\nAlternatively, you can raise a `BlueFactory::AuthorizationError` with an optional custom message. This will return a 401 status response to the Bluesky app, which will make it display the pink error banner in the app:\n\n```rb\nclass HiddenFeed\n  def get_posts(params, current_user)\n    if AUTHORIZED_USERS.include?(current_user)\n      # ...\n    else\n      raise BlueFactory::AuthorizationError, \"You shall not pass!\"\n    end\n  end\nend\n```\n\n\u003cp\u003e\u003cimg width=\"400\" src=\"https://github.com/mackuba/blue_factory/assets/28465/9197c0ec-9302-4ca0-b06c-3fce2e0fa4f4\"\u003e\u003c/p\u003e\n\n\n### Unauthenticated access\n\nPlease note that the `current_user` may be nil - this will happen if the authentication header is not set at all. Since the [bsky.app](https://bsky.app) website is now open to the public and can be accessed without authentication, people can also access your feeds without being logged in.\n\nIf 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 `current_user` is nil:\n\n```rb\nclass RestrictedFeed\n  def get_posts(params, current_user)\n    if current_user.nil?\n      raise BlueFactory::AuthorizationError, \"Log in to see this feed\"\n    end\n\n    # ...\n  end\nend\n```\n\n\n## Additional configuration \u0026 customizing\n\nYou 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.\n\nFor example, you can change the port used in development with:\n\n```rb\nBlueFactory::Server.set :port, 7777\n```\n\nYou can also add additional routes, e.g. to make a redirect or print something on the root URL:\n\n```rb\nBlueFactory::Server.get '/' do\n  redirect 'https://github.com/mackuba/blue_factory'\nend\n```\n\n\n## Publishing the feed\n\nWhen your feed server is ready and deployed to the production server, you can use the included `bluesky:publish` Rake task to upload the feed configuration to the Bluesky network. To do that, add this line to your `Rakefile`:\n\n```rb\nrequire 'blue_factory/rake'\n```\n\nYou also need to load your `BlueFactory` configuration and your feed classes here, so it's recommended that you extract this configuration code to some kind of init file that can be included in the `Rakefile`, `config.ru` and elsewhere if needed.\n\nTo 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`):\n\n- `display_name` (required) - the publicly visible name of your feed, e.g. \"WWDC 23\" (should be something short)\n- `description` (optional) - a longer (~1-2 lines) description of what the feed does, displayed on the feed page as the \"bio\"\n- `avatar_file` (optional) - path to an avatar image from the project's root (PNG or JPG)\n- `content_mode` (optional) - return `:video` to create a video feed\n\nWhen you're ready, run the rake task passing the feed key (you will be asked for the uploader account's password):\n\n```\nbundle exec rake bluesky:publish KEY=wwdc\n```\n\nFor non-Bluesky PDSes, you need to also add an env var `SERVER_URL=https://your.pds.host`.\n\n\n## Credits\n\nCopyright © 2025 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/mackuba.eu)).\n\nThe code is available under the terms of the [zlib license](https://choosealicense.com/licenses/zlib/) (permissive, similar to MIT).\n\nBug reports and pull requests are welcome 😎\n","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmackuba%2Fblue_factory","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fmackuba%2Fblue_factory","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fmackuba%2Fblue_factory/lists"}