Link unfurling without SignalID
It's easiest to think of this PR as having two parts:
- Generic link unfurling
- A Basecamp integration via OAuth
Generic Link Unfurling
The link unfurling logic takes inspiration from @flavorjones prior attempt in https://github.com/basecamp/fizzy/pull/743 and a bit from Campfire, bit it also extends the logic a bit to support different kinds of unfurling, which I'll get to later.
This is how the unfurling process goes:
- You paste a link into the text editor
- The editor emits an event that a link was iserted
- A Stimulus controller responds to that event, takes the URL of the link and makes a POST request to the server with it to /unfurl_link
- The server then initializes a Link object and tells it to unfurl itself
- The Link object then goes through a list of Unfurler objects asking each one if it can unfurl the given URL
- When it finds an unfurler that can unfurl the URL it calls it to unfurl the link
- The unfurler returns a Metadata object that can contain a title, description image and canonical URL
- That metadata object is used as the response to the POST request
- The Stimulus controller takes the response, generates new HTML for the link, and tells the editor to re-render the link
For this to work I had to extend Lexxy to emit an event when someone pastes a link - https://github.com/basecamp/lexxy/pull/281#event-20008987679
After that, the application logic was fairly straight forward.
One downside of this unfurling method is that it's synchronous: A malicious person could create multiple accounts and intentionally paste links to a URL that is very slow to resolve. This would exhaust our thread pool and result in denial of service.
A better way to do this would be to unfurl the link in a job and relay the result via ActionCable to the frontend. I decided not to go down this route, for now, because all our apps do synchronous unfurling and so far it didn't seem to be a problem. As a, albeit weak, protection measure I adopted the rate limit from Basecamp and RichLink.
The unfurler objects allow us to plug in unfurling for different 3rd party apps - like Sentry & HackerOne - but for now they are only used to unfurl links from Basecam and Fizzy itself.
Basecamp integration via OAuth
Since the goal of this PR is to avoid using Signal ID I had to use regular OAuth to access Basecamp. For Fizzy to be able to read someone's Basecamp data the person has to to link their Fizzy and Basecamp accounts first. The linking process is a regular OAuth2 token exchange.
To make the exchange I needed an untennated route - the callback. It can't be tenanted because OAuth return URLs can't have wildcards in them. Another twist here is that I couldn't pass the tenant ID as a param to the URL because Signal ID / Launchpad does strict comparison of the return URL (think == instead of uri.host == redirect.host && uri.path == redirect.path) which isn't uncommon in OAuth. To work around that I used the OAuth state, a mechanism for preventing token hijacking, to pass the exact integration I'm setting up and the tenant around. The state gets echoed back from the OAuth server with the callback. I used a signed global ID of the integration as the state. Thanks to AR::Tenanted the SGID contains the tenant ID in it and I can just ask for it, in addition ot that I can set a short expiry to prevent hijacking, and I can ignore tokens for integrations that have already been setup before.
Once the integration is setup and Fizzy has a Basecamp access token to use it can just make a regular HTTP request to the Basecamp URL with that access token and Basecamp will respond with HTML. This works because Basecamp has a special case in its auth logic which treats OAuth access tokens of trusted OAuth apps (which Fizzy is) as if they were cookies.
To make this request, Basecamp has its own link unfurler object that adds the user's access token to the request. It also handles the failure case if the user doesn't have a Basecamp integration setup but wants to unfurl a Basecamp link. And it handles token refreshes in a lazy manner.
If everything is setup, the unfurler returns a Metadata object. If the integration isn't setup it raises an error that the controller catches and turns into a JSON response to the Stimulus controller. The Stimulus controller, when it encounters the error, renders a prompt for the user to link their account.
The linking is done in a popup so that we don't interrupt whatever the user was doing if they decide to link their accounts. I took care that the popup is triggered by a user interaction so that it gets shown even on browsers with aggressive popup protections, and I styled it as much as I could such that it looks like a "native" element instead of a browser window.
I also extended the rich textarea tag to always include the link unfurling controller and elements, unless specifically instructed not to do that. I feel like that's something we'd want in all rich text areas, and didn't want to copy paste the same incantation code and HTML elements around. That said, the override feels like a heavy handed solution but I couldn't think of a better one. After looking around I found a very similar solution in Basecamp.
How to style this
The prompt is rendered using a helper in rich_text_helper.rb - if it gets more involved I can move it to a partial, just let me know. The positioning of the promt is controlled via CSS in app/assets/stylesheets/lexxy.css:43 The first screen that is rendered in the popup is in app/views/integrations/basecamps/new.html.erb The "done" screen is in app/views/integrations/basecamps/callbacks/show.html.erb
To get the integration to work locally
Run the following in launchpad:
SignalId::Oauth::Client.create!(
owner_id: 1071630348,
organization: "Basecamp",
name: "Fizzy",
description: "Fizzy",
about_url: "http://fizzy.localhost:3006",
avatar_key: nil,
client_id: "9d2ff56438a8513c20044752d1f3616e076e0a57",
client_secret: "66366ae78baaeffab72cb3c1ba5bcc53f50cb407",
redirect_uri: "http://fizzy.localhost:3006/integrations/basecamp/callback",
active: true,
trusted: true,
scope: ["bc3"]
)
Reviewer's note
I'm opening this as a drive sinc it's gotten quite big. But there are a few things I still want to adress:
- [x] In the callback move the setup step to a job
- [x] Limit the number of basecamp integrations to 1 per user
- [x] Configure a Fizzy OAuth2 app in Launchpad during bin/setup
@monorkin this is good to go from my end. Here's the flow: