diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 465658e..d77b8f7 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,9 +1,38 @@ class ApplicationController < ActionController::Base - before_action :authenticate_user! + skip_forgery_protection if: -> { request.format.json? } + + before_action :authenticate_request! def execute_read_task system('rake read') flash[:notice] = "Read task executed!" redirect_back_or_to root_path end + + private + + def authenticate_request! + if request.format.json? + authenticate_with_personal_access_token! + else + authenticate_user! + end + end + + def authenticate_with_personal_access_token! + raw_token = bearer_token + pat = PersonalAccessToken.authenticate(raw_token) + + if pat + pat.update_column(:last_used_at, Time.current) + sign_in pat.user, store: false + else + render json: { error: "Unauthorized" }, status: :unauthorized + end + end + + def bearer_token + header = request.headers["Authorization"].to_s + header.start_with?("Bearer ") ? header.split(" ", 2).last : nil + end end diff --git a/app/controllers/links_controller.rb b/app/controllers/links_controller.rb index 960b3d5..6170e11 100644 --- a/app/controllers/links_controller.rb +++ b/app/controllers/links_controller.rb @@ -9,11 +9,25 @@ def index Link end @links = scope.order("published_at DESC NULLS LAST") + + respond_to do |format| + format.html + format.json { render json: @links.map { |l| link_json(l) } } + end end # GET /links/1 def show @grouped_social_media_snippets = @link.social_media_snippets.group_by(&:social_media_type) + + respond_to do |format| + format.html + format.json do + render json: link_json(@link).merge( + social_media_snippets: @link.social_media_snippets.map { |s| snippet_json(s) } + ) + end + end end private @@ -26,4 +40,26 @@ def set_link def link_params params.require(:link).permit(:url, :title, :description) end + + def link_json(link) + { + id: link.id, + url: link.url, + title: link.title, + description: link.description, + open_graph_description: link.open_graph_description, + published_at: link.published_at, + created_at: link.created_at, + updated_at: link.updated_at + } + end + + def snippet_json(snippet) + { + id: snippet.id, + social_media_type: snippet.social_media_type, + content: snippet.content, + created_at: snippet.created_at + } + end end diff --git a/app/controllers/personal_access_tokens_controller.rb b/app/controllers/personal_access_tokens_controller.rb new file mode 100644 index 0000000..4c6263e --- /dev/null +++ b/app/controllers/personal_access_tokens_controller.rb @@ -0,0 +1,32 @@ +class PersonalAccessTokensController < ApplicationController + def index + @personal_access_tokens = current_user.personal_access_tokens.order(created_at: :desc) + @new_token = PersonalAccessToken.new + end + + def create + @new_token = current_user.personal_access_tokens.build(token_params) + + if @new_token.save + @personal_access_tokens = current_user.personal_access_tokens.order(created_at: :desc) + @raw_token = @new_token.token + flash.now[:notice] = "Token created. Copy it now — it won't be shown again." + render :index, status: :created + else + @personal_access_tokens = current_user.personal_access_tokens.order(created_at: :desc) + render :index, status: :unprocessable_entity + end + end + + def destroy + token = current_user.personal_access_tokens.find(params[:id]) + token.destroy + redirect_to personal_access_tokens_path, notice: "Token revoked." + end + + private + + def token_params + params.require(:personal_access_token).permit(:name) + end +end diff --git a/app/controllers/shares_controller.rb b/app/controllers/shares_controller.rb index af87006..0eb5e6a 100644 --- a/app/controllers/shares_controller.rb +++ b/app/controllers/shares_controller.rb @@ -5,6 +5,11 @@ class SharesController < ApplicationController # GET /shares def index @shares = @link.shares + + respond_to do |format| + format.html + format.json { render json: @shares.map { |s| share_json(s) } } + end end # GET /shares/1 @@ -49,4 +54,21 @@ def set_share def share_params params.require(:share).permit(:link_id, :shortened_url, :utm_source, :utm_medium, :utm_campaign, :utm_term, :utm_content, :utm_id, :shared_link_name) end + + def share_json(share) + { + id: share.id, + link_id: share.link_id, + shortened_url: share.shortened_url, + utm_source: share.utm_source, + utm_medium: share.utm_medium, + utm_campaign: share.utm_campaign, + utm_term: share.utm_term, + utm_content: share.utm_content, + utm_id: share.utm_id, + shared_link_name: share.shared_link_name, + created_at: share.created_at, + updated_at: share.updated_at + } + end end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb new file mode 100644 index 0000000..7c5b0a9 --- /dev/null +++ b/app/models/personal_access_token.rb @@ -0,0 +1,30 @@ +require "digest" +require "securerandom" + +class PersonalAccessToken < ApplicationRecord + belongs_to :user + + validates :name, presence: true + validates :token_digest, presence: true, uniqueness: true + + attr_reader :token + + before_validation :assign_token, on: :create + + def self.authenticate(raw_token) + return nil if raw_token.blank? + find_by(token_digest: digest_for(raw_token)) + end + + def self.digest_for(raw_token) + Digest::SHA256.hexdigest(raw_token) + end + + private + + def assign_token + return if token_digest.present? + @token = SecureRandom.hex(32) + self.token_digest = self.class.digest_for(@token) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index fee3e9d..463d463 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,3 +1,5 @@ class User < ApplicationRecord include OmbuLabsAuthenticable + + has_many :personal_access_tokens, dependent: :destroy end \ No newline at end of file diff --git a/app/views/personal_access_tokens/index.html.erb b/app/views/personal_access_tokens/index.html.erb new file mode 100644 index 0000000..ba185d1 --- /dev/null +++ b/app/views/personal_access_tokens/index.html.erb @@ -0,0 +1,59 @@ +

