Refactor TBDB connections, drop API Token and go with Dynamic OAuth. Many other improvements
Some checks failed
CI / scan_ruby (push) Has been cancelled
CI / lint (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
Dan Milne
2025-10-23 11:57:52 +11:00
parent 828537de8a
commit 2a2a2322c6
27 changed files with 288 additions and 724 deletions

View File

@@ -137,7 +137,11 @@ User-specific tracking of barcode scans:
- **Primary Keys**: Standard Rails integer auto-increment primary keys (no ULIDs)
- **Book Identification**: Books identified by EAN-13 barcodes (13-digit strings)
- **Image Handling**: Products have `cover_image` (Active Storage) and `cover_image_url` fields
- **TBDB Integration**: Single shared `TbdbConnection` singleton for OAuth (not per-user)
- **TBDB Integration**:
- Single shared `TbdbConnection` singleton for OAuth (not per-user)
- `Tbdb::Client` - API client for making authenticated requests (lazy validation)
- `Tbdb::OauthService` - OAuth lifecycle management (register, exchange, refresh, revoke)
- Client initialization is cheap (just DB query), validation happens on first request
- Pagination handled by Pagy gem
- the MCP gitea is available for the repository dkam/shelf-life for git actions

View File

@@ -10,5 +10,4 @@
*
*= require_tree .
*= require_self
*= require choices
*/

View File

@@ -1,9 +1,10 @@
class Components::Libraries::ShowView < Components::Base
include ActionView::Helpers::FormTagHelper
include Phlex::Rails::Helpers::TurboStreamFrom
def initialize(library:, library_items:, pagy: nil)
def initialize(library:, products: [], grouped_items: {}, pagy: nil)
@library = library
@library_items = library_items
@products = products
@grouped_items = grouped_items
@pagy = pagy
end
@@ -18,7 +19,7 @@ class Components::Libraries::ShowView < Components::Base
)
div(class: "pt-20 px-4", id: "blahblahblah") do
div(class: "max-w-4xl mx-auto") do
div(class: "max-w-7xl mx-auto") do
div(class: "mb-8") do
div(class: "flex items-center justify-between") do
div do
@@ -27,27 +28,36 @@ class Components::Libraries::ShowView < Components::Base
p(class: "text-gray-600 mt-2") { @library.description }
end
end
div(class: "flex gap-2") do
a(href: import_library_path(@library), class: "bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors") { "Import" }
a(href: export_library_path(@library, format: :csv), class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors") { "Export CSV" }
a(href: edit_library_path(@library), class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors") { "Edit Library" }
end
end
end
if @library_items.any?
if @products.any?
# Pagination at top
render_pagination if @pagy && @pagy.pages > 1
div(class: "grid gap-4") do
@library_items.each do |library_item|
render Components::Libraries::LibraryItemView.new(library_item: library_item)
# Render grouped products
div(class: "mt-4") do
@products.each do |product|
library_items = @grouped_items[product]
render Components::Libraries::ProductGroupView.new(
product: product,
library_items: library_items
)
end
end
# Pagination at bottom
render_pagination if @pagy && @pagy.pages > 1
# Pagination and action buttons at bottom
div(class: "mt-8") do
div(class: "flex justify-center") do
render_pagination if @pagy && @pagy.pages > 1
end
div(class: "flex justify-center gap-2 mt-4") do
a(href: import_library_path(@library), class: "bg-green-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-green-700 transition-colors") { "Import" }
a(href: export_library_path(@library, format: :csv), class: "bg-purple-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-purple-700 transition-colors") { "Export CSV" }
a(href: edit_library_path(@library), class: "bg-blue-600 text-white px-3 py-1.5 text-sm rounded-lg hover:bg-blue-700 transition-colors") { "Edit Library" }
end
end
else
div(class: "bg-white rounded-lg shadow-md p-8 text-center") do
div(class: "text-6xl mb-4") { "📚" }
@@ -66,50 +76,52 @@ class Components::Libraries::ShowView < Components::Base
private
def render_pagination
div(class: "mt-8 flex justify-center") do
nav(class: "flex space-x-2") do
# Previous button
nav(class: "flex items-center gap-1 text-sm") do
# Previous link
if @pagy.prev
a(href: library_path(@library, page: @pagy.prev),
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50") do
"Previous"
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
"Previous"
end
else
span(class: "px-3 py-2 text-gray-300 bg-gray-100 border rounded-md cursor-not-allowed") do
"Previous"
span(class: "text-gray-400 px-2") do
"Previous"
end
end
span(class: "text-gray-400 mx-1") { "|" }
# Page numbers
start_page = [@pagy.page - 2, 1].max
end_page = [@pagy.page + 2, @pagy.pages].min
(start_page..end_page).each do |page_num|
if page_num == @pagy.page
span(class: "px-3 py-2 text-white bg-blue-600 border rounded-md mr-1") do
span(class: "font-semibold text-gray-900 px-2") do
page_num.to_s
end
else
a(href: library_path(@library, page: page_num),
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50 mr-1") do
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
page_num.to_s
end
end
end
# Next button
span(class: "text-gray-400 mx-1") { "|" }
# Next link
if @pagy.next
a(href: library_path(@library, page: @pagy.next),
class: "px-3 py-2 bg-white border rounded-md hover:bg-gray-50") do
"Next"
class: "text-blue-600 hover:text-blue-800 hover:underline px-2") do
"Next"
end
else
span(class: "px-3 py-2 text-gray-300 bg-gray-100 border rounded-md cursor-not-allowed") do
"Next"
span(class: "text-gray-400 px-2") do
"Next"
end
end
end
end
end
end

View File

@@ -56,8 +56,18 @@ class Components::Products::DisplayView < Components::Base
# Library Selection Dropdown
render Components::Shared::LibraryDropdownView.new(product: @product)
# Delete Product Button
div(class: "mt-4 pt-4 border-t border-gray-300") do
# Refresh and Delete Product Buttons
div(class: "mt-4 pt-4 border-t border-gray-300 flex gap-2") do
# Refresh Data Button
form(method: "post", action: refresh_product_path(@product), class: "inline") do
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
button(
type: "submit",
class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors text-sm"
) { "Refresh Data" }
end
# Delete Product Button
form(method: "post", action: product_path(@product), class: "inline") do
input(type: "hidden", name: "_method", value: "delete")
input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)

View File

@@ -122,9 +122,6 @@ class Components::User::ProfileView < Components::Base
if @connection.last_error.present?
div(class: "text-xs text-red-600 mt-1") { @connection.last_error }
end
if @connection.api_base_url.present?
div(class: "text-xs text-red-500 mt-1") { "OAuth tokens from: #{@connection.api_base_url}" }
end
end
a(
href: auth_tbdb_path,

View File

@@ -1,6 +1,10 @@
class ApplicationController < ActionController::Base
include Authentication
include Pagy::Backend
# Enable Pagy array extra for paginating arrays
require 'pagy/extras/array'
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern

View File

@@ -14,8 +14,19 @@ class LibrariesController < ApplicationController
library_items = library_items.joins(:product).where(products: { valid_barcode: true })
end
@pagy, @library_items = pagy(library_items, overflow: :last_page)
render Components::Libraries::ShowView.new(library: @library, library_items: @library_items, pagy: @pagy)
# Group library items by product for display
@grouped_items = library_items.group_by(&:product)
# Paginate by unique products, not individual items
products_array = @grouped_items.keys
@pagy, @products = pagy_array(products_array, overflow: :last_page)
render Components::Libraries::ShowView.new(
library: @library,
products: @products,
grouped_items: @grouped_items,
pagy: @pagy
)
end
def edit

View File

@@ -1,15 +1,37 @@
class LibraryItemsController < ApplicationController
before_action :set_library_item, only: [:show, :edit, :update, :destroy]
def show
render Components::LibraryItems::ShowView.new(library_item: @library_item)
end
def edit
render Components::LibraryItems::EditView.new(library_item: @library_item)
end
def update
# Convert tags string to array if present
if params[:library_item][:tags].present?
params[:library_item][:tags] = params[:library_item][:tags].split(',').map(&:strip).reject(&:blank?)
end
if @library_item.update(library_item_params)
redirect_to library_path(@library_item.library), notice: "Item updated successfully."
else
render Components::LibraryItems::EditView.new(library_item: @library_item), status: :unprocessable_entity
end
end
def create
handle_exist_checkbox
end
def destroy
@library_item = LibraryItem.find(params[:id])
@product = @library_item.product
@library = @library_item.library
@library_item.destroy
respond_to do |format|
format.turbo_stream
format.html { redirect_back_or_to libraries_path, notice: "Removed from library." }
@@ -18,6 +40,33 @@ class LibraryItemsController < ApplicationController
private
def set_library_item
@library_item = LibraryItem.find(params[:id])
end
def library_item_params
params.require(:library_item).permit(
:location,
:condition_id,
:condition_notes,
:notes,
:private_notes,
:acquisition_date,
:acquisition_price,
:acquisition_source_id,
:ownership_status_id,
:item_status_id,
:copy_identifier,
:replacement_cost,
:original_retail_price,
:current_market_value,
:lent_to,
:due_date,
:is_favorite,
tags: []
)
end
def handle_exist_checkbox
@product = Product.find(params[:library_item][:product_id])
@library = Library.find(params[:library_item][:library_id])

View File

@@ -2,12 +2,12 @@ class OauthController < ApplicationController
before_action :require_authentication
def tbdb
oauth_service = TbdbOauthService.new
oauth_service = Tbdb::OauthService.new
begin
authorization_url = oauth_service.authorization_url
redirect_to authorization_url, allow_other_host: true
rescue TbdbOauthService::OAuthError => e
rescue Tbdb::OauthService::OAuthError => e
Rails.logger.error "OAuth initiation failed: #{e.message}"
redirect_to profile_path, alert: "Failed to connect to TBDB: #{e.message}"
end
@@ -24,7 +24,7 @@ class OauthController < ApplicationController
if error == "invalid_client" && error_hint == "client_not_found"
Rails.logger.info "OAuth client not found on TBDB, clearing credentials and re-registering"
oauth_service = TbdbOauthService.new
oauth_service = Tbdb::OauthService.new
begin
# Clear the invalid credentials
@@ -50,19 +50,19 @@ class OauthController < ApplicationController
return
end
oauth_service = TbdbOauthService.new
oauth_service = Tbdb::OauthService.new
begin
oauth_service.exchange_code_for_token(code, state)
redirect_to profile_path, notice: "Successfully connected to TBDB!"
rescue TbdbOauthService::OAuthError => e
rescue Tbdb::OauthService::OAuthError => e
Rails.logger.error "OAuth token exchange failed: #{e.message}"
redirect_to profile_path, alert: "Failed to complete TBDB connection: #{e.message}"
end
end
def tbdb_disconnect
oauth_service = TbdbOauthService.new
oauth_service = Tbdb::OauthService.new
oauth_service.revoke_tokens
redirect_to profile_path, notice: "Disconnected from TBDB"
end

View File

@@ -79,6 +79,13 @@ class ProductsController < ApplicationController
)
end
def refresh
@product = Product.find(params[:id])
ProductDataFetchJob.set(queue: :high_priority).perform_later(@product, true)
redirect_to "/#{@product.gtin}", notice: "Refreshing data for #{@product.safe_title}..."
end
def destroy
@product = Product.find(params[:id])
product_title = @product.safe_title

View File

@@ -1,3 +1,4 @@
// Entry point for the build script in your package.json
import "@hotwired/turbo-rails"
import "./controllers"
import "slim-select/styles"

View File

@@ -6,9 +6,11 @@ import HelloController from "./hello_controller"
import LibraryDropdownController from "./library_dropdown_controller"
import LibraryFormController from "./library_form_controller"
import SettingsFormController from "./settings_form_controller"
import SlimSelectController from "./slim_select_controller"
application.register("barcode-scanner", BarcodeScannerController)
application.register("hello", HelloController)
application.register("library-dropdown", LibraryDropdownController)
application.register("library-form", LibraryFormController)
application.register("settings-form", SettingsFormController)
application.register("settings-form", SettingsFormController)
application.register("slim-select", SlimSelectController)

View File

@@ -64,7 +64,7 @@ export default class extends Controller {
// Update UI
this.buttonTextTarget.textContent = "Stop";
this.startButtonTarget.classList.remove("bg-booko", "hover:bg-booko-darker");
this.startButtonTarget.classList.remove("bg-indigo-600", "hover:bg-indigo-700");
this.startButtonTarget.classList.add("bg-red-600", "hover:bg-red-700");
// Show scanner
@@ -148,7 +148,7 @@ export default class extends Controller {
// Reset UI
this.buttonTextTarget.textContent = "Scan";
this.startButtonTarget.classList.remove("bg-red-600", "hover:bg-red-700");
this.startButtonTarget.classList.add("bg-booko", "hover:bg-booko-darker");
this.startButtonTarget.classList.add("bg-indigo-600", "hover:bg-indigo-700");
// Hide scanner
this.scannerContainerTarget.style.display = "none";

View File

@@ -1,10 +1,13 @@
class LibraryItem < ApplicationRecord
belongs_to :product, foreign_key: :product_id
belongs_to :library, foreign_key: :library_id
belongs_to :condition, optional: true
belongs_to :item_status, optional: true
belongs_to :acquisition_source, optional: true
belongs_to :ownership_status, optional: true
serialize :tags, type: Array, coder: JSON
validates :product_id, presence: true
validates :library_id, presence: true
validates :acquisition_price, :replacement_cost, :original_retail_price, :current_market_value,
@@ -19,6 +22,7 @@ class LibraryItem < ApplicationRecord
scope :owned_items, -> { joins(:library).where(libraries: { virtual: false }) }
scope :favorites, -> { where(is_favorite: true) }
scope :by_condition, ->(condition) { where(condition: condition) }
scope :with_condition, ->(condition_name) { joins(:condition).where(conditions: { name: condition_name }) }
scope :overdue, -> { where("due_date < ?", Date.current) }
scope :with_status, ->(status_name) { joins(:item_status).where(item_statuses: { name: status_name }) }
scope :available, -> { joins(:item_status).where(item_statuses: { name: "Available" }) }
@@ -81,9 +85,12 @@ class LibraryItem < ApplicationRecord
def update_condition(new_condition, notes = nil)
return false if virtual_item?
condition_record = new_condition.is_a?(Condition) ? new_condition : Condition.find_by(name: new_condition)
return false unless condition_record
update(
condition: new_condition,
condition: condition_record,
condition_notes: notes,
last_condition_check: Date.current
)
@@ -99,16 +106,7 @@ class LibraryItem < ApplicationRecord
end
def condition_status
return "Unknown" if condition.blank?
condition.humanize
end
def tag_list
tags&.split(",")&.map(&:strip) || []
end
def tag_list=(tag_array)
self.tags = Array(tag_array).map(&:strip).reject(&:blank?).join(", ")
condition&.name || "Unknown"
end
private

View File

@@ -1,7 +1,8 @@
class TbdbConnection < ApplicationRecord
# Singleton pattern - only one TBDB connection per ShelfLife instance
# This connection is used for all TBDB API product data lookups
# See app/services/tbdb for tbdb client, oauth service and error definitions.
VERIFICATION_TTL = 10.minutes
def self.instance
@@ -42,7 +43,12 @@ class TbdbConnection < ApplicationRecord
def mark_invalid!(error_message)
update!(
status: 'invalid',
last_error: error_message
last_error: error_message,
quota_remaining: nil,
quota_limit: nil,
quota_percentage: nil,
quota_reset_at: nil,
quota_updated_at: nil
)
end
@@ -69,7 +75,53 @@ class TbdbConnection < ApplicationRecord
api_base_url: nil,
status: 'connected',
verified_at: nil,
last_error: nil
last_error: nil,
quota_remaining: nil,
quota_limit: nil,
quota_percentage: nil,
quota_reset_at: nil,
quota_updated_at: nil
)
end
# Update quota information from API response
def update_quota(remaining:, limit:, reset_at: nil)
percentage = if limit && limit > 0
(remaining.to_f / limit * 100).round(1)
else
0.0
end
update!(
quota_remaining: remaining,
quota_limit: limit,
quota_percentage: percentage,
quota_reset_at: reset_at,
quota_updated_at: Time.current
)
end
# Clear quota information (when connection becomes invalid)
def clear_quota
update!(
quota_remaining: nil,
quota_limit: nil,
quota_percentage: nil,
quota_reset_at: nil,
quota_updated_at: nil
)
end
# Get quota status as a hash (for compatibility with cached format)
def quota_status
return nil unless quota_remaining && quota_limit
{
remaining: quota_remaining,
limit: quota_limit,
percentage: quota_percentage,
reset_at: quota_reset_at,
updated_at: quota_updated_at
}
end
end

View File

@@ -1,6 +1,6 @@
class ProductEnrichmentService
def initialize(tbdb_service: nil)
@tbdb_service = tbdb_service || ShelfLife::TbdbService.new
def initialize(tbdb_client: nil)
@tbdb_client = tbdb_client || Tbdb::Client.new
end
def call(product, force = false)
@@ -62,7 +62,7 @@ class ProductEnrichmentService
end
def fetch_tbdb_data(gtin)
tbdb_response = @tbdb_service.get_product(gtin)
tbdb_response = @tbdb_client.get_product(gtin)
return nil unless tbdb_response.present?
# Extract data from response structure

View File

@@ -1,39 +0,0 @@
require_relative '../tbdb'
module ShelfLife
class TbdbService
attr_reader :client
def initialize
@client = get_or_create_client
end
# Delegate common methods to the TBDB client
def get_product(product_id)
client.get_product(product_id)
end
def search_products(query, options = {})
client.search_products(query, options)
end
# Convenience class method
def self.client
new.client
end
private
def get_or_create_client
connection = TbdbConnection.instance
# Create cache key based on connection status and updated timestamp
# This ensures cache is invalidated when connection changes
cache_key = "tbdb_client:#{connection.status}:#{connection.updated_at.to_i}"
Rails.cache.fetch(cache_key, expires_in: 25.minutes) do
Tbdb::Client.new
end
end
end
end

View File

@@ -1,395 +1,9 @@
require "net/http"
require "json"
require "uri"
# Tbdb module namespace loader
# Ensures all Tbdb classes are available for autoloading
require_relative "tbdb/errors"
module Tbdb
class RateLimitError < StandardError
attr_accessor :reset_time, :retry_after
end
class QuotaExhaustedError < StandardError
attr_accessor :reset_time, :retry_after
end
class AuthenticationError < StandardError
end
class ConnectionRequiredError < StandardError
end
class Client
VERSION = "0.3"
# Fallback base URI if connection doesn't specify one
DEFAULT_BASE_URI = ENV.fetch("TBDB_API_URI", "https://api.thebookdb.info").freeze
attr_reader :jwt_token, :jwt_expires_at, :base_uri, :last_request_time, :calculated_delay
def initialize(base_uri: nil)
@connection = TbdbConnection.instance
@last_request_time = nil
@calculated_delay = nil
# Ensure we have a valid OAuth connection
ensure_oauth_connected!
# Use connection's base URL (set during OAuth), or fallback
effective_base_uri = base_uri || @connection.api_base_url || DEFAULT_BASE_URI
@base_uri = URI(effective_base_uri)
# Verify base URI matches connection's base URI
verify_base_uri_match!
# Load JWT from OAuth (access token IS the JWT)
load_jwt_from_oauth
end
def user_agent
"ShelfLife-Bot/#{VERSION} (#{Rails.application.class.module_parent_name})"
end
# Main API methods
def get_product(product_id)
make_request("/api/v1/products/#{product_id}")
end
def search_products(query, options = {})
params = { q: query }
params[:ptype] = options[:product_type] if options[:product_type]
params[:per_page] = [options[:per_page] || 20, 100].min
params[:page] = [options[:page] || 1, 1].max
make_request("/search", method: :get, params: params)
end
def create_product(product_data)
make_request("/api/v1/products", method: :post, params: product_data)
end
def update_product(product_id, product_data)
make_request("/api/v1/products/#{product_id}", method: :patch, params: product_data)
end
def get_me
make_request("/api/v1/me")
end
private
def ensure_oauth_connected!
# Check if connection is marked as invalid
if @connection.status == 'invalid'
error_msg = @connection.last_error || "TBDB connection is invalid. Please reconnect at /profile"
Rails.logger.error "TBDB connection invalid: #{error_msg}"
raise ConnectionRequiredError, error_msg
end
# Check if we have OAuth tokens
unless @connection.access_token.present?
error_msg = "No TBDB OAuth connection. Please connect at /profile"
Rails.logger.error error_msg
raise ConnectionRequiredError, error_msg
end
# Check if token is expired
if @connection.token_expired?
Rails.logger.debug "OAuth token expired, attempting refresh..."
refresh_oauth_token
end
end
def verify_base_uri_match!
# Warn if using different base URI than what connection was registered with
if @connection.api_base_url.present? && @connection.api_base_url != @base_uri.to_s
Rails.logger.warn "⚠️ Base URI mismatch: connection=#{@connection.api_base_url}, client=#{@base_uri}"
end
end
def load_jwt_from_oauth
# OAuth access tokens ARE JWTs - use directly
@jwt_token = @connection.access_token
@jwt_expires_at = @connection.expires_at
Rails.logger.debug "Using OAuth JWT (expires at #{@jwt_expires_at})"
end
def refresh_oauth_token
oauth_service = TbdbOauthService.new
if oauth_service.refresh_access_token
# Reload connection to get fresh token
@connection.reload
@jwt_token = @connection.access_token
@jwt_expires_at = @connection.expires_at
Rails.logger.debug "OAuth token refreshed successfully"
else
error_msg = "Failed to refresh OAuth token. Please reconnect at /profile"
@connection.mark_invalid!(error_msg)
Rails.logger.error error_msg
raise AuthenticationError, error_msg
end
end
def make_request(path, method: :get, params: {}, retry_count: 0)
# Check if token needs refresh before making request
if @connection.token_expired?
refresh_oauth_token
end
throttle_request
# Ensure path starts with /
api_path = path.start_with?("/") ? path : "/#{path}"
uri = URI.join(@base_uri.to_s.chomp("/") + "/", api_path.sub(/^\//, ""))
# Add query parameters for GET requests
if method == :get && params.any?
uri.query = URI.encode_www_form(params)
end
Rails.logger.debug "TBDB API Request: #{method.upcase} #{uri}"
# Create request object
request = case method
when :get then Net::HTTP::Get.new(uri)
when :post then Net::HTTP::Post.new(uri)
when :patch then Net::HTTP::Patch.new(uri)
when :delete then Net::HTTP::Delete.new(uri)
else raise ArgumentError, "Unsupported HTTP method: #{method}"
end
# Set headers with OAuth JWT
request["Authorization"] = "Bearer #{@jwt_token}"
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request["User-Agent"] = user_agent
# Add body for non-GET requests
if method != :get && params.any?
request.body = JSON.generate(params)
end
# Make the request
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
response = http.request(request)
@last_request_time = Time.now
# Extract rate limit and quota info from headers
store_rate_limit_info(response)
check_quota_status(response)
# Handle response
handle_response(response, path, method, params, retry_count)
end
def handle_response(response, path, method, params, retry_count)
case response
when Net::HTTPSuccess
return {} if response.body.nil? || response.body.empty?
begin
parsed_body = JSON.parse(response.body)
# Extract quota from response body if present (e.g., from /me endpoint)
check_quota_from_body(parsed_body)
parsed_body
rescue JSON::ParserError => e
Rails.logger.error "Failed to parse TBDB API response as JSON: #{e.message}"
nil
end
when Net::HTTPUnauthorized # 401
handle_401_error(response)
else
# Handle other status codes
case response.code
when "429"
handle_429_response(response, path, method, params, retry_count)
when "503"
handle_503_response(response)
else
Rails.logger.error "TBDB API request failed: #{response.code} - #{response.message}"
log_error_details(response)
nil # Return nil for other errors (404, 400, etc.)
end
end
end
def handle_401_error(response)
Rails.logger.error "TBDB API request failed: 401 - Unauthorized"
log_error_details(response)
# Mark OAuth connection as invalid
error_msg = if @connection.api_base_url.present? && @connection.api_base_url != @base_uri.to_s
"OAuth tokens from #{@connection.api_base_url} cannot access #{@base_uri}. Please reconnect to the correct TBDB instance."
else
"OAuth tokens are invalid or expired. Please reconnect to TBDB."
end
Rails.logger.error "Marking OAuth connection as invalid: #{error_msg}"
@connection.mark_invalid!(error_msg)
# Clear cached clients
Rails.cache.delete_matched("tbdb_client:*")
raise AuthenticationError, error_msg
end
def log_error_details(response)
begin
error_data = JSON.parse(response.body)
Rails.logger.error "Error details: #{error_data.inspect}"
rescue JSON::ParserError
Rails.logger.error "Response: #{response.body}"
end
end
def throttle_request
return unless @last_request_time
# Use dynamic delay from headers, fallback to 1.1s
min_interval = @calculated_delay || 1.1
time_since_last = Time.now - @last_request_time
if time_since_last < min_interval
sleep_time = min_interval - time_since_last
Rails.logger.debug "Throttling request: sleeping #{sleep_time.round(2)}s (interval: #{min_interval}s)"
sleep(sleep_time)
end
end
def calculate_backoff_time(retry_count)
# Exponential backoff: 2^retry_count + 1 second (1s buffer for rate limit)
base_wait = 2 ** retry_count
base_wait + 1
end
def store_rate_limit_info(response)
limit = response["X-RateLimit-Limit"]&.to_f
window = response["X-RateLimit-Window"]&.to_f
if limit && window && limit > 0
@calculated_delay = window / limit
Rails.logger.debug "Rate limit extracted: #{limit} requests per #{window}s = #{@calculated_delay}s delay"
end
end
def check_quota_status(response)
remaining = response["X-Quota-Remaining"]&.to_i
limit = response["X-Quota-Limit"]&.to_i
reset_time = response["X-Quota-Reset"]&.to_i
if remaining && limit && remaining > 0
store_quota_in_cache(remaining, limit, reset_time)
end
end
def check_quota_from_body(body)
# Extract quota from /me endpoint response body
return unless body.is_a?(Hash) && body["rate_limits"]
rate_limits = body["rate_limits"]
limits = rate_limits["limits"] || {}
usage = rate_limits["usage"] || {}
quota_max = limits["quota_max"]
current_usage = usage["current_quota"] || 0
quota_expires_at = usage["quota_expires_at"]
if quota_max
remaining = quota_max - current_usage
# Use quota_expires_at from API if present, otherwise fallback to quota_window calculation
reset_time = if quota_expires_at.present?
Time.parse(quota_expires_at).to_i
else
Time.now.to_i + (limits["quota_window"] || 86400)
end
Rails.logger.debug "Extracted quota from response body: #{remaining}/#{quota_max}, resets at #{Time.at(reset_time)}"
store_quota_in_cache(remaining, quota_max, reset_time)
end
end
def store_quota_in_cache(remaining, limit, reset_time)
# Handle division by zero and calculate percentage
percentage = if limit && limit > 0
(remaining.to_f / limit * 100).round(1)
else
0.0
end
Rails.logger.debug "TBDB quota: #{remaining}/#{limit || 'unknown'} remaining (#{percentage}%)"
# Cache quota info for display in UI
quota_data = {
remaining: remaining,
limit: limit,
percentage: percentage,
reset_at: reset_time ? Time.at(reset_time) : nil,
updated_at: Time.now
}
# Store in cache with single shared key for entire instance
Rails.cache.write("tbdb_quota_status:default", quota_data, expires_in: 1.hour)
if remaining == 0
Rails.logger.error "❌ TBDB quota exhausted: #{remaining}/#{limit || 'unknown'} remaining"
elsif limit && limit > 0 && remaining < (limit * 0.1)
Rails.logger.warn "⚠️ TBDB quota low: #{remaining}/#{limit} remaining (#{percentage}%)"
end
end
def handle_429_response(response, path, method, params, retry_count)
retry_after = response["Retry-After"]&.to_i || 60
reset_time_header = response["X-Quota-Reset"]&.to_i
# Check if this is quota exhaustion (long retry) vs rate limit (short retry)
if retry_after > 60 # Quota exhausted
error = QuotaExhaustedError.new("TBDB daily quota exhausted")
error.reset_time = reset_time_header ? Time.at(reset_time_header) : (Time.now + retry_after)
error.retry_after = retry_after
Rails.logger.error "TBDB quota exhausted. Resets at #{error.reset_time}. Retry in #{retry_after}s"
raise error
elsif retry_count < 3 # Rate limit, retry with backoff
wait_time = calculate_backoff_time(retry_count)
Rails.logger.warn "Rate limited (429), retrying in #{wait_time}s (attempt #{retry_count + 1}/3)"
sleep(wait_time)
return make_request(path, method: method, params: params, retry_count: retry_count + 1)
else # Rate limit but out of retries
error = RateLimitError.new("TBDB API rate limit exceeded after #{retry_count + 1} attempts")
error.retry_after = retry_after
error.reset_time = reset_time_header ? Time.at(reset_time_header) : nil
Rails.logger.error "Rate limit exceeded after #{retry_count + 1} attempts"
raise error
end
end
def handle_503_response(response)
# Parse retry time from response body (e.g., "service unavailable: Retry in 600")
retry_after = 600 # Default to 10 minutes
begin
body = response.body
if body =~ /Retry in (\d+)/
retry_after = $1.to_i
end
rescue
# Use default if parsing fails
end
error = QuotaExhaustedError.new("TBDB service unavailable")
error.reset_time = Time.now + retry_after
error.retry_after = retry_after
Rails.logger.error "TBDB service unavailable (503). Retry in #{retry_after}s (#{(retry_after / 60.0).round(1)} minutes)"
raise error
end
end
# Convenience method for creating a client instance
def self.client
@client ||= Client.new
@@ -404,8 +18,8 @@ module Tbdb
client.search_products(query, options)
end
# Retrieve cached quota status for the instance
# Retrieve quota status from the connection
def self.quota_status
Rails.cache.read("tbdb_quota_status:default")
TbdbConnection.instance.quota_status
end
end

View File

@@ -1,205 +0,0 @@
require "net/http"
require "json"
require "uri"
require "securerandom"
class TbdbOauthService
class OAuthError < StandardError; end
# OAuth endpoints (authorization, token exchange, etc.)
TBDB_OAUTH_URL = ENV.fetch("TBDB_OAUTH_URL", "http://thebookdb.localhost:3000").freeze
# API endpoints (for making authenticated API calls)
TBDB_API_URL = ENV.fetch("TBDB_API_URI", "http://api.thebookdb.localhost:3000").freeze
def initialize
@connection = TbdbConnection.instance
end
# Step 1: Register OAuth client dynamically if needed
def ensure_oauth_client
return if @connection.registered?
register_oauth_client
end
# Step 2: Generate authorization URL for user to visit
def authorization_url
ensure_oauth_client
state = SecureRandom.hex(16)
Rails.cache.write("oauth_state", state, expires_in: 10.minutes)
params = {
response_type: "code",
client_id: @connection.client_id,
redirect_uri: redirect_uri,
state: state,
scope: "data:read"
}
"#{TBDB_OAUTH_URL}/oauth/authorize?#{URI.encode_www_form(params)}"
end
# Step 3: Exchange authorization code for access token
def exchange_code_for_token(code, state)
# Verify state parameter
cached_state = Rails.cache.read("oauth_state")
raise OAuthError, "Invalid state parameter" if state != cached_state
Rails.cache.delete("oauth_state")
uri = URI("#{TBDB_OAUTH_URL}/oauth/token")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
grant_type: "authorization_code",
client_id: @connection.client_id,
client_secret: @connection.client_secret,
code: code,
redirect_uri: redirect_uri
})
# Debug logging
Rails.logger.debug "=== OAuth Token Exchange Debug ==="
Rails.logger.debug "Client ID: #{@connection.client_id}"
Rails.logger.debug "Redirect URI: #{redirect_uri}"
Rails.logger.debug "Authorization Code: #{code[0..10]}..." # Only log first part for security
Rails.logger.debug "Request Body: #{request.body}"
response = make_http_request(uri, request)
Rails.logger.debug "Response Status: #{response.code}"
Rails.logger.debug "Response Body: #{response.body}"
token_data = JSON.parse(response.body)
if response.is_a?(Net::HTTPSuccess)
store_tokens(token_data)
else
raise OAuthError, "Token exchange failed: #{token_data['error'] || response.message}"
end
end
# Refresh access token using refresh token
def refresh_access_token
return false unless @connection.refresh_token.present?
uri = URI("#{TBDB_OAUTH_URL}/oauth/token")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
grant_type: "refresh_token",
client_id: @connection.client_id,
client_secret: @connection.client_secret,
refresh_token: @connection.refresh_token
})
response = make_http_request(uri, request)
if response.is_a?(Net::HTTPSuccess)
token_data = JSON.parse(response.body)
store_tokens(token_data)
true
else
Rails.logger.error "OAuth token refresh failed: #{response.body}"
false
end
end
# Revoke OAuth tokens
def revoke_tokens
return unless @connection.access_token.present?
uri = URI("#{TBDB_OAUTH_URL}/oauth/revoke")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
request.body = JSON.generate({
client_id: @connection.client_id,
client_secret: @connection.client_secret,
token: @connection.access_token
})
make_http_request(uri, request)
@connection.clear_connection
end
# Clear client credentials (for re-registration scenarios)
def clear_client_credentials
@connection.clear_registration
Rails.logger.info "Cleared OAuth client credentials"
end
private
def redirect_uri
# Generate redirect URI at runtime to avoid host issues
if Rails.env.development?
"http://localhost:4001/auth/tbdb/callback"
else
"#{Rails.application.routes.url_helpers.root_url.chomp('/')}/auth/tbdb/callback"
end
end
def register_oauth_client
uri = URI("#{TBDB_OAUTH_URL}/oauth/register")
request = Net::HTTP::Post.new(uri)
request["Content-Type"] = "application/json"
request["Accept"] = "application/json"
client_name = "ShelfLife Instance"
request.body = JSON.generate({
client_name: client_name,
redirect_uris: [redirect_uri], # Single redirect URI for this ShelfLife instance
scope: "data:read",
application_type: "web",
token_endpoint_auth_method: "client_secret_post"
})
response = make_http_request(uri, request)
if response.is_a?(Net::HTTPSuccess)
client_data = JSON.parse(response.body)
@connection.update!(
client_id: client_data["client_id"],
client_secret: client_data["client_secret"],
api_base_url: TBDB_API_URL
)
Rails.logger.info "Registered OAuth client for ShelfLife instance with #{TBDB_API_URL}"
else
error_data = JSON.parse(response.body) rescue { "error" => response.message }
raise OAuthError, "Client registration failed: #{error_data['error']}"
end
end
def store_tokens(token_data)
expires_at = if token_data["expires_in"]
Time.current + token_data["expires_in"].to_i.seconds
else
1.hour.from_now # Default fallback
end
@connection.update!(
access_token: token_data["access_token"],
refresh_token: token_data["refresh_token"],
expires_at: expires_at,
api_base_url: TBDB_API_URL, # Ensure base URL is always set
verified_at: Time.current # Mark as verified since we just got tokens
)
Rails.logger.info "Stored OAuth tokens for ShelfLife instance (#{TBDB_API_URL})"
end
def make_http_request(uri, request)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = uri.scheme == "https"
http.request(request)
end
end

View File

@@ -1,8 +1,8 @@
<% content_for :tags do %>
<meta name="description" content="Advanced barcode scanner for book prices - find the cheapest books with Booko's improved scanner" >
<meta name="description" content="Advanced barcode scanner for books, DVDs, and board games - scan and manage your library" >
<meta name="keywords" content="barcode scanner, book prices, ISBN scanner, quagga2, advanced scanner"/>
<meta property="og:description" content="Advanced Booko Barcode Scanner" >
<meta property="og:site_name" content="Booko" >
<meta property="og:description" content="ShelfLife Barcode Scanner" >
<meta property="og:site_name" content="ShelfLife" >
<!-- script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2/dist/quagga.min.js"></script-->
<% end %>
@@ -14,16 +14,16 @@
<!-- Scanner Controls -->
<div class="text-center mb-6">
<button data-quagga2-scanner-target="startButton" data-action="click->quagga2-scanner#toggleCamera"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-booko hover:bg-booko-darker focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko mr-4">
<button data-quagga2-scanner-target="startButton" data-action="click->quagga2-scanner#toggleCamera"
class="inline-flex items-center px-4 py-2 border border-transparent text-base font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 mr-4">
<svg class="w-6 h-6 mr-2" fill="currentColor" viewBox="0 0 24 24">
<path d="M23 4.5V8h-1V4.5A1.502 1.502 0 0 0 20.5 3H17V2h3.5A2.503 2.503 0 0 1 23 4.5zM4.5 22A1.502 1.502 0 0 1 3 20.5V17H2v3.5A2.503 2.503 0 0 0 4.5 23H8v-1zM22 20.5a1.502 1.502 0 0 1-1.5 1.5H17v1h3.5a2.503 2.503 0 0 0 2.5-2.5V17h-1zM3 4.5A1.502 1.502 0 0 1 4.5 3H8V2H4.5A2.503 2.503 0 0 0 2 4.5V8h1zM10 19V6H9v13zM6 6v13h2V6zm8 13V6h-2v13zm3-13v13h2V6zm-2 0v13h1V6z"/>
</svg>
<span data-quagga2-scanner-target="buttonText">Start Scan</span>
</button>
<button data-quagga2-scanner-target="flashButton" data-action="click->quagga2-scanner#toggleFlash"
class="inline-flex items-center px-4 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-tertiary-600 hover:bg-gray-50 dark:hover:bg-tertiary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko"
<button data-quagga2-scanner-target="flashButton" data-action="click->quagga2-scanner#toggleFlash"
class="inline-flex items-center px-4 py-3 border border-gray-300 text-base font-medium rounded-md text-gray-700 dark:text-gray-200 bg-white dark:bg-tertiary-600 hover:bg-gray-50 dark:hover:bg-tertiary-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="display: none;">
<span data-quagga2-scanner-target="flashText">💡 Torch Off</span>
</button>
@@ -76,7 +76,7 @@
placeholder="Enter ISBN or barcode (e.g., 9781234567890)"
class="flex-1 min-w-0 block w-full px-3 py-2 rounded-md border border-gray-300 dark:border-tertiary-500 bg-white dark:bg-tertiary-600 text-gray-900 dark:text-white focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
<button data-action="click->quagga2-scanner#searchManual"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-booko hover:bg-booko-darker focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-booko">
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Search
</button>
</div>
@@ -106,8 +106,7 @@
<!-- Navigation Links -->
<div class="text-center space-y-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
Having issues? <a href="/faq" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">Check our FAQ</a> or
<a href="mailto:support@booko.com.au" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">contact support</a>
Having issues? <a href="/faq" class="text-indigo-600 hover:text-indigo-500 dark:text-indigo-400">Check our FAQ</a>
</p>
</div>

View File

@@ -14,7 +14,7 @@ development:
<<: *default
database: storage/development.sqlite3
cache:
<<: *default
<<: *default
database: storage/development_cache.sqlite3
migrations_paths: db/cache_migrate
queue:

View File

@@ -48,7 +48,8 @@ Rails.application.configure do
# Replace the default in-process memory cache store with a durable alternative.
config.cache_store = :solid_cache_store
config.solid_cache.connects_to = { database: { writing: :cache } }
# Already specified in cache.yml
# config.solid_cache.connects_to = { database: { writing: :cache } }
# Replace the default in-process and non-durable queuing backend for Active Job.
config.active_job.queue_adapter = :solid_queue

View File

@@ -29,7 +29,11 @@ Rails.application.routes.draw do
delete "/signout", to: "sessions#destroy", as: :signout
# Product management routes
resources :products, only: [ :index, :show, :destroy ]
resources :products, only: [ :index, :show, :destroy ] do
member do
post :refresh
end
end
# Library management routes
resources :libraries, only: [ :index, :show, :edit, :update ] do
@@ -39,7 +43,7 @@ Rails.application.routes.draw do
get :export
end
end
resources :library_items, only: [ :create, :destroy ]
resources :library_items, only: [ :show, :edit, :update, :create, :destroy ]
# Scanner routes
get "/scanner", to: "scanners#index", as: :scanner

20
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
ActiveRecord::Schema[8.0].define(version: 2025_10_18_035415) do
create_table "acquisition_sources", force: :cascade do |t|
t.string "name", null: false
t.text "description"
@@ -49,6 +49,15 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
create_table "conditions", force: :cascade do |t|
t.string "name", null: false
t.text "description"
t.integer "sort_order", default: 0
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_conditions_on_name", unique: true
end
create_table "item_statuses", force: :cascade do |t|
t.string "name", null: false
t.text "description"
@@ -73,7 +82,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
create_table "library_items", force: :cascade do |t|
t.integer "product_id", null: false
t.integer "library_id", null: false
t.string "condition"
t.string "location"
t.text "notes"
t.datetime "date_added", default: -> { "CURRENT_TIMESTAMP" }
@@ -97,7 +105,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
t.boolean "is_favorite", default: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "condition_id"
t.index ["acquisition_source_id"], name: "index_library_items_on_acquisition_source_id"
t.index ["condition_id"], name: "index_library_items_on_condition_id"
t.index ["item_status_id"], name: "index_library_items_on_item_status_id"
t.index ["library_id"], name: "index_library_items_on_library_id"
t.index ["ownership_status_id"], name: "index_library_items_on_ownership_status_id"
@@ -170,6 +180,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
t.string "status", default: "connected"
t.datetime "verified_at"
t.text "last_error"
t.integer "quota_remaining"
t.integer "quota_limit"
t.decimal "quota_percentage"
t.datetime "quota_reset_at"
t.datetime "quota_updated_at"
end
create_table "users", force: :cascade do |t|
@@ -186,6 +201,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_10_11_014729) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "library_items", "acquisition_sources"
add_foreign_key "library_items", "conditions"
add_foreign_key "library_items", "item_statuses"
add_foreign_key "library_items", "libraries"
add_foreign_key "library_items", "ownership_statuses"

