Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,7 @@ VAPID_PRIVATE_KEY=

# Vimeo access token required for Library downloads. Required scopes: "private video_files public"
VIMEO_ACCESS_TOKEN=

# X Oauth 2.0 Token
X_CLIENT_ID=
X_CLIENT_SECRET=
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,7 @@ gem "inertia_rails", "~> 3.11"
gem "vite_rails", "~> 3.0"

gem "ruby-openai"

gem "omniauth"
gem "omniauth-twitter2"
gem "omniauth-rails_csrf_protection"
Comment on lines +83 to +85
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These three gems are recommended on X documentation page here.

omniauth-twitter2 is dependent on omniauth.
omniauth-rails_csrf_protection adds CSRF protection to omniauth.

38 changes: 35 additions & 3 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ GEM
globalid (1.2.1)
activesupport (>= 6.1)
hashdiff (1.1.0)
hashie (5.1.0)
logger
heapy (0.2.0)
thor
httparty (0.23.1)
Expand Down Expand Up @@ -230,7 +232,6 @@ GEM
matrix (0.4.2)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (5.25.4)
mocha (2.1.0)
ruby2_keywords (>= 0.0.5)
Expand Down Expand Up @@ -264,6 +265,28 @@ GEM
racc (~> 1.4)
nokogiri (1.18.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth2 (2.0.18)
faraday (>= 0.17.3, < 4.0)
jwt (>= 1.0, < 4.0)
logger (~> 1.2)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.3)
version_gem (~> 1.1, >= 1.1.9)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-oauth2 (1.9.0)
oauth2 (>= 2.0.2, < 3)
omniauth (~> 2.0)
omniauth-rails_csrf_protection (2.0.1)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth-twitter2 (1.0.0)
omniauth
omniauth-oauth2 (~> 1.0)
openssl (3.2.0)
parallel (1.24.0)
parser (3.3.0.3)
Expand Down Expand Up @@ -393,8 +416,13 @@ GEM
rack (~> 2.2, >= 2.2.4)
rack-protection (= 3.2.0)
tilt (~> 2.0)
sqlite3 (1.7.3)
mini_portile2 (~> 2.8.0)
snaky_hash (2.0.3)
hashie (>= 0.1.0, < 6)
version_gem (>= 1.1.8, < 3)
sqlite3 (1.7.3-aarch64-linux)
sqlite3 (1.7.3-arm64-darwin)
sqlite3 (1.7.3-x86_64-darwin)
sqlite3 (1.7.3-x86_64-linux)
stackprof (0.2.27)
stimulus-rails (1.3.3)
railties (>= 6.0.0)
Expand All @@ -412,6 +440,7 @@ GEM
unicode-display_width (2.5.0)
uri (1.0.3)
useragent (0.16.11)
version_gem (1.1.9)
vite_rails (3.0.19)
railties (>= 5.1, < 9)
vite_ruby (~> 3.0, >= 3.2.2)
Expand Down Expand Up @@ -462,6 +491,9 @@ DEPENDENCIES
mailkick
mocha
net-http-persistent
omniauth
omniauth-rails_csrf_protection
omniauth-twitter2
platform_agent
propshaft!
puma (~> 6.4)
Expand Down
101 changes: 101 additions & 0 deletions app/controllers/users/omniauth_callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
class Users::OmniauthCallbacksController < ApplicationController
include Authentication

skip_before_action :verify_authenticity_token, only: [ :twitter, :failure ]
skip_before_action :require_authentication, only: [ :twitter, :failure, :initiate_login ]
before_action :restore_authentication, only: [ :twitter ]
Comment on lines +4 to +6
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line prevents redirect during OAuth callback while still restoring session


def twitter
auth_hash = request.env["omniauth.auth"]
login_only = session.delete(:oauth_login_only)
Comment on lines +9 to +10
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line flags login vs connect flow.


if auth_hash.blank?
redirect_to new_session_path, alert: "Authentication failed. Please try again."
return
end

if login_only
handle_login(auth_hash)
else
handle_connect(auth_hash)
end
end

private

def handle_login(auth_hash)
user = User.find_by(twitter_uid: auth_hash["uid"])