<%= notice %>

+ +<% if flash[:raw_token].present? %> +
+

Your new token (copy it now — it won't be shown again):

+ <%= flash[:raw_token] %> +
+<% end %> + +

Personal Access Tokens

+ +
+

Create a new token

+ <%= form_with model: @new_token, url: personal_access_tokens_path, local: true do |f| %> + <% if @new_token.errors.any? %> + + <% end %> + +
+ <%= f.label :name, "Name" %> + <%= f.text_field :name, class: "border rounded px-2 py-1 ml-2", placeholder: "e.g. Local script" %> +
+ + <%= f.submit "Generate token", class: "inline-block border rounded py-1 px-3" %> + <% end %> +
+ +

Your tokens

+ +<% if @personal_access_tokens.any? %> + + + + + + + + + + + <% @personal_access_tokens.each do |token| %> + + + + + + + <% end %> + +
NameCreatedLast used
<%= token.name %><%= token.created_at.to_date %><%= token.last_used_at ? token.last_used_at.to_date : "Never" %> + <%= button_to "Revoke", personal_access_token_path(token), method: :delete, data: { turbo_confirm: "Revoke this token?" }, class: "inline-block border rounded py-1 px-3" %> +
+<% else %> +

No tokens yet.

+<% end %> diff --git a/app/views/shared/_navigation_bar.html.erb b/app/views/shared/_navigation_bar.html.erb index 08e09f9..2fa0bba 100644 --- a/app/views/shared/_navigation_bar.html.erb +++ b/app/views/shared/_navigation_bar.html.erb @@ -21,6 +21,7 @@
  • <%= current_user.email %> + <%= link_to "API Tokens", personal_access_tokens_path, class: "inline-block border rounded py-1 px-3 ml-2" %> <%= button_to "Sign out", ombu_labs_auth.destroy_user_session_path, method: :delete, class: "inline-block border rounded py-1 px-3" %>
  • diff --git a/config/routes.rb b/config/routes.rb index 431c0ab..f21ad14 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -6,5 +6,7 @@ end post 'execute_read_task', to: 'application#execute_read_task' + resources :personal_access_tokens, only: [:index, :create, :destroy] + mount OmbuLabs::Auth::Engine, at: '/', as: 'ombu_labs_auth' end diff --git a/db/migrate/20260429134259_create_personal_access_tokens.rb b/db/migrate/20260429134259_create_personal_access_tokens.rb new file mode 100644 index 0000000..d7853e5 --- /dev/null +++ b/db/migrate/20260429134259_create_personal_access_tokens.rb @@ -0,0 +1,15 @@ +class CreatePersonalAccessTokens < ActiveRecord::Migration[7.0] + def change + create_table :personal_access_tokens do |t| + t.references :user, null: false, foreign_key: true + t.string :name, null: false + t.string :token_digest, null: false + t.datetime :last_used_at + t.datetime :expires_at + + t.timestamps + end + + add_index :personal_access_tokens, :token_digest, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index dc3b508..fc71bf8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2023_05_16_150928) do +ActiveRecord::Schema[7.0].define(version: 2026_04_29_134259) do # These are extensions that must be enabled in order to support this database enable_extension "pg_trgm" enable_extension "plpgsql" @@ -26,6 +26,18 @@ t.index ["url"], name: "index_links_on_url", opclass: :gin_trgm_ops, using: :gin end + create_table "personal_access_tokens", force: :cascade do |t| + t.bigint "user_id", null: false + t.string "name", null: false + t.string "token_digest", null: false + t.datetime "last_used_at" + t.datetime "expires_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["token_digest"], name: "index_personal_access_tokens_on_token_digest", unique: true + t.index ["user_id"], name: "index_personal_access_tokens_on_user_id" + end + create_table "shares", force: :cascade do |t| t.bigint "link_id", null: false t.string "shortened_url" @@ -65,6 +77,7 @@ t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end + add_foreign_key "personal_access_tokens", "users" add_foreign_key "shares", "links" add_foreign_key "social_media_snippets", "links" end diff --git a/spec/factories/personal_access_tokens.rb b/spec/factories/personal_access_tokens.rb new file mode 100644 index 0000000..587854b --- /dev/null +++ b/spec/factories/personal_access_tokens.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :personal_access_token do + sequence(:name) { |n| "Token ##{n}" } + user + end +end diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb new file mode 100644 index 0000000..58caebb --- /dev/null +++ b/spec/models/personal_access_token_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe PersonalAccessToken, type: :model do + describe '#token' do + it 'is generated on create and exposed via attr_reader' do + pat = FactoryBot.create(:personal_access_token) + expect(pat.token).to be_present + expect(pat.token.length).to eq(64) + end + + it 'is stored hashed, not plaintext' do + pat = FactoryBot.create(:personal_access_token) + expect(pat.token_digest).to eq(Digest::SHA256.hexdigest(pat.token)) + expect(pat.token_digest).not_to eq(pat.token) + end + + it 'is not retrievable after reload' do + pat = FactoryBot.create(:personal_access_token) + reloaded = PersonalAccessToken.find(pat.id) + expect(reloaded.token).to be_nil + end + end + + describe '.authenticate' do + it 'returns the token record for a valid raw token' do + pat = FactoryBot.create(:personal_access_token) + expect(PersonalAccessToken.authenticate(pat.token)).to eq(pat) + end + + it 'returns nil for an invalid raw token' do + FactoryBot.create(:personal_access_token) + expect(PersonalAccessToken.authenticate('not-a-real-token')).to be_nil + end + + it 'returns nil for a blank token' do + expect(PersonalAccessToken.authenticate(nil)).to be_nil + expect(PersonalAccessToken.authenticate('')).to be_nil + end + end + + describe 'validations' do + it 'requires a name' do + pat = PersonalAccessToken.new(user: FactoryBot.create(:user)) + expect(pat).not_to be_valid + expect(pat.errors[:name]).to be_present + end + end +end diff --git a/spec/requests/api_links_spec.rb b/spec/requests/api_links_spec.rb new file mode 100644 index 0000000..14475eb --- /dev/null +++ b/spec/requests/api_links_spec.rb @@ -0,0 +1,105 @@ +require 'rails_helper' + +RSpec.describe 'JSON API for links and shares', type: :request do + before do + allow_any_instance_of(Link).to receive(:fetch_social_media_snippets).and_return([]) + end + + let(:user) { FactoryBot.create(:user) } + let(:token) { FactoryBot.create(:personal_access_token, user: user) } + let(:auth_headers) { { 'Authorization' => "Bearer #{token.token}", 'Accept' => 'application/json' } } + + let!(:fastruby_link) do + FactoryBot.create(:link, + url: 'https://www.fastruby.io/blog/some-post.html', + title: 'FastRuby Post') + end + + let!(:other_link) do + FactoryBot.create(:link, + url: 'https://www.ombulabs.com/blog/another-post.html', + title: 'OmbuLabs Post') + end + + describe 'GET /links.json' do + it 'returns all links for a valid token' do + get '/links.json', headers: auth_headers + + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body.map { |l| l['title'] }).to contain_exactly('FastRuby Post', 'OmbuLabs Post') + end + + it 'filters by domain' do + get '/links.json', params: { domain: 'fastruby.io' }, headers: auth_headers + + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body.map { |l| l['title'] }).to eq(['FastRuby Post']) + end + + it 'returns 401 with no token' do + get '/links.json', headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unauthorized) + end + + it 'returns 401 with an invalid token' do + get '/links.json', headers: { 'Authorization' => 'Bearer wrong', 'Accept' => 'application/json' } + expect(response).to have_http_status(:unauthorized) + end + end + + describe 'GET /links/:id.json' do + before do + fastruby_link.social_media_snippets.create!(content: 'Tweet 1', social_media_type: 'Twitter') + fastruby_link.social_media_snippets.create!(content: 'LI 1', social_media_type: 'LinkedIn') + fastruby_link.shares.create!( + utm_source: 'LinkedIn', utm_medium: 'community', + utm_campaign: 'campaignOne', utm_term: 'termOne' + ) + end + + it 'returns the link with its social_media_snippets and no shares' do + get "/links/#{fastruby_link.id}.json", headers: auth_headers + + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body['id']).to eq(fastruby_link.id) + expect(body['title']).to eq('FastRuby Post') + expect(body['social_media_snippets'].size).to eq(2) + expect(body).not_to have_key('shares') + end + end + + describe 'GET /links/:link_id/shares.json' do + let!(:share) do + fastruby_link.shares.create!( + utm_source: 'LinkedIn', utm_medium: 'community', + utm_campaign: 'campaignOne', utm_term: 'termOne' + ) + end + + it 'returns the shares for the link' do + get "/links/#{fastruby_link.id}/shares.json", headers: auth_headers + + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body.size).to eq(1) + expect(body.first['utm_source']).to eq('LinkedIn') + expect(body.first['link_id']).to eq(fastruby_link.id) + end + + it 'returns 401 without a token' do + get "/links/#{fastruby_link.id}/shares.json", headers: { 'Accept' => 'application/json' } + expect(response).to have_http_status(:unauthorized) + end + end + + describe 'last_used_at' do + it 'is updated after a successful authenticated request' do + expect(token.last_used_at).to be_nil + get '/links.json', headers: auth_headers + expect(token.reload.last_used_at).to be_present + end + end +end