encoded_id 1.0.0.rc4 → 1.0.0.rc6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ # EncodedId Ruby Gem - Technical Documentation
2
+
3
+ ## Overview
4
+
5
+ `encoded_id` is a Ruby gem that provides reversible obfuscation of numerical and hexadecimal IDs into human-readable strings suitable for use in URLs. It offers a secure way to hide sequential database IDs from users while maintaining the ability to decode them back to their original values.
6
+
7
+ ## Key Features
8
+
9
+ - **Reversible Encoding**: Unlike UUIDs, encoded IDs can be decoded back to their original numeric values
10
+ - **Multiple ID Support**: Encode multiple numeric IDs in a single string
11
+ - **Algorithm Choice**: Supports both HashIds and Sqids encoding algorithms
12
+ - **Human-Readable Format**: Character grouping and configurable separators for better readability
13
+ - **Character Mapping**: Handles easily confused characters (0/O, 1/I/l) through equivalence mapping
14
+ - **Performance Optimized**: Uses an optimized HashIds implementation for better performance
15
+ - **Profanity Protection**: Built-in blocklist support to prevent offensive words in generated IDs
16
+ - **Customizable**: Configurable alphabets, lengths, and formatting options
17
+
18
+ ## Core API
19
+
20
+ ### EncodedId::ReversibleId
21
+
22
+ The main class for encoding and decoding IDs.
23
+
24
+ #### Constructor
25
+
26
+ ```ruby
27
+ EncodedId::ReversibleId.new(
28
+ salt:, # Required: String salt (min 4 chars)
29
+ length: 8, # Minimum length of encoded string
30
+ split_at: 4, # Split encoded string every X characters
31
+ split_with: "-", # Character to split with
32
+ alphabet: EncodedId::Alphabet.modified_crockford,
33
+ hex_digit_encoding_group_size: 4,
34
+ max_length: 128, # Maximum length limit
35
+ max_inputs_per_id: 32, # Maximum IDs to encode together
36
+ encoder: :hashids, # :hashids or :sqids
37
+ blocklist: nil # Words to prevent in IDs
38
+ )
39
+ ```
40
+
41
+ #### Key Methods
42
+
43
+ ##### encode(values)
44
+ Encodes one or more integer IDs into an obfuscated string.
45
+
46
+ ```ruby
47
+ coder = EncodedId::ReversibleId.new(salt: "my-salt")
48
+
49
+ # Single ID
50
+ coder.encode(123) # => "p5w9-z27j"
51
+
52
+ # Multiple IDs
53
+ coder.encode([78, 45]) # => "z2j7-0dmw"
54
+ ```
55
+
56
+ ##### decode(encoded_id, downcase: true)
57
+ Decodes an encoded string back to original IDs.
58
+
59
+ ```ruby
60
+ coder.decode("p5w9-z27j") # => [123]
61
+ coder.decode("z2j7-0dmw") # => [78, 45]
62
+
63
+ # Handles confused characters
64
+ coder.decode("p5w9-z27J") # => [123]
65
+ ```
66
+
67
+ ##### encode_hex(hex_strings) (Experimental)
68
+ Encodes hexadecimal strings (like UUIDs).
69
+
70
+ ```ruby
71
+ # Encode UUID
72
+ coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
73
+ # => "5jjy-c8d9-hxp2-qsve-rgh9-rxnt-7nb5-tve7-bf84-vr"
74
+
75
+ # With larger group size for shorter output
76
+ coder = EncodedId::ReversibleId.new(
77
+ salt: "my-salt",
78
+ hex_digit_encoding_group_size: 32
79
+ )
80
+ coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
81
+ # => "vr7m-qra8-m5y6-dkgj-5rqr-q44e-gp4a-52"
82
+ ```
83
+
84
+ ##### decode_hex(encoded_id, downcase: true) (Experimental)
85
+ Decodes back to hexadecimal strings.
86
+
87
+ ```ruby
88
+ coder.decode_hex("w72a-y0az") # => ["10f8c"]
89
+ ```
90
+
91
+ ### EncodedId::Alphabet
92
+
93
+ Class for creating custom alphabets.
94
+
95
+ #### Predefined Alphabets
96
+
97
+ ```ruby
98
+ # Default: modified Crockford Base32
99
+ # Characters: "0123456789abcdefghjkmnpqrstuvwxyz"
100
+ # Excludes: i, l, o, u (easily confused)
101
+ # Equivalences: {"o"=>"0", "i"=>"j", "l"=>"1", ...}
102
+ EncodedId::Alphabet.modified_crockford
103
+ ```
104
+
105
+ #### Custom Alphabets
106
+
107
+ ```ruby
108
+ # Simple custom alphabet
109
+ alphabet = EncodedId::Alphabet.new("0123456789abcdef")
110
+
111
+ # With character equivalences
112
+ alphabet = EncodedId::Alphabet.new(
113
+ "0123456789ABCDEF",
114
+ {"a"=>"A", "b"=>"B", "c"=>"C", "d"=>"D", "e"=>"E", "f"=>"F"}
115
+ )
116
+
117
+ # Greek alphabet example
118
+ alphabet = EncodedId::Alphabet.new("αβγδεζηθικλμνξοπρστυφχψω")
119
+ coder = EncodedId::ReversibleId.new(salt: "my-salt", alphabet: alphabet)
120
+ coder.encode(123) # => "θεαψ-ζκυο"
121
+ ```
122
+
123
+ ## Configuration Options
124
+
125
+ ### Basic Options
126
+
127
+ - **salt**: Required secret salt (minimum 4 characters). Changing the salt changes all encoded IDs
128
+ - **length**: Minimum length of encoded string (default: 8)
129
+ - **max_length**: Maximum allowed length (default: 128) to prevent DoS attacks
130
+ - **max_inputs_per_id**: Maximum IDs encodable together (default: 32)
131
+
132
+ ### Encoder Selection
133
+
134
+ ```ruby
135
+ # Default HashIds encoder
136
+ coder = EncodedId::ReversibleId.new(salt: "my-salt")
137
+
138
+ # Sqids encoder (requires 'sqids' gem)
139
+ coder = EncodedId::ReversibleId.new(salt: "my-salt", encoder: :sqids)
140
+ ```
141
+
142
+ **Important**: HashIds and Sqids produce different encodings and are not compatible.
143
+
144
+ ### Formatting Options
145
+
146
+ ```ruby
147
+ # Custom splitting
148
+ coder = EncodedId::ReversibleId.new(
149
+ salt: "my-salt",
150
+ split_at: 3, # Group every 3 chars
151
+ split_with: "." # Use dots
152
+ )
153
+ coder.encode(123) # => "p5w.9z2.7j"
154
+
155
+ # No splitting
156
+ coder = EncodedId::ReversibleId.new(
157
+ salt: "my-salt",
158
+ split_at: nil
159
+ )
160
+ coder.encode(123) # => "p5w9z27j"
161
+ ```
162
+
163
+ ### Blocklist Configuration
164
+
165
+ ```ruby
166
+ # Prevent specific words
167
+ coder = EncodedId::ReversibleId.new(
168
+ salt: "my-salt",
169
+ blocklist: ["bad", "offensive", "words"]
170
+ )
171
+
172
+ # Behavior differs by encoder:
173
+ # - HashIds: Raises error if blocklisted word appears
174
+ # - Sqids: Automatically avoids generating blocklisted words
175
+ ```
176
+
177
+ ## Exception Handling
178
+
179
+ | Exception | Description |
180
+ |-----------|-------------|
181
+ | `EncodedId::InvalidConfigurationError` | Invalid configuration parameters |
182
+ | `EncodedId::InvalidAlphabetError` | Invalid alphabet (< 16 unique chars) |
183
+ | `EncodedId::EncodedIdFormatError` | Invalid encoded ID format |
184
+ | `EncodedId::EncodedIdLengthError` | Encoded ID exceeds max_length |
185
+ | `EncodedId::InvalidInputError` | Invalid input (negative integers, too many inputs) |
186
+ | `EncodedId::SaltError` | Invalid salt (too short) |
187
+
188
+ ## Usage Examples
189
+
190
+ ### Basic Usage
191
+ ```ruby
192
+ # Initialize
193
+ coder = EncodedId::ReversibleId.new(salt: "my-secret-salt")
194
+
195
+ # Encode/decode cycle
196
+ encoded = coder.encode(123) # => "p5w9-z27j"
197
+ decoded = coder.decode(encoded) # => [123]
198
+ original_id = decoded.first # => 123
199
+ ```
200
+
201
+ ### Multiple IDs
202
+ ```ruby
203
+ # Encode multiple IDs in one string
204
+ encoded = coder.encode([78, 45, 92]) # => "z2j7-0dmw-kf8p"
205
+ decoded = coder.decode(encoded) # => [78, 45, 92]
206
+ ```
207
+
208
+ ### Custom Configuration
209
+ ```ruby
210
+ # Highly customized instance
211
+ coder = EncodedId::ReversibleId.new(
212
+ salt: "my-app-salt",
213
+ encoder: :sqids,
214
+ length: 12,
215
+ split_at: 3,
216
+ split_with: ".",
217
+ alphabet: EncodedId::Alphabet.new("0123456789ABCDEF"),
218
+ blocklist: ["BAD", "FAKE"]
219
+ )
220
+ ```
221
+
222
+ ### Hex Encoding (UUIDs)
223
+ ```ruby
224
+ # For encoding UUIDs efficiently
225
+ coder = EncodedId::ReversibleId.new(
226
+ salt: "my-salt",
227
+ hex_digit_encoding_group_size: 32
228
+ )
229
+
230
+ uuid = "550e8400-e29b-41d4-a716-446655440000"
231
+ encoded = coder.encode_hex(uuid)
232
+ decoded = coder.decode_hex(encoded).first # => original UUID
233
+ ```
234
+
235
+ ## Performance Considerations
236
+
237
+ 1. **Algorithm Choice**:
238
+ - HashIds: Faster encoding, especially with blocklists
239
+ - Sqids: Faster decoding
240
+
241
+ 2. **Blocklist Impact**: Large blocklists can slow down encoding, especially with Sqids
242
+
243
+ 3. **Length vs Performance**: Longer minimum lengths may require more computation
244
+
245
+ 4. **Memory Usage**: The gem uses optimized implementations to minimize memory allocation
246
+
247
+ ## Security Notes
248
+
249
+ **Important**: Encoded IDs are NOT cryptographically secure. They provide obfuscation, not encryption. Do not rely on them for security purposes. They can potentially be reversed through brute-force attacks if the salt is compromised.
250
+
251
+ Use encoded IDs for:
252
+ - Hiding sequential database IDs
253
+ - Creating user-friendly URLs
254
+ - Preventing ID enumeration attacks
255
+
256
+ Do NOT use for:
257
+ - Secure tokens
258
+ - Authentication
259
+ - Sensitive data protection
260
+
261
+ ## Installation
262
+
263
+ ```ruby
264
+ # Gemfile
265
+ gem 'encoded_id'
266
+
267
+ # Or install directly
268
+ gem install encoded_id
269
+ ```
270
+
271
+ For Sqids support:
272
+ ```ruby
273
+ gem 'encoded_id'
274
+ gem 'sqids'
275
+ ```
276
+
277
+ ## Best Practices
278
+
279
+ 1. **Salt Management**: Use a strong, unique salt and store it securely (e.g., environment variables)
280
+ 2. **Consistent Configuration**: Once in production, don't change salt or encoder
281
+ 3. **Error Handling**: Always handle potential exceptions when decoding user input
282
+ 4. **Length Limits**: Set appropriate max_length to prevent DoS attacks
283
+ 5. **Validation**: Validate decoded IDs before using them in database queries
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # rbs_inline: enabled
4
+
3
5
  module EncodedId
