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
70 changes: 70 additions & 0 deletions app/concerns/master_only_redirect.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
module MasterOnlyRedirect
extend ActiveSupport::Concern

included do
class_attribute :master_only_actions, default: []
end

class_methods do
def redirect_to_master_only(*actions)
self.master_only_actions = actions.map(&:to_s)
end
end

def redirect_to_master_unless_master
return if master?
return if master_url.blank?

# Preserve full path and query; use 307 so POST is replayed on master
url = "#{master_redirect_base_url}#{request.fullpath}"
redirect_to url, status: :temporary_redirect, allow_other_host: true
end

# Redirects to master when Postems are only stored there. Replicas MUST have
# freebmd_master_url set; otherwise master? is true and no redirect occurs.
def redirect_to_master_for_postem_display_if_not_master
return if master?
return if master_url.blank?

redirect_to "#{master_redirect_base_url}#{request.fullpath}", allow_other_host: true
end


def master_redirect_base_url
@master_redirect_base_url ||= begin
uri = URI.parse(master_url)
uri.user = nil
uri.password = nil
uri.to_s.chomp('@').chomp('/')
end
rescue URI::InvalidURIError
master_url.to_s.chomp('/')
end

# Returns true if the record has Postems (via record_flag & POSTEM). Use when deciding whether to redirect to master.
def record_has_postems?(record)
return false unless record
return false unless record.respond_to?(:record_flag)
record_flag = record.record_flag
return false unless record_flag.respond_to?(:nonzero?)
(record_flag & ::Constant::POSTEM).nonzero?
end

def master?
master_flag = Rails.application.config.respond_to?(:master) ? Rails.application.config.master : ENV['MASTER']
return true if master_flag.present? && master_flag.to_s == '1'
return true if master_url.blank? # no config => single server

my_host = request.host
master_host = URI.parse(master_url).host rescue nil
return true if master_host.blank?

my_host == master_host
end

def master_url
@master_url ||= (
Rails.application.config.respond_to?(:freebmd_master_url) ? Rails.application.config.freebmd_master_url : nil
).presence || ENV['FREEBMD_MASTER_URL'].presence
end
end
12 changes: 11 additions & 1 deletion app/controllers/best_guess_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class BestGuessController < ApplicationController
include MasterOnlyRedirect

before_action :viewed
skip_before_action :require_login

Expand All @@ -23,7 +25,12 @@ def show
flash[:notice] = 'The record you requested does not exist.'
redirect_back(fallback_location: root_path) && return
end


# Only master has Postem rows; if this record has postems, serve from master
if record_has_postems?(@current_record)
redirect_to_master_for_postem_display_if_not_master and return
end

@spouse_record = @current_record.get_spouse_record
@postems_count = @current_record&.postems_list&.count || 0
page_entries = @current_record.entries_in_the_page
Expand Down Expand Up @@ -66,6 +73,9 @@ def show_marriage
record_number = params[:entry_id]
@search_id = params[:search_id] if @search
@current_record = BestGuess.where(RecordNumber: record_number).first
if @current_record && record_has_postems?(@current_record)
redirect_to_master_for_postem_display_if_not_master and return
end
@spouse_record = @current_record.get_spouse_record
#show_scans
show_postem_or_scan
Expand Down
102 changes: 80 additions & 22 deletions app/controllers/postems_controller.rb
Original file line number Diff line number Diff line change
@@ -1,38 +1,96 @@
# updated postems controller that delegates to freebmd1 perl api
# this ensures postems are created through the perl codebase's single source of truth
# preserving all logging, validation, and database update mechanisms

class PostemsController < ApplicationController
include MasterOnlyRedirect

skip_before_action :require_login
require 'rails_autolink'
before_action :redirect_to_master_unless_master, only: [:create]

def create
unless spam_alert(postem_params[:honeypot])
@postem = Postem.new(postem_params.delete_if { |_k, v| v.blank? })
@record = BestGuessHash.where(Hash: postem_params[:Hash]).first.best_guess
@search_query = SearchQuery.where(id: params[:search_query]).first
@postem.QuarterNumberEvent = (@record.QuarterNumber * 3) + @record.RecordTypeID
@postem.RecordInfo = "#{@record.Surname}|#{@record.GivenName}|#{@record.AgeAtDeath}#{@record.AssociateName}|#{@record.District}|#{@record.Volume}|#{@record.Page}"
@postem.SourceInfo = request.remote_ip
@postem.Created = Time.now.strftime('%s')
if @postem.save
flash[:notice] = "Added Postem successfully"
else
flash[:notice] = "Unsuccessful. Please Retry"
redirect_to :back
end
if @search_query.present?
redirect_to friendly_bmd_record_details_path(@search_query.id,@record.RecordNumber, @record.friendly_url)
return if spam_detected?(postem_params[:honeypot])

