Skip to content

Commit 6fb267d

Browse files
committed
feat: implement hashID URL obfuscation/shortening
1 parent d152b18 commit 6fb267d

7 files changed

Lines changed: 208 additions & 3 deletions

File tree

Gemfile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ gem 'elasticsearch', '~> 9.1', '>= 9.1.3'
8181
# LLM Integration for Support Chatbot
8282
gem 'ruby-openai', '~> 7.0'
8383

84+
# HashID for URL obfuscation and shortening
85+
gem 'hashid-rails', '~> 1.0'
86+
8487
group :development, :test do
8588
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
8689
gem 'debug', platforms: %i[mri mingw x64_mingw]

Gemfile.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ GEM
145145
globalid (1.3.0)
146146
activesupport (>= 6.1)
147147
hashdiff (1.2.1)
148+
hashid-rails (1.4.1)
149+
activerecord (>= 4.0)
150+
hashids (~> 1.0)
151+
hashids (1.0.6)
148152
i18n (1.14.7)
149153
concurrent-ruby (~> 1.0)
150154
io-console (0.8.1)
@@ -419,6 +423,7 @@ DEPENDENCIES
419423
faker
420424
faraday
421425
faraday-retry
426+
hashid-rails (~> 1.0)
422427
jwt
423428
kamal (~> 2.0)
424429
kaminari

app/models/concerns/hashable.rb

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
# frozen_string_literal: true
2+
3+
# Hashable Concern
4+
# Adds HashID encoding/decoding capabilities to ActiveRecord models with UUID primary keys
5+
#
6+
# @see config/initializers/hashid.rb
7+
8+
module Hashable
9+
extend ActiveSupport::Concern
10+
11+
included do
12+
def hashid
13+
return nil if id.blank?
14+
15+
numeric_id = uuid_to_numeric(id)
16+
17+
hashids_instance.encode(numeric_id)
18+
rescue StandardError => e
19+
Rails.logger.error "[HASHID] Failed to encode #{self.class.name}##{id}: #{e.message}"
20+
Rails.logger.error e.backtrace.first(3).join("\n")
21+
nil
22+
end
23+
24+
# Alternative shorter method name
25+
alias_method :to_hashid, :hashid
26+
27+
# @example
28+
# vod.public_hashid_url # => "https://prostaff.gg/vod-reviews/Zx1U3mA7caXq"
29+
def public_hashid_url
30+
return nil unless hashid.present?
31+
return nil unless ENV['FRONTEND_URL'].present?
32+
33+
"#{ENV['FRONTEND_URL']}/#{self.class.name.underscore.pluralize}/#{hashid}"
34+
end
35+
36+
private
37+
38+
# Get Hashids instance with proper configuration
39+
# @return [Hashids] Configured Hashids instance
40+
def hashids_instance
41+
salt = ENV.fetch('HASHID_SALT', 'development_fallback_salt')
42+
min_length = ENV.fetch('HASHID_MIN_LENGTH', '6').to_i
43+
alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
44+
45+
full_salt = "#{salt}#{self.class.table_name}"
46+
47+
Hashids.new(full_salt, min_length, alphabet)
48+
end
49+
50+
# Convert UUID to numeric value
51+
# @param uuid [String] UUID string (e.g., "5c8d9b3e-3155-4871-a419-e72ad5f21c19")
52+
# @return [Integer] Numeric representation (128-bit integer)
53+
def uuid_to_numeric(uuid)
54+
uuid.delete('-').to_i(16)
55+
end
56+
end
57+
58+
class_methods do
59+
# Find record by HashID
60+
# @param hashid [String] The HashID to decode
61+
# @return [ActiveRecord::Base, nil] The found record or nil
62+
# @example
63+
# VodReview.find_by_hashid("mA7cXq") # => #<VodReview:0x00...>
64+
def find_by_hashid(hashid)
65+
return nil if hashid.blank?
66+
67+
numeric_id = hashids_instance.decode(hashid).first
68+
return nil if numeric_id.nil?
69+
70+
uuid = numeric_to_uuid(numeric_id)
71+
find_by(id: uuid)
72+
rescue StandardError => e
73+
Rails.logger.error "[HASHID] Failed to decode hashid '#{hashid}' for #{name}: #{e.message}"
74+
Rails.logger.error e.backtrace.first(3).join("\n")
75+
nil
76+
end
77+
78+
def find_by_hashid!(hashid)
79+
find_by_hashid(hashid) or raise ActiveRecord::RecordNotFound, "Couldn't find #{name} with hashid=#{hashid}"
80+
end
81+
82+
private
83+
84+
# Get Hashids instance with proper configuration (class method)
85+
# @return [Hashids] Configured Hashids instance
86+
def hashids_instance
87+
salt = ENV.fetch('HASHID_SALT', 'development_fallback_salt')
88+
min_length = ENV.fetch('HASHID_MIN_LENGTH', '6').to_i
89+
alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
90+
91+
# Add table name as pepper
92+
full_salt = "#{salt}#{table_name}"
93+
94+
Hashids.new(full_salt, min_length, alphabet)
95+
end
96+
97+
# Convert numeric value to UUID string
98+
# @param numeric [Integer] Numeric representation of UUID (128-bit integer)
99+
# @return [String] UUID string (e.g., "5c8d9b3e-3155-4871-a419-e72ad5f21c19")
100+
def numeric_to_uuid(numeric)
101+
# Convert to hex and pad to 32 characters
102+
hex = numeric.to_s(16).rjust(32, '0')
103+
104+
# Format as UUID: 8-4-4-4-12
105+
"#{hex[0..7]}-#{hex[8..11]}-#{hex[12..15]}-#{hex[16..19]}-#{hex[20..31]}"
106+
end
107+
end
108+
end