4
6
  class Alphabet
5
7
  MIN_UNIQUE_CHARACTERS = 16
6
8
 
7
9
  class << self
10
+ # @rbs return: Alphabet
8
11
  def modified_crockford
9
12
  new(
10
13
  "0123456789abcdefghjkmnpqrstuvwxyz",
@@ -15,12 +18,22 @@ module EncodedId
15
18
  }
16
19
  )
17
20
  end
21
+
22
+ # @rbs return: Alphabet
23
+ def alphanum
24
+ new("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890")
25
+ end
18
26
  end
19
27
 
28
+ # @rbs @unique_characters: Array[String]
29
+ # @rbs @characters: String
30
+ # @rbs @equivalences: Hash[String, String]?
31
+
32
+ # @rbs (String | Array[String] characters, ?Hash[String, String]? equivalences) -> void
20
33
  def initialize(characters, equivalences = nil)
21
34
  raise_invalid_alphabet! unless valid_input_characters?(characters)
22
35
  @unique_characters = unique_character_alphabet(characters)
23
- raise_invalid_alphabet! unless valid_characters?
36
+ raise_invalid_characters! unless valid_characters?
24
37
  raise_character_set_too_small! unless sufficient_characters?
25
38
  raise_invalid_equivalences! unless valid_equivalences?(equivalences)