if postem_hash_blocked?(postem_params[:Hash])
flash[:notice] = "Postems cannot be added for this record."
redirect_back(fallback_location: root_path) && return
end

best_guess_hash = BestGuessHash.find_by(Hash: postem_params[:Hash])
unless best_guess_hash
flash[:notice] = "Record not found."
redirect_back(fallback_location: root_path) && return
end

@record = best_guess_hash.best_guess
@search_query = SearchQuery.find_by(id: params[:search_query])

# delegate to freebmd1 perl api
service = FreebmdPostemService.new
source_info = "MyopicVicar submission from: #{request.remote_ip}"

# check for dry-run mode (for testing/preview)
dry_run = params[:dry_run].present? || postem_params[:dry_run].present?

begin
response = service.create_postem(
record: @record,
information: postem_params[:Information],
source_info: source_info,
dry_run: dry_run
)

if response[:dry_run]
flash[:notice] = "Preview: #{response[:message]} No data was saved."
redirect_back(fallback_location: root_path)
else
redirect_to friendly_bmd_record_details_non_search_path(@record.RecordNumber, @record.friendly_url)
flash[:notice] = "Added Postem successfully. #{response[:note]}"
redirect_to postem_success_redirect_path
end

rescue FreebmdPostemService::ValidationError => e
flash[:notice] = "Validation error: #{e.message}"
redirect_back(fallback_location: root_path)

rescue FreebmdPostemService::AuthenticationError => e
Rails.logger.error("FreeBMD API authentication failed: #{e.message}")
flash[:notice] = "System error: unable to create postem. Please try again later."
redirect_back(fallback_location: root_path)

rescue FreebmdPostemService::PostemCreationError => e
Rails.logger.error("FreeBMD API error: #{e.message}")
flash[:notice] = "Error creating postem: #{e.message}"
redirect_back(fallback_location: root_path)

rescue StandardError => e
Rails.logger.error("Unexpected error creating postem: #{e.message}\n#{e.backtrace.join("\n")}")
flash[:notice] = "System error: unable to create postem. Please try again later."
redirect_back(fallback_location: root_path)
end
end

def spam_alert honeypot
private

def spam_detected?(honeypot)
honeypot.present?
end

private
def postem_hash_blocked?(hash_value)
return false if hash_value.blank?
blocked = Rails.application.config.respond_to?(:postem_blocked_hashes) ?
Rails.application.config.postem_blocked_hashes : []
blocked.is_a?(Array) && blocked.include?(hash_value.to_s)
end

def postem_success_redirect_path
if @search_query.present?
friendly_bmd_record_details_path(@search_query.id, @record.RecordNumber, @record.friendly_url)
else
friendly_bmd_record_details_non_search_path(@record.RecordNumber, @record.friendly_url)
end
end

def postem_params
params.require(:postem).permit!
end

end
end
31 changes: 30 additions & 1 deletion app/models/freebmd/postem.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
class Postem < FreebmdDbBase
self.pluralize_table_names = false
self.pluralize_table_names = false
self.table_name = 'Postems'
belongs_to :best_guess_hash, foreign_key: 'Hash', primary_key: 'Hash', class_name: '::BestGuessHash'

MAX_INFORMATION_LENGTH = 250

before_validation :truncate_information_to_max_length
validates :Information, presence: true, length: { maximum: MAX_INFORMATION_LENGTH }
validate :information_contains_space_or_newline

# Reject duplicate content for same record (same Hash + Information) like FreeBMD1
validate :no_duplicate_postem, on: :create

private

def truncate_information_to_max_length
return if self['Information'].blank?
self['Information'] = self['Information'].to_s[0, MAX_INFORMATION_LENGTH]
end

def information_contains_space_or_newline
return if self['Information'].blank?
return if self['Information'].to_s =~ /\s/
errors.add(:Information, 'must contain at least one space or newline')
end