if user.present?
start_new_session_for(user)
redirect_to root_path, notice: "Signed in with X successfully!"
else
redirect_to new_session_path, alert: "No account found with this X account. Please sign in with email first and connect your X account."
end
end
Comment on lines +26 to +35
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handle_login finds user by twitter_uid and signs them in. The twitter_uid is unique at db level, meaning that if two users cannot connect the same X account.


def handle_connect(auth_hash)
unless Current.user
redirect_to new_session_path, alert: "Please sign in first to connect your X account."
return
end

begin
Current.user.update!(
twitter_uid: auth_hash["uid"],
twitter_oauth_token: auth_hash.dig("credentials", "token"),
twitter_oauth_refresh_token: auth_hash.dig("credentials", "refresh_token"),
twitter_screen_name: auth_hash.dig("info", "nickname"),
twitter_username: auth_hash.dig("info", "nickname"),
twitter_profile_image: auth_hash.dig("info", "image"),
twitter_connected_at: Time.current,
twitter_url: "https://x.com/#{auth_hash.dig('info', 'nickname')}"
)

redirect_to user_profile_path("me"), notice: "Successfully connected your X account!"
rescue => e
Rails.logger.error "X OAuth Error: #{e.message}"
Rails.logger.error e.backtrace.join("\n")
redirect_to user_profile_path("me"), alert: "Failed to connect X account. Please try again."
end
end
Comment on lines +37 to +61
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updates current user with OAuth data (requires authentication).


public

def initiate_login
session[:oauth_login_only] = true
render inline: <<-HTML, layout: false
<!DOCTYPE html>
<html>
<body>
<form id="oauth-form" action="/auth/twitter2" method="post">
<input type="hidden" name="authenticity_token" value="#{form_authenticity_token}">
</form>
<script>document.getElementById('oauth-form').submit();</script>
</body>
</html>
HTML
end
Comment on lines +65 to +78
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Auto-submits POST form to start OAuth (required by CSRF protection)


def failure
error_message = request.params[:message] || "Authentication failed"
Rails.logger.error "OmniAuth Failure: #{error_message}"
redirect_to user_profile_path("me"), alert: "X authentication failed: #{error_message}"
end

def disconnect
Current.user.update!(
twitter_uid: nil,
twitter_oauth_token: nil,
twitter_oauth_refresh_token: nil,
twitter_screen_name: nil,
twitter_username: nil,
twitter_profile_image: nil,
twitter_connected_at: nil,
twitter_url: nil
)

redirect_to user_profile_path("me"), notice: "X account disconnected successfully!"
end

Comment on lines +86 to +100
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clears all db fields and disconnects the user's X account.

end
5 changes: 5 additions & 0 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ def mentioning_messages

validates_presence_of :email_address, if: :person?
normalizes :email_address, with: ->(email_address) { email_address.downcase }
validates :twitter_uid, uniqueness: true, allow_nil: true
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prevents one X account from being linked to multiple users.


scope :without_default_names, -> { where.not(name: DEFAULT_NAME) }
scope :non_suspended, -> { where(suspended_at: nil) }
Expand Down Expand Up @@ -215,6 +216,10 @@ def unblock!(other_user)
blocks_given.where(blocked: other_user).destroy_all
end

def twitter_connected?
twitter_uid.present? && twitter_connected_at.present?
end

Comment on lines +219 to +222
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Helper to check if account is linked. This is only used in the test to make sure that the X account is linked properly.

private
def self.find_and_initialize_unclaimed_gumroad_import(attributes)
unclaimed_gumroad_import = User.active.non_suspended.unclaimed_gumroad_imports.find_by(email_address: attributes[:email_address])
Expand Down
16 changes: 16 additions & 0 deletions app/views/sessions/new.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,22 @@
<% end %>
</fieldset>
<% end %>

<div class="flex align-center gap center-block upad" style="margin-top: 0.5rem;">
<hr class="flex-item-grow borderless" style="border-top: 1px solid var(--color-border);">
<span class="txt-small txt-deemphasized">or</span>
<hr class="flex-item-grow borderless" style="border-top: 1px solid var(--color-border);">
</div>