26
39
 
@@ -28,24 +41,31 @@ module EncodedId
28
41
  @equivalences = equivalences
29
42
  end
30
43
 
31
- attr_reader :unique_characters, :characters, :equivalences
44
+ attr_reader :unique_characters #: Array[String]
45
+ attr_reader :characters #: String
46
+ attr_reader :equivalences #: Hash[String, String]?
32
47
 
48
+ # @rbs (String character) -> bool
33
49
  def include?(character)
34
50
  unique_characters.include?(character)
35
51
  end
36
52
 
53
+ # @rbs return: Array[String]
37
54
  def to_a
38
55
  unique_characters.dup
39
56
  end
40
57
 
58
+ # @rbs return: String
41
59
  def to_s
42
60
  @characters.dup
43
61
  end
44
62
 
63
+ # @rbs return: String
45
64
  def inspect
46
65
  "#<#{self.class.name} chars: #{unique_characters.inspect}>"
47
66
  end
48
67
 
68
+ # @rbs return: Integer
49
69
  def size
50
70
  unique_characters.size
51
71
  end
@@ -53,23 +73,28 @@ module EncodedId
53
73
 
54
74
  private
55
75
 
76
+ # @rbs (String | Array[String] characters) -> bool
56
77
  def valid_input_characters?(characters)