def no_duplicate_postem
return if self['Hash'].blank? || self['Information'].blank?
normalized = self['Information'].to_s.strip
return unless Postem.where(Hash: self['Hash']).exists?(['TRIM(Information) = ?', normalized])
errors.add(:base, 'A postem with this content already exists for this record')
end
end
135 changes: 135 additions & 0 deletions app/services/freebmd_postem_service.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
# service to create postems via freebmd1 perl api
# delegates postem creation to perl codebase to ensure single source of truth
# for postem logging, validation, and database updates

class FreebmdPostemService
class PostemCreationError < StandardError; end
class AuthenticationError < StandardError; end
class ValidationError < StandardError; end

def initialize
@api_endpoint = ENV.fetch('FREEBMD_POSTEM_API_URL', 'https://www.freebmd.org.uk/api/create-postem.pl')
@api_key = ENV.fetch('FREEBMD_API_KEY', nil)
@timeout = 10 # seconds
end

# create a postem via freebmd1 perl api
# @param record [BestGuess] the bestguess record to attach postem to
# @param information [String] the postem text (max 250 chars)
# @param source_info [String] optional source information (e.g., ip address)
# @param dry_run [Boolean] if true, validate but don't create (returns 412)
# @return [Hash] response from api with :success, :message, :error, :dry_run
def create_postem(record:, information:, source_info: nil, dry_run: false)
validate_inputs!(record, information)

database_name = get_database_name
record_number = record.RecordNumber
hash = get_record_hash(record)

payload = {
database: database_name,
record_number: record_number,
hash: hash,
information: information.to_s.strip[0, 250], # truncate to max length
source_info: source_info || '',
dry_run: dry_run
}

response = call_api(payload)

# handle dry-run response (412)
if response[:dry_run]
Rails.logger.info("Dry-run postem validation passed for record #{record_number}")
return response
end

if response[:success]
response
else
raise ValidationError, response[:error] if response[:code] == 422
raise PostemCreationError, response[:error]
end
rescue Timeout::Error
raise PostemCreationError, 'API request timed out'
rescue StandardError => e
Rails.logger.error("FreeBMD Postem API error: #{e.message}")
raise
end

private

def validate_inputs!(record, information)
raise ArgumentError, 'record cannot be nil' unless record
raise ArgumentError, 'information cannot be blank' if information.blank?
raise ArgumentError, 'information must contain at least one space' unless information.to_s =~ /\s/
raise ArgumentError, 'information too long (max 250 chars)' if information.to_s.length > 250
end

def get_database_name
Postem.connection.current_database
end

def get_record_hash(record)
# use bestguesshash table if available
best_guess_hash = record.best_guess_hash
return best_guess_hash.Hash if best_guess_hash.present?

# fallback: compute hash manually (should match perl logic)
record.record_hash
end

def call_api(payload)
require 'net/http'
require 'json'

uri = URI(@api_endpoint)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = (uri.scheme == 'https')
http.open_timeout = @timeout
http.read_timeout = @timeout

request = Net::HTTP::Post.new(uri.path, {
'Content-Type' => 'application/json',
'X-FreeBMD-API-Key' => @api_key
})
request.body = payload.to_json

response = http.request(request)

case response.code.to_i
when 200
JSON.parse(response.body, symbolize_names: true)
when 401
raise AuthenticationError, 'Invalid API key'
when 404
raise PostemCreationError, 'Record not found in FreeBMD database'
when 412
# dry-run mode: validation passed but not created
JSON.parse(response.body, symbolize_names: true)
when 422
# validation error from perl api
data = JSON.parse(response.body, symbolize_names: true)
{ success: false, error: data[:error], code: 422 }
else
data = JSON.parse(response.body, symbolize_names: true) rescue {}
raise PostemCreationError, "API error (#{response.code}): #{data[:error] || response.body}"
end
rescue JSON::ParserError => e
raise PostemCreationError, "Invalid API response: #{e.message}"
end

# dead code - don't alter the BestGuess postem flag ourselves - the cron will do it
def set_postem_flag_on_record(record)
raise PostemCreationError, "Not updating local Postem flag"

new_confirmed = (record.Confirmed.to_i | BestGuess::ENTRY_POSTEM)
record.update_column(:Confirmed, new_confirmed)

# also update marriage record if present
marriage = BestGuessMarriage.find_by(RecordNumber: record.RecordNumber)
marriage&.update_column(:Confirmed, (marriage.Confirmed.to_i | BestGuess::ENTRY_POSTEM))
rescue => e
Rails.logger.warn("Failed to update Confirmed flag locally: #{e.message}")
# non-fatal: perl's updatepostems.pl cron will fix it within 24 hours
end
end
Loading