Skip to content
This repository has been archived by the owner on Sep 2, 2024. It is now read-only.

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
gdonald committed Dec 9, 2023
1 parent 4d1968b commit cde150b
Show file tree
Hide file tree
Showing 19 changed files with 301 additions and 47 deletions.
3 changes: 3 additions & 0 deletions .env → .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ SITE_NAME="ELDAC"
USER_AGENT="ELDAC/1.0 (https://eldac.io)"
HOST_THROTTLE_SECONDS=900
HOST_RULE_DEFAULT="deny"
RSA_ALGO="RS256"
RSA_PRIV_KEY=""
RSA_PUB_KEY=""
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@
!/app/assets/builds/.keep

/node_modules
.env
20 changes: 20 additions & 0 deletions app/admin/client_addresses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# :nocov:
ActiveAdmin.register ClientAddress do
menu parent: 'Remote', priority: 1

permit_params :client_id, :value, :active

index do
selectable_column
id_column
column :client
column :value
column :active
column :created_at
column :updated_at
actions
end
end
# :nocov:
20 changes: 20 additions & 0 deletions app/admin/server_addresses.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# :nocov:
ActiveAdmin.register ServerAddress do
menu parent: 'Remote', priority: 1

permit_params :server_id, :value, :active

index do
selectable_column
id_column
column :server
column :value
column :active
column :created_at
column :updated_at
actions
end
end
# :nocov:
7 changes: 6 additions & 1 deletion app/controllers/api/searches_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,14 @@

module Api
class SearchesController < ApplicationController
skip_before_action :verify_authenticity_token

def show
respond_to do |format|
format.json { @pages = PageService.new(params).search }
format.json do
data = RequestDecoderService.new(request).decode
@pages = PageService.new(data).search
end
format.html { redirect_to root_path }
end
end
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/remote_search_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

class RemoteSearchController < ApplicationController
def index
@pages = RemoteSearchService.search(params[:q])
@pages = RemoteSearchService.new(params[:q]).search
render layout: nil
end
end
4 changes: 4 additions & 0 deletions app/models/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,8 @@ def self.ransackable_attributes(_auth_object = nil)
def self.ransackable_associations(_auth_object = nil)
%w[client_addresses]
end

def pub_key
OpenSSL::PKey.read(public_key.gsub('\\n', "\n"))
end
end
6 changes: 5 additions & 1 deletion app/models/remote_page.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# frozen_string_literal: true

RemotePage = Struct.new(:title, :blurb, :content, :url)
RemotePage = Struct.new(:title, :blurb, :content, :url) do
def to_partial_path
'pages/page'
end
end
32 changes: 32 additions & 0 deletions app/models/request_url.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# frozen_string_literal: true

class RequestUrl
attr_reader :address

def initialize
# TODO: Make this dynamic, distributed
server = Server.first
@address = server&.server_addresses&.first
end

def url
return unless scheme && address

"#{scheme.name}://#{address.value}#{port}/api/search"
end

def scheme
name = Rails.env.production? ? 'https' : 'http'
Scheme.find_by(name:)
rescue StandardError
logger.error("Invalid scheme name '#{name}'")
end

def port
Rails.env.production? ? '' : ':3000'
end

def self.url
RequestUrl.new.url
end
end
54 changes: 30 additions & 24 deletions app/services/remote_search_service.rb
Original file line number Diff line number Diff line change
@@ -1,36 +1,42 @@
# frozen_string_literal: true

module RemoteSearchService
include Headers
class RemoteSearchService
attr_reader :term, :url

def self.search(term) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
server = Server.first
return Page.none unless server

address = server.server_addresses.first
return Page.none unless address

url = "#{scheme.name}://#{address.value}#{port}/api/search?q=#{term}"
def initialize(term)
@term = term
@url = RequestUrl.url
end

response = HTTParty.get(url, { headers: })
json = JSON.parse(response.body)
def search
return [] unless json && json['pages']

pages = []
json['pages'].each do |page|
pages << RemotePage.new(title: page['title'], blurb: page['blurb'], content: page['content'], url: page['url'])
json['pages'].map do |page|
RemotePage.new(title: page['title'], blurb: page['blurb'], content: page['content'], url: page['url'])
end
end

pages
private

def request
RequestEncoderService.new(term).encode
# rescue StandardError
# Rails.logger.error('Failed to encode request')
# nil
end

class << self
def scheme
name = Rails.env.production? ? 'https' : 'http'
Scheme.find_by(name:)
end
def response
r = request
HTTParty.post(url, body: r[:body], headers: r[:headers])
# rescue StandardError
# Rails.logger.error('Remote request failed')
# nil
end

def port
Rails.env.production? ? '' : ':3000'
end
def json
JSON.parse(response.body)
# rescue StandardError
# Rails.logger.error('Failed to parse response body')
# nil
end
end
38 changes: 38 additions & 0 deletions app/services/request_decoder_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# frozen_string_literal: true

class RequestDecoderService
attr_reader :request

def initialize(request)
@request = request
end