57
78
  return false unless characters.is_a?(Array) || characters.is_a?(String)
58
79
  characters.size > 0
59
80
  end
60
81
 
82
+ # @rbs (String | Array[String] characters) -> Array[String]
61
83
  def unique_character_alphabet(characters)
62
84
  (characters.is_a?(Array) ? characters : characters.chars).uniq
63
85
  end
64
86
 
87
+ # @rbs return: bool
65
88
  def valid_characters?
66
89
  unique_characters.size > 0 && unique_characters.grep(/\s|\0/).size == 0
67
90
  end
68
91
 
92
+ # @rbs return: bool
69
93
  def sufficient_characters?
70
94
  unique_characters.size >= MIN_UNIQUE_CHARACTERS
71
95
  end
72
96
 
97
+ # @rbs (Hash[String, String]? equivalences) -> bool
73
98
  def valid_equivalences?(equivalences)
74
99
  return true if equivalences.nil?
75
100
  return false unless equivalences.is_a?(Hash)
@@ -78,14 +103,22 @@ module EncodedId
78
103
  (unique_characters & equivalences.keys).empty? && (equivalences.values - unique_characters).empty?
79
104
  end
80
105
 
106
+ # @rbs return: void
81
107
  def raise_invalid_alphabet!
82
- raise InvalidAlphabetError, "Alphabet must be a string or array and not contain whitespace."
108
+ raise InvalidAlphabetError, "Alphabet must be a populated string or array"
109
+ end
110
+
111
+ # @rbs return: void
112
+ def raise_invalid_characters!
113
+ raise InvalidAlphabetError, "Alphabet must not contain whitespace or null characters."
83
114
  end
84
115
 
116
+ # @rbs return: void
85
117
  def raise_character_set_too_small!
86
118
  raise InvalidAlphabetError, "Alphabet must contain at least #{MIN_UNIQUE_CHARACTERS} unique characters."
87
119
  end
88
120
 
121
+ # @rbs return: void
89
122
  def raise_invalid_equivalences!
90
123
  raise InvalidConfigurationError, "Character equivalences must be a hash or nil and contain mappings to valid alphabet characters."