<div class="flex align-center gap center-block upad" style="margin-top: 0; justify-content: center;">
<form id="x-login-form" action="/auth/twitter2/login" method="post" data-turbo="false" style="width: 100%;">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
<button type="submit" class="flex align-center gap input input--actor txt-large" style="width: 100%; cursor: pointer; justify-content: center;">
<span>Sign in with</span>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 20, class: "colorize--black" %>
</button>
</form>
</div>
Comment on lines +41 to +55
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added "Login with X" button that links to initiate_login endpoint.
Styled the button to match email input field.
Only works for users who already connected their X account previously.

</div>
</div>

Expand Down
22 changes: 18 additions & 4 deletions app/views/users/profiles/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,18 @@
<div class="flex align-center gap">
<%= translation_button(:twitter) %>

<label class="flex align-center gap flex-item-grow input input--actor">
<%= form.text_field :twitter_url, class: "input txt-medium ", placeholder: "Your X profile", required: false %>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %>
</label>
<% if @user.twitter_connected_at.present? %>
<label class="flex align-center gap flex-item-grow input input--actor">
<%= link_to "@#{@user.twitter_screen_name}", @user.twitter_url, target: "_blank", class: "input txt-medium", style: "text-decoration: none; color: inherit;" %>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %>
</label>
<%= link_to "Disconnect", "/auth/twitter/disconnect", data: { turbo_method: :delete, turbo_confirm: "Disconnect your X account?" }, class: "btn txt-small" %>
<% else %>
<a href="#" onclick="document.getElementById('x-oauth-form').submit(); return false;" class="flex align-center gap flex-item-grow input input--actor" style="cursor: pointer;">
<span class="input txt-medium txt-deemphasized">Connect your X account</span>
<%= image_tag "social/twitter-outline.svg", role: "presentation", size: 24, class: "colorize--black" %>
</a>
<% end %>
Comment on lines +82 to +93
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After account is linked, it will show users a clickable button with their X account username that redirects them to X for authorization. After account linking, the disconnect button appears next to the X account.

</div>

<div class="flex align-center gap">
Expand Down Expand Up @@ -163,4 +171,10 @@
<%= link_to "Push Notifications Dev Mode", user_push_subscriptions_url, class: "btn txt-small center" %>
</fieldset>
<% end %>

<% unless @user.twitter_connected_at.present? %>
<form id="x-oauth-form" action="/auth/twitter2" method="post" style="display: none;">
<input type="hidden" name="authenticity_token" value="<%= form_authenticity_token %>">
</form>
<% end %>
Comment on lines +174 to +179
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the form where it initiates the X account linking by calling the auth/twitter2 endpoint.

</section>
6 changes: 6 additions & 0 deletions config/initializers/omniauth.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
Rails.application.config.middleware.use OmniAuth::Builder do
provider :twitter2,
ENV["X_CLIENT_ID"],
ENV["X_CLIENT_SECRET"],
scope: "tweet.read users.read offline.access"
end
Comment on lines +1 to +6
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configures OmniAuth middleware with X client ID/secret and required scopes.

5 changes: 5 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@
resource :email_subscription, only: %i[ show update ]
end

get "/auth/:provider/callback", to: "users/omniauth_callbacks#twitter"
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handles Oauth 2.0 callback from X/Twitter. 'omniauth' will automatically create this endpoint: /auth/twitter2/callback

Important Note:

Inside X developer dashboard, you have to add this callback endpoint for it to work.
image

get "/auth/failure", to: "users/omniauth_callbacks#failure"
delete "/auth/twitter/disconnect", to: "users/omniauth_callbacks#disconnect"
Comment on lines +75 to +76
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These two endpoints handle auth failures and account disconnection.

post "/auth/twitter2/login", to: "users/omniauth_callbacks#initiate_login"

Comment on lines +77 to +78
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint is used for auth login using Twitter Oauth 2.0.