View File

@@ -11,6 +11,26 @@ end
puts "Created #{Library.count} libraries: #{Library.pluck(:name).join(', ')}"
# Create default conditions
default_conditions = [
{ name: 'Mint', description: 'Perfect condition, like new', sort_order: 1 },
{ name: 'Like New', description: 'Minimal wear, excellent condition', sort_order: 2 },
{ name: 'Very Good', description: 'Minor wear, still in great shape', sort_order: 3 },
{ name: 'Good', description: 'Normal wear from use', sort_order: 4 },
{ name: 'Fair', description: 'Noticeable wear but fully functional', sort_order: 5 },
{ name: 'Poor', description: 'Heavy wear, may have damage', sort_order: 6 },
{ name: 'Damaged', description: 'Significant damage affecting functionality', sort_order: 7 }
]
default_conditions.each do |condition_attrs|
Condition.find_or_create_by(name: condition_attrs[:name]) do |condition|
condition.description = condition_attrs[:description]
condition.sort_order = condition_attrs[:sort_order]
end
end
puts "Created #{Condition.count} conditions: #{Condition.pluck(:name).join(', ')}"
# Create some sample products for testing
sample_products = [
{

9
package-lock.json generated
View File

@@ -8,7 +8,8 @@
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.16",
"html5-qrcode": "^2.3.8"
"html5-qrcode": "^2.3.8",
"slim-select": "^2.13.1"
},
"devDependencies": {
"esbuild": "^0.25.8"
@@ -534,6 +535,12 @@
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/slim-select": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/slim-select/-/slim-select-2.13.1.tgz",
"integrity": "sha512-0/j1SAYzwaCgb4mWEOIr+QSzznVPEfT/o6FHAK3yk9qZTM+79DC/J3YRZz7lC4JvteZTzxoGXxPF6CAG7ezjyg==",
"license": "MIT"
}
}
}

View File

@@ -8,7 +8,8 @@
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"@hotwired/turbo-rails": "^8.0.16",
"html5-qrcode": "^2.3.8"
"html5-qrcode": "^2.3.8",
"slim-select": "^2.13.1"
},
"devDependencies": {
"esbuild": "^0.25.8"