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? %>
+
+ <% @new_token.errors.full_messages.each do |msg| %>
+ - <%= msg %>
+ <% end %>
+
+ <% 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? %>
+
+
+
+ | Name |
+ Created |
+ Last used |
+ |
+
+
+
+ <% @personal_access_tokens.each do |token| %>
+
+ | <%= 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" %>
+ |
+
+ <% end %>
+
+
+<% 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