91
124
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module EncodedId
6
+ class Blocklist
7
+ include Enumerable #[String]
8
+
9
+ # @rbs @words: Set[String]
10
+
11
+ # Class instance variables for memoization
12
+ # @rbs self.@empty: Blocklist
13
+ # @rbs self.@minimal: Blocklist
14
+
15
+ class << self
16
+ # @rbs () -> Blocklist
17
+ def sqids_blocklist
18
+ if defined?(::Sqids::DEFAULT_BLOCKLIST)
19
+ new(::Sqids::DEFAULT_BLOCKLIST)
20
+ else
21
+ empty
22
+ end
23
+ end
24
+
25
+ # @rbs () -> Blocklist
26
+ def empty
27
+ @empty ||= new([])
28
+ end
29
+
30
+ # @rbs () -> Blocklist
31
+ def minimal
32
+ @minimal ||= new([
33
+ "ass", "cum", "fag", "fap", "fck", "fuk", "jiz", "pis", "poo", "sex",
34
+ "tit", "xxx", "anal", "anus", "ball", "blow", "butt", "clit", "cock",
35
+ "coon", "cunt", "dick", "dyke", "fart", "fuck", "jerk", "jizz", "jugs",
36
+ "kike", "kunt", "muff", "nigg", "nigr", "piss", "poon", "poop", "porn",
37
+ "pube", "pusy", "quim", "rape", "scat", "scum", "shit", "slut", "suck",
38
+ "turd", "twat", "vag", "wank", "whor"
39
+ ])
40
+ end
41
+ end
42
+
43
+ attr_reader :words #: Set[String]
44
+
45
+ # @rbs (?(Array[String] | Set[String]) words) -> void
46
+ def initialize(words = [])
47
+ @words = if words.is_a?(Array) || words.is_a?(Set)
48
+ Set.new(words.map(&:to_s).map(&:downcase))
49
+ else
50
+ Set.new
51
+ end
52
+ end
53
+
54
+ # @rbs () { (String) -> void } -> void
55
+ def each(&block)
56
+ @words.each(&block)
57
+ end
58
+
59
+ # @rbs (String word) -> bool
60
+ def include?(word)
61
+ @words.include?(word.to_s.downcase)
62
+ end
63
+
64
+ # @rbs (String string) -> (String | false)
65
+ def blocks?(string)
66
+ return false if empty?
67
+
68
+ downcased_string = string.to_s.downcase
69
+ @words.each do |word|
70
+ return word if downcased_string.include?(word)
71
+ end
72
+ false
73
+ end
74
+
75
+ # @rbs () -> Integer
76
+ def size
77
+ @words.size
78
+ end
79
+
80
+ # @rbs () -> bool
81
+ def empty?
82
+ @words.empty?
83
+ end
84
+
85
+ # @rbs (Blocklist other_blocklist) -> Blocklist
86
+ def merge(other_blocklist)
87
+ self.class.new(to_a + other_blocklist.to_a)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ # rbs_inline: enabled
4
+
5
+ module EncodedId
6
+ module Encoders
7
+ class Base
8
+ # @rbs @min_hash_length: Integer
9
+ # @rbs @alphabet: Alphabet
10
+ # @rbs @salt: String
11
+ # @rbs @blocklist: Blocklist
12
+
13
+ # @rbs (String salt, ?Integer min_hash_length, ?Alphabet alphabet, ?Blocklist blocklist) -> void
14
+ def initialize(salt, min_hash_length = 0, alphabet = Alphabet.alphanum, blocklist = Blocklist.empty)
15
+ @min_hash_length = min_hash_length
16
+ @alphabet = alphabet
17
+ @salt = salt
18
+ @blocklist = blocklist
19
+ end
20
+
21
+ attr_reader :min_hash_length #: Integer
22
+ attr_reader :alphabet #: Alphabet
23
+ attr_reader :salt #: String
24
+ attr_reader :blocklist #: Blocklist
25
+
26
+ # Encode array of numbers into a string
27
+ # @rbs (Array[Integer] numbers) -> String
28
+ def encode(numbers)
29
+ raise NotImplementedError, "#{self.class} must implement #encode"
30
+ end
31
+
32
+ # Encode hexadecimal string(s) into a string
33
+ # @rbs (String str) -> String
34
+ def encode_hex(str)
35
+ return "" unless hex_string?(str)
36
+
37
+ numbers = str.scan(/[\w\W]{1,12}/).map do |num|
38
+ "1#{num}".to_i(16)
39
+ end
40
+
41
+ encode(numbers)
42
+ end
43
+
44
+ # Decode a string back into an array of numbers
45
+ # @rbs (String hash) -> Array[Integer]
46
+ def decode(hash)
47
+ raise NotImplementedError, "#{self.class} must implement #decode"
48
+ end
49
+
50
+ # Decode a string back into an array of hexadecimal strings
51
+ # @rbs (String hash) -> String
52
+ def decode_hex(hash)
53
+ numbers = decode(hash)
54
+ return "" if numbers.empty?
55
+
56
+ ret = numbers.map do |n|
57
+ n.to_s(16)[1..]
58
+ end
59
+
60
+ ret.join.upcase
61
+ end
62
+
63
+ private
64
+
65
+ # @rbs (String string) -> MatchData?
66
+ def hex_string?(string)
67
+ string.to_s.match(/\A[0-9a-fA-F]+\Z/)
68
+ end
69
+ end
70
+ end
71
+ end