resources :users, only: :show do
scope module: "users" do
resource :avatar, only: %i[ show destroy ]
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20260112044358_add_twitter_o_auth_to_users.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class AddTwitterOAuthToUsers < ActiveRecord::Migration[7.2]
def change
add_column :users, :twitter_uid, :string
add_column :users, :twitter_oauth_token, :text
add_column :users, :twitter_oauth_refresh_token, :text
add_column :users, :twitter_screen_name, :string
add_column :users, :twitter_profile_image, :string
add_column :users, :twitter_connected_at, :datetime

add_index :users, :twitter_uid, unique: true
end
end
Comment on lines +1 to +12
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds OAuth db fields to User table. This will be used for refreshing the session and logging in during X OAuth flow.

5 changes: 4 additions & 1 deletion db/structure.sql
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ FOREIGN KEY ("creator_id")
CREATE INDEX "index_searches_on_user_id" ON "searches" ("user_id");
CREATE INDEX "index_searches_on_creator_id" ON "searches" ("creator_id");
CREATE TABLE IF NOT EXISTS "webhook_events" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "source" varchar, "event_type" varchar, "payload" text, "processed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "role" integer DEFAULT 0 NOT NULL, "email_address" varchar DEFAULT NULL, "password_digest" varchar DEFAULT NULL, "active" boolean DEFAULT 1, "bio" text DEFAULT NULL, "bot_token" varchar DEFAULT NULL, "avatar_url" varchar DEFAULT NULL, "twitter_username" varchar DEFAULT NULL, "linkedin_username" varchar DEFAULT NULL, "personal_url" varchar DEFAULT NULL, "membership_started_at" datetime(6) DEFAULT NULL, "ascii_name" varchar DEFAULT NULL, "twitter_url" varchar DEFAULT NULL, "linkedin_url" varchar DEFAULT NULL, "order_id" bigint DEFAULT NULL, "suspended_at" datetime(6) DEFAULT NULL, "preferences" text DEFAULT '{}', "last_authenticated_at" datetime(6));
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "role" integer DEFAULT 0 NOT NULL, "email_address" varchar DEFAULT NULL, "password_digest" varchar DEFAULT NULL, "active" boolean DEFAULT 1, "bio" text DEFAULT NULL, "bot_token" varchar DEFAULT NULL, "avatar_url" varchar DEFAULT NULL, "twitter_username" varchar DEFAULT NULL, "linkedin_username" varchar DEFAULT NULL, "personal_url" varchar DEFAULT NULL, "membership_started_at" datetime(6) DEFAULT NULL, "ascii_name" varchar DEFAULT NULL, "twitter_url" varchar DEFAULT NULL, "linkedin_url" varchar DEFAULT NULL, "order_id" bigint DEFAULT NULL, "suspended_at" datetime(6) DEFAULT NULL, "preferences" text DEFAULT '{}', "last_authenticated_at" datetime(6), "provider" varchar, "uid" varchar, "twitter_uid" varchar, "twitter_oauth_token" text, "twitter_oauth_refresh_token" text, "twitter_screen_name" varchar, "twitter_profile_image" varchar, "twitter_connected_at" datetime(6));
CREATE UNIQUE INDEX "index_users_on_bot_token" ON "users" ("bot_token");
CREATE UNIQUE INDEX "index_users_on_email_address" ON "users" ("email_address");
CREATE UNIQUE INDEX "index_users_on_order_id" ON "users" ("order_id") WHERE order_id IS NOT NULL;
Expand Down Expand Up @@ -184,7 +184,10 @@ CREATE INDEX "index_messages_on_answered_at" ON "messages" ("answered_at");
CREATE INDEX "index_messages_on_answered_by_id" ON "messages" ("answered_by_id");
CREATE INDEX "index_messages_on_room_id_and_mentions_everyone" ON "messages" ("room_id", "mentions_everyone") WHERE mentions_everyone = true;
CREATE INDEX "index_messages_on_original_message_id" ON "messages" ("original_message_id");
CREATE UNIQUE INDEX "index_users_on_twitter_uid" ON "users" ("twitter_uid");
INSERT INTO "schema_migrations" (version) VALUES
('20260112044358'),
('20260112031751'),
('20251128154156'),
('20251106202943'),
('20251103002249'),
Expand Down
Loading