def decode
client = find_client_by_ip_addr
return {} unless client

decode_request(client)
end

private

def decode_request(client)
params = request.params
return {} unless params[:data] && params[:algorithm]

begin
data = JWT.decode(params[:data], client.pub_key, true, { algorithm: params[:algorithm] })
{ q: data.first['q'] }
rescue JWT::DecodeError
{}
end
end

def find_client_by_ip_addr
client_address = ClientAddress.find_by(value: request.remote_ip)
return unless client_address&.active?

client = client_address.client
client if client.active?
end
end
39 changes: 39 additions & 0 deletions app/services/request_encoder_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

class RequestEncoderService
include Headers

attr_reader :term, :private_key, :algorithm

def initialize(term, private_key: nil, algorithm: nil)
@term = term
@private_key = private_key || ENV.fetch('RSA_PRIVATE_KEY', nil)
@algorithm = algorithm || ENV.fetch('RSA_ALGO', nil)
end

def encode
Rails.logger.error('Invalid RSA private key value') unless private_key
Rails.logger.error('Invalid RSA algorithm value') unless algorithm

return unless private_key && algorithm

body = { data:, algorithm: }.to_json
headers = self.headers.merge({ 'Content-Type' => 'application/json' })

{ body:, headers: }
end

private

def pkey
OpenSSL::PKey::RSA.new(private_key)
# rescue StandardError
# logger.error('Invalid RSA private key value')
end

def data
JWT.encode({ q: term }, pkey, algorithm)
# rescue StandardError
# logger.error('Failed to encode JWT data')
end
end
File renamed without changes.
5 changes: 1 addition & 4 deletions app/views/remote_search/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
<% if @pages&.any? %>
<p>Remote search results for "<%= params[:q] %>":</p>

<% @pages.each do |page| %>
<%= render partial: 'shared/page', locals: { page: page } %>
<% end %>
<%= render @pages %>
<% else %>
<p>No remote search results found for "<%= params[:q] %>".</p>
<% end %>
5 changes: 1 addition & 4 deletions app/views/searches/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@
<div class="container mx-auto mt-3 pb-5">
<% if @pages&.any? %>
<p>Search results for "<%= params[:q] %>":</p>

<% @pages.each do |page| %>
<%= render partial: 'shared/page', locals: { page: page } %>
<% end %>
<%= render @pages %>

<div class="mt-4">
<% if params[:page] %>
Expand Down
2 changes: 1 addition & 1 deletion config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
get 'up' => 'rails/health#show', as: :rails_health_check

namespace :api do
resource :search, only: %i[show]
post :search, to: 'searches#show'
end

resources :remote_search, only: %i[index]
Expand Down
1 change: 1 addition & 0 deletions spec/factories/clients.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
sequence(:name) { |n| "Client #{n}" }
requests_per_hour { 1000 }
active { true }
public_key { OpenSSL::PKey::RSA.new(2048).public_key.to_s }
end
end
56 changes: 50 additions & 6 deletions spec/requests/api/searches_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,58 @@

RSpec.describe 'Api::Searches' do
describe 'GET /index' do
it 'returns http success' do
get '/api/search', params: { q: 'foo', format: :json }
expect(response).to have_http_status(:success)
let(:json) { response.parsed_body }

context 'with no client' do
it 'returns http success' do
post '/api/search', params: { q: 'foo', format: :json }

aggregate_failures do
expect(response).to have_http_status(:success)
expect(json['pages']).to be_empty
end
end

it 'redirects html requests' do
post '/api/search', params: { q: 'foo', format: :html }
expect(response).to redirect_to(root_path)
end
end

it 'redirects html requests' do
get '/api/search', params: { q: 'foo', format: :html }
expect(response).to redirect_to(root_path)
context 'with a client' do # rubocop:disable RSpec/MultipleMemoizedHelpers
let(:client_address) { create(:client_address) }
let(:client) { client_address.client }
let(:data) { 'data' }
let(:pkey) { instance_double(OpenSSL::PKey::RSA) }
let(:private_key) { 'private_key' }
let(:algorithm) { 'RS256' }

before do
client_address

allow_any_instance_of(ActionDispatch::Request) # rubocop:disable RSpec/AnyInstance
.to receive(:remote_addr).and_return(client_address.value)

allow(ENV).to receive(:fetch).with('RSA_PRIV_KEY', nil).and_return(private_key)
allow(ENV).to receive(:fetch).with('RSA_ALGO', nil).and_return(algorithm)

allow(OpenSSL::PKey::RSA).to receive(:new).with(private_key).and_return(pkey)

allow(pkey).to receive(:sign)

allow(JWT).to receive(:encode).and_return(data)

allow(JWT).to receive(:decode).and_return([{ 'q' => 'foo' }, { 'alg' => algorithm }])
end

it 'returns http success' do
post '/api/search', params: { data:, format: :json }

aggregate_failures do
expect(response).to have_http_status(:success)
expect(json['pages']).to be_empty
end
end
end
end
end
Loading

0 comments on commit cde150b

Please sign in to comment.