app/models/vod_review.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
class VodReview < ApplicationRecord
3434
include OrganizationScoped
3535
include Constants
36+
include Hashable
3637

3738
# Associations
3839
belongs_to :organization
@@ -148,9 +149,21 @@ def share_with_all_players!
148149
end
149150

150151
def public_url
151-
return nil unless is_public? && share_link.present?
152+
return nil unless is_public?
152153

153-
"#{ENV['FRONTEND_URL']}/vod-reviews/#{share_link}"
154+
# Use HashID if available, fallback to share_link
155+
identifier = hashid.presence || share_link
156+
return nil if identifier.blank?
157+
158+
"#{ENV['FRONTEND_URL']}/vod-reviews/#{identifier}"
159+
end
160+
161+
# Override from Hashable concern to use proper route
162+
def public_hashid_url
163+
return nil unless hashid.present?
164+
return nil unless ENV['FRONTEND_URL'].present?
165+
166+
"#{ENV['FRONTEND_URL']}/vod-reviews/#{hashid}"
154167
end
155168

156169
private

app/modules/vod_reviews/controllers/vod_reviews_controller.rb

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,19 @@ def destroy
125125
private
126126

127127
def set_vod_review
128-
@vod_review = organization_scoped(VodReview).find(params[:id])
128+
# Try to find by HashID first, then fall back to UUID
129+
id_param = params[:id]
130+
131+
@vod_review = if id_param.match?(/\A[a-zA-Z0-9]{6,12}\z/)
132+
# Looks like a HashID (Base62, 6-12 chars)
133+
VodReview.find_by_hashid(id_param)
134+
else
135+
# Looks like a UUID or numeric ID
136+
organization_scoped(VodReview).find_by(id: id_param)
137+
end
138+
139+
# If not found, raise 404
140+
raise ActiveRecord::RecordNotFound, "Couldn't find VodReview with id=#{id_param}" if @vod_review.nil?
129141
end
130142

131143
def vod_review_params

app/serializers/vod_review_serializer.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,21 @@ class VodReviewSerializer < Blueprinter::Base
1111
:status, :tags, :metadata,
1212
:created_at, :updated_at
1313

14+
# HashID for short, obfuscated public URLs
15+
field :hashid do |vod_review|
16+
vod_review.hashid
17+
end
18+
19+
# Public URL using HashID (preferred) or share_link (fallback)
20+
field :public_url do |vod_review|
21+
vod_review.public_url
22+
end
23+
24+
# Direct HashID URL (for explicit HashID usage)
25+
field :public_hashid_url do |vod_review|
26+
vod_review.public_hashid_url
27+
end
28+
1429
field :timestamps_count do |vod_review, options|
1530
options[:include_timestamps_count] ? vod_review.vod_timestamps.count : nil
1631
end

config/initializers/hashid.rb

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# frozen_string_literal: true
2+
3+
# HashID Configuration for URL Obfuscation
4+
#
5+
# This initializer configures Hashid::Rails for generating short, obfuscated URLs
6+
# for public-facing resources like VOD Reviews, Draft Plans, and Tactical Boards.
7+
#
8+
# @see https://github.com/jcypret/hashid-rails
9+
10+
require 'hashid/rails'
11+
12+
Hashid::Rails.configure do |config|
13+
# Salt: MUST be set via ENV for security
14+
salt = ENV.fetch('HASHID_SALT') do
15+
if Rails.env.production?
16+
raise 'HASHID_SALT environment variable must be set in production!'
17+
else
18+
Rails.logger.warn '[HASHID] Using fallback salt in development. Set HASHID_SALT for production!'
19+
'development_fallback_salt'
20+
end
21+
end
22+
config.salt = salt
23+
24+
# Minimum length of HashIDs
25+
# Lower = shorter URLs (e.g., 6 = "aBcD3f")
26+
# Higher = more obfuscation (e.g., 12 = "aBcD3fGhIjKl")
27+
min_length = ENV.fetch('HASHID_MIN_LENGTH') do
28+
if Rails.env.production?
29+
Rails.logger.warn '[HASHID] HASHID_MIN_LENGTH not set, using default: 6'
30+
'6'
31+
else
32+
'6'
33+
end
34+
end
35+
config.min_hash_length = min_length.to_i
36+
37+
# Alphabet: Use Base62 by default (a-z, A-Z, 0-9)
38+
config.alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
39+
end
40+
41+
Rails.application.config.after_initialize do
42+
min_length = ENV.fetch('HASHID_MIN_LENGTH', '6')
43+
salt = ENV.fetch('HASHID_SALT', 'development_fallback_salt')
44+
45+
Rails.logger.info '[HASHID] Initialized with:'
46+
Rails.logger.info " - Salt: #{salt[0..2]}*** (hidden)"
47+
Rails.logger.info " - Min Length: #{min_length}"
48+
Rails.logger.info " - Alphabet: Base62 (a-z, A-Z, 0-9)"
49+
end

0 commit comments

Comments
 (0)