encoded_id-rails 1.0.0.rc1 → 1.0.0.rc7
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -18
- data/LICENSE.txt +1 -1
- data/README.md +81 -473
- data/context/encoded_id-rails.md +651 -0
- data/context/encoded_id.md +437 -0
- data/lib/encoded_id/rails/active_record_finders.rb +54 -0
- data/lib/encoded_id/rails/annotated_id.rb +14 -9
- data/lib/encoded_id/rails/annotated_id_parser.rb +9 -1
- data/lib/encoded_id/rails/coder.rb +55 -10
- data/lib/encoded_id/rails/composite_id_base.rb +39 -0
- data/lib/encoded_id/rails/configuration.rb +66 -10
- data/lib/encoded_id/rails/encoder_methods.rb +30 -7
- data/lib/encoded_id/rails/finder_methods.rb +11 -0
- data/lib/encoded_id/rails/model.rb +60 -7
- data/lib/encoded_id/rails/path_param.rb +8 -0
- data/lib/encoded_id/rails/persists.rb +55 -9
- data/lib/encoded_id/rails/query_methods.rb +21 -4
- data/lib/encoded_id/rails/railtie.rb +13 -0
- data/lib/encoded_id/rails/salt.rb +8 -0
- data/lib/encoded_id/rails/slugged_id.rb +14 -9
- data/lib/encoded_id/rails/slugged_id_parser.rb +9 -1
- data/lib/encoded_id/rails/slugged_path_param.rb +8 -0
- data/lib/encoded_id/rails.rb +11 -6
- data/lib/generators/encoded_id/rails/install_generator.rb +36 -2
- data/lib/generators/encoded_id/rails/templates/{encoded_id.rb → hashids_encoded_id.rb} +49 -5
- data/lib/generators/encoded_id/rails/templates/sqids_encoded_id.rb +116 -0
- metadata +16 -24
- data/.devcontainer/Dockerfile +0 -17
- data/.devcontainer/compose.yml +0 -10
- data/.devcontainer/devcontainer.json +0 -12
- data/.standard.yml +0 -3
- data/Appraisals +0 -9
- data/Gemfile +0 -24
- data/Rakefile +0 -20
- data/Steepfile +0 -4
- data/gemfiles/.bundle/config +0 -2
- data/gemfiles/rails_7.2.gemfile +0 -19
- data/gemfiles/rails_8.0.gemfile +0 -19
- data/lib/encoded_id/rails/version.rb +0 -7
- data/rbs_collection.yaml +0 -24
- data/sig/encoded_id/rails.rbs +0 -141
|
@@ -0,0 +1,437 @@
|
|
|
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
|
+
- **Blocklist Modes**: Three modes for controlling blocklist checking performance
|
|
18
|
+
|
|
19
|
+
## Quick Reference
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
# Sqids encoder (default, no salt required)
|
|
23
|
+
coder = EncodedId::ReversibleId.sqids(min_length: 10)
|
|
24
|
+
id = coder.encode(123) # => "p5w9-z27j-k8"
|
|
25
|
+
nums = coder.decode(id) # => [123]
|
|
26
|
+
|
|
27
|
+
# Hashids encoder (requires salt)
|
|
28
|
+
coder = EncodedId::ReversibleId.hashid(salt: "my-salt", min_length: 8)
|
|
29
|
+
id = coder.encode([78, 45]) # => "z2j7-0dmw"
|
|
30
|
+
nums = coder.decode(id) # => [78, 45]
|
|
31
|
+
|
|
32
|
+
# UUID encoding (experimental)
|
|
33
|
+
hex_id = coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
|
34
|
+
uuid = coder.decode_hex(hex_id).first
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Core API
|
|
38
|
+
|
|
39
|
+
### EncodedId::ReversibleId
|
|
40
|
+
|
|
41
|
+
The main class for encoding and decoding IDs.
|
|
42
|
+
|
|
43
|
+
#### Factory Methods (Recommended)
|
|
44
|
+
|
|
45
|
+
Factory methods provide the cleanest way to create encoders:
|
|
46
|
+
|
|
47
|
+
```ruby
|
|
48
|
+
# Sqids encoder (default, no salt required)
|
|
49
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
50
|
+
min_length: 10,
|
|
51
|
+
blocklist: ["bad", "words"]
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# Hashids encoder (requires salt)
|
|
55
|
+
coder = EncodedId::ReversibleId.hashid(
|
|
56
|
+
salt: "my-salt",
|
|
57
|
+
min_length: 8,
|
|
58
|
+
blocklist: ["bad", "words"]
|
|
59
|
+
)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Both factory methods accept all configuration options described below.
|
|
63
|
+
|
|
64
|
+
#### Constructor (Alternative)
|
|
65
|
+
|
|
66
|
+
You can also use the constructor with explicit configuration objects:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# Using Sqids configuration
|
|
70
|
+
config = EncodedId::Encoders::SqidsConfiguration.new(
|
|
71
|
+
min_length: 8, # Minimum length of encoded string
|
|
72
|
+
split_at: 4, # Split encoded string every X characters
|
|
73
|
+
split_with: "-", # Character to split with
|
|
74
|
+
alphabet: EncodedId::Alphabet.modified_crockford,
|
|
75
|
+
hex_digit_encoding_group_size: 4,
|
|
76
|
+
max_length: 128, # Maximum length limit
|
|
77
|
+
max_inputs_per_id: 32, # Maximum IDs to encode together
|
|
78
|
+
blocklist: nil, # Words to prevent in IDs
|
|
79
|
+
blocklist_mode: :length_threshold, # :always, :length_threshold, or :raise_if_likely
|
|
80
|
+
blocklist_max_length: 32 # Max length for :length_threshold mode
|
|
81
|
+
)
|
|
82
|
+
coder = EncodedId::ReversibleId.new(config)
|
|
83
|
+
|
|
84
|
+
# Using Hashids configuration (requires salt)
|
|
85
|
+
config = EncodedId::Encoders::HashidConfiguration.new(
|
|
86
|
+
salt: "my-salt", # Required for Hashids (min 4 chars)
|
|
87
|
+
min_length: 8,
|
|
88
|
+
# ... other options same as above
|
|
89
|
+
)
|
|
90
|
+
coder = EncodedId::ReversibleId.new(config)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Note**: As of v1.0.0, the default encoder is `:sqids`. For backwards compatibility with pre-v1 versions, use `ReversibleId.hashid()`.
|
|
94
|
+
|
|
95
|
+
#### Key Methods
|
|
96
|
+
|
|
97
|
+
##### encode(values)
|
|
98
|
+
Encodes one or more integer IDs into an obfuscated string.
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
coder = EncodedId::ReversibleId.sqids
|
|
102
|
+
|
|
103
|
+
# Single ID
|
|
104
|
+
coder.encode(123) # => "p5w9-z27j"
|
|
105
|
+
|
|
106
|
+
# Multiple IDs
|
|
107
|
+
coder.encode([78, 45]) # => "z2j7-0dmw"
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
##### decode(encoded_id, downcase: false)
|
|
111
|
+
Decodes an encoded string back to original IDs.
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
coder.decode("p5w9-z27j") # => [123]
|
|
115
|
+
coder.decode("z2j7-0dmw") # => [78, 45]
|
|
116
|
+
|
|
117
|
+
# Case-sensitive by default (v1.0.0+)
|
|
118
|
+
coder.decode("p5w9-z27J") # => [] (case doesn't match)
|
|
119
|
+
|
|
120
|
+
# For case-insensitive matching (pre-v1 behavior)
|
|
121
|
+
coder.decode("p5w9-z27J", downcase: true) # => [123]
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
**Note**: As of v1.0.0, decoding is case-sensitive by default (`downcase: false`). Set `downcase: true` for backwards compatibility.
|
|
125
|
+
|
|
126
|
+
##### encode_hex(hex_strings) (Experimental)
|
|
127
|
+
Encodes hexadecimal strings (like UUIDs).
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
# Encode UUID
|
|
131
|
+
coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
|
132
|
+
# => "5jjy-c8d9-hxp2-qsve-rgh9-rxnt-7nb5-tve7-bf84-vr"
|
|
133
|
+
|
|
134
|
+
# With larger group size for shorter output
|
|
135
|
+
coder = EncodedId::ReversibleId.sqids(hex_digit_encoding_group_size: 32)
|
|
136
|
+
coder.encode_hex("9a566b8b-8618-42ab-8db7-a5a0276401fd")
|
|
137
|
+
# => "vr7m-qra8-m5y6-dkgj-5rqr-q44e-gp4a-52"
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
##### decode_hex(encoded_id, downcase: false) (Experimental)
|
|
141
|
+
Decodes back to hexadecimal strings.
|
|
142
|
+
|
|
143
|
+
```ruby
|
|
144
|
+
coder.decode_hex("w72a-y0az") # => ["10f8c"]
|
|
145
|
+
|
|
146
|
+
# For case-insensitive decoding (pre-v1 behavior)
|
|
147
|
+
coder.decode_hex("W72A-Y0AZ", downcase: true) # => ["10f8c"]
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### EncodedId::Alphabet
|
|
151
|
+
|
|
152
|
+
Class for creating custom alphabets.
|
|
153
|
+
|
|
154
|
+
#### Predefined Alphabets
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
# Default: modified Crockford Base32
|
|
158
|
+
# Characters: "0123456789abcdefghjkmnpqrstuvwxyz"
|
|
159
|
+
# Excludes: i, l, o, u (easily confused)
|
|
160
|
+
# Equivalences: {"o"=>"0", "i"=>"j", "l"=>"1", ...}
|
|
161
|
+
EncodedId::Alphabet.modified_crockford
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### Custom Alphabets
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
# Simple custom alphabet
|
|
168
|
+
alphabet = EncodedId::Alphabet.new("0123456789abcdef")
|
|
169
|
+
|
|
170
|
+
# With character equivalences
|
|
171
|
+
alphabet = EncodedId::Alphabet.new(
|
|
172
|
+
"0123456789ABCDEF",
|
|
173
|
+
{"a"=>"A", "b"=>"B", "c"=>"C", "d"=>"D", "e"=>"E", "f"=>"F"}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Greek alphabet example
|
|
177
|
+
alphabet = EncodedId::Alphabet.new("αβγδεζηθικλμνξοπρστυφχψω")
|
|
178
|
+
coder = EncodedId::ReversibleId.sqids(alphabet: alphabet)
|
|
179
|
+
coder.encode(123) # => "θεαψ-ζκυο"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### EncodedId::Blocklist
|
|
183
|
+
|
|
184
|
+
Class for managing profanity/word blocklists.
|
|
185
|
+
|
|
186
|
+
#### Predefined Blocklists
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
# Empty blocklist (no filtering)
|
|
190
|
+
EncodedId::Blocklist.empty
|
|
191
|
+
|
|
192
|
+
# Minimal blocklist (~50 common profane words)
|
|
193
|
+
EncodedId::Blocklist.minimal
|
|
194
|
+
|
|
195
|
+
# Full Sqids default blocklist (comprehensive)
|
|
196
|
+
EncodedId::Blocklist.sqids_blocklist
|
|
197
|
+
|
|
198
|
+
# Use in configuration
|
|
199
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
200
|
+
blocklist: EncodedId::Blocklist.minimal
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
#### Custom Blocklists
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
# From array
|
|
208
|
+
blocklist = EncodedId::Blocklist.new(["bad", "offensive", "words"])
|
|
209
|
+
|
|
210
|
+
# Merge blocklists
|
|
211
|
+
combined = EncodedId::Blocklist.minimal.merge(
|
|
212
|
+
EncodedId::Blocklist.new(["custom", "words"])
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
# Filter for specific alphabet (automatic with configuration)
|
|
216
|
+
filtered = blocklist.filter_for_alphabet(EncodedId::Alphabet.modified_crockford)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Note**: Blocklists are automatically filtered to only include words possible with your configured alphabet. This optimization improves performance.
|
|
220
|
+
|
|
221
|
+
## Configuration Options
|
|
222
|
+
|
|
223
|
+
### Basic Options
|
|
224
|
+
|
|
225
|
+
- **min_length**: Minimum length of encoded string (default: 8)
|
|
226
|
+
- **max_length**: Maximum allowed length (default: 128) to prevent DoS attacks
|
|
227
|
+
- **max_inputs_per_id**: Maximum IDs encodable together (default: 32)
|
|
228
|
+
- **hex_digit_encoding_group_size**: Group size for hex encoding (default: 4)
|
|
229
|
+
|
|
230
|
+
### Encoder Selection
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
# Sqids encoder (default, no salt required)
|
|
234
|
+
coder = EncodedId::ReversibleId.sqids
|
|
235
|
+
|
|
236
|
+
# Hashids encoder (requires salt - minimum 4 characters)
|
|
237
|
+
coder = EncodedId::ReversibleId.hashid(salt: "my-salt-minimum-4-chars")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
**Important**:
|
|
241
|
+
- As of v1.0.0, `:sqids` is the default encoder
|
|
242
|
+
- **Sqids**: No salt required, automatically avoids blocklisted words via iteration
|
|
243
|
+
- **Hashids**: Salt required (min 4 chars), raises exception if blocklisted word appears
|
|
244
|
+
- HashIds and Sqids produce different encodings and are **not compatible**
|
|
245
|
+
- Do NOT change encoders after going to production with existing encoded IDs
|
|
246
|
+
|
|
247
|
+
### Blocklist Configuration
|
|
248
|
+
|
|
249
|
+
#### Blocklist Modes
|
|
250
|
+
|
|
251
|
+
Control how blocklist checking behaves to balance performance and safety:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
# :length_threshold (default) - Check blocklist only until encoded length reaches blocklist_max_length
|
|
255
|
+
# Best for most use cases - prevents performance issues with very long IDs
|
|
256
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
257
|
+
blocklist: EncodedId::Blocklist.minimal,
|
|
258
|
+
blocklist_mode: :length_threshold,
|
|
259
|
+
blocklist_max_length: 32 # Stop checking after 32 characters
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
# :always - Always check blocklist regardless of encoded length
|
|
263
|
+
# Can be slow for long IDs or large blocklists
|
|
264
|
+
coder = EncodedId::ReversibleId.hashid(
|
|
265
|
+
salt: "my-salt",
|
|
266
|
+
blocklist: ["bad", "words"],
|
|
267
|
+
blocklist_mode: :always
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# :raise_if_likely - Raise error at configuration time if settings likely cause blocklist collisions
|
|
271
|
+
# Prevents configurations that would cause performance issues
|
|
272
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
273
|
+
min_length: 8,
|
|
274
|
+
blocklist: ["bad", "words"],
|
|
275
|
+
blocklist_mode: :raise_if_likely
|
|
276
|
+
)
|
|
277
|
+
# Raises InvalidConfigurationError if min_length > blocklist_max_length
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Blocklist Behavior by Encoder**:
|
|
281
|
+
- **Sqids**: Iteratively regenerates to avoid blocklisted words (may impact encoding performance)
|
|
282
|
+
- **Hashids**: Raises `EncodedId::BlocklistError` if a blocklisted word appears
|
|
283
|
+
|
|
284
|
+
**Recommendation**: Use `:length_threshold` mode (default) for best balance of performance and safety.
|
|
285
|
+
|
|
286
|
+
### Formatting Options
|
|
287
|
+
|
|
288
|
+
```ruby
|
|
289
|
+
# Custom splitting
|
|
290
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
291
|
+
split_at: 3, # Group every 3 chars
|
|
292
|
+
split_with: "." # Use dots
|
|
293
|
+
)
|
|
294
|
+
coder.encode(123) # => "p5w.9z2.7j"
|
|
295
|
+
|
|
296
|
+
# No splitting
|
|
297
|
+
coder = EncodedId::ReversibleId.sqids(split_at: nil)
|
|
298
|
+
coder.encode(123) # => "p5w9z27j"
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
## Exception Handling
|
|
302
|
+
|
|
303
|
+
| Exception | Description |
|
|
304
|
+
|-----------|-------------|
|
|
305
|
+
| `EncodedId::InvalidConfigurationError` | Invalid configuration parameters |
|
|
306
|
+
| `EncodedId::InvalidAlphabetError` | Invalid alphabet (< 16 unique chars) |
|
|
307
|
+
| `EncodedId::EncodedIdFormatError` | Invalid encoded ID format |
|
|
308
|
+
| `EncodedId::EncodedIdLengthError` | Encoded ID exceeds max_length |
|
|
309
|
+
| `EncodedId::InvalidInputError` | Invalid input (negative integers, too many inputs) |
|
|
310
|
+
| `EncodedId::SaltError` | Invalid salt (too short, only for Hashids) |
|
|
311
|
+
| `EncodedId::BlocklistError` | Generated ID contains blocklisted word (Hashids only) |
|
|
312
|
+
|
|
313
|
+
## Usage Examples
|
|
314
|
+
|
|
315
|
+
### Basic Usage
|
|
316
|
+
```ruby
|
|
317
|
+
# Initialize with Sqids (no salt needed)
|
|
318
|
+
coder = EncodedId::ReversibleId.sqids
|
|
319
|
+
|
|
320
|
+
# Encode/decode cycle
|
|
321
|
+
encoded = coder.encode(123) # => "p5w9-z27j"
|
|
322
|
+
decoded = coder.decode(encoded) # => [123]
|
|
323
|
+
original_id = decoded.first # => 123
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
### Multiple IDs
|
|
327
|
+
```ruby
|
|
328
|
+
# Encode multiple IDs in one string
|
|
329
|
+
encoded = coder.encode([78, 45, 92]) # => "z2j7-0dmw-kf8p"
|
|
330
|
+
decoded = coder.decode(encoded) # => [78, 45, 92]
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
### With Hashids and Blocklist
|
|
334
|
+
```ruby
|
|
335
|
+
coder = EncodedId::ReversibleId.hashid(
|
|
336
|
+
salt: "my-app-salt",
|
|
337
|
+
min_length: 12,
|
|
338
|
+
blocklist: EncodedId::Blocklist.minimal,
|
|
339
|
+
blocklist_mode: :length_threshold
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
encoded = coder.encode(123)
|
|
343
|
+
# Raises BlocklistError if result contains blocklisted word
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Custom Configuration
|
|
347
|
+
```ruby
|
|
348
|
+
# Highly customized Sqids instance
|
|
349
|
+
coder = EncodedId::ReversibleId.sqids(
|
|
350
|
+
min_length: 12,
|
|
351
|
+
split_at: 3,
|
|
352
|
+
split_with: ".",
|
|
353
|
+
alphabet: EncodedId::Alphabet.new("0123456789ABCDEF"),
|
|
354
|
+
blocklist: ["BAD", "FAKE"],
|
|
355
|
+
blocklist_mode: :length_threshold,
|
|
356
|
+
blocklist_max_length: 32
|
|
357
|
+
)
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Hex Encoding (UUIDs)
|
|
361
|
+
```ruby
|
|
362
|
+
# For encoding UUIDs efficiently
|
|
363
|
+
coder = EncodedId::ReversibleId.sqids(hex_digit_encoding_group_size: 32)
|
|
364
|
+
|
|
365
|
+
uuid = "550e8400-e29b-41d4-a716-446655440000"
|
|
366
|
+
encoded = coder.encode_hex(uuid)
|
|
367
|
+
decoded = coder.decode_hex(encoded).first # => original UUID (without hyphens)
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
## Performance Considerations
|
|
371
|
+
|
|
372
|
+
1. **Algorithm Choice**:
|
|
373
|
+
- HashIds: Faster encoding, especially with blocklists
|
|
374
|
+
- Sqids: Faster decoding, automatically avoids blocklisted words
|
|
375
|
+
|
|
376
|
+
2. **Blocklist Impact**:
|
|
377
|
+
- Large blocklists slow encoding, especially with Sqids (which iterates to avoid words)
|
|
378
|
+
- Hashids may raise exceptions requiring retry logic
|
|
379
|
+
- Use `blocklist_mode: :length_threshold` for best performance
|
|
380
|
+
- `:always` mode can significantly impact encoding speed for long IDs
|
|
381
|
+
- Blocklists are automatically filtered for your alphabet, improving performance
|
|
382
|
+
|
|
383
|
+
3. **Blocklist Mode Performance**:
|
|
384
|
+
- `:length_threshold` (default): Only checks blocklist for IDs ≤ `blocklist_max_length` (default: 32)
|
|
385
|
+
- `:always`: Checks all IDs regardless of length (can be slow)
|
|
386
|
+
- `:raise_if_likely`: Validates configuration at initialization to prevent performance issues
|
|
387
|
+
|
|
388
|
+
4. **Length vs Performance**: Longer minimum lengths may require more computation
|
|
389
|
+
|
|
390
|
+
5. **Memory Usage**: The gem uses optimized implementations to minimize memory allocation
|
|
391
|
+
|
|
392
|
+
## Version Compatibility
|
|
393
|
+
|
|
394
|
+
**v1.0.0 Breaking Changes:**
|
|
395
|
+
|
|
396
|
+
1. **Default encoder**: Changed from `:hashids` to `:sqids`
|
|
397
|
+
2. **Case sensitivity**: `decode` is now case-sensitive by default (`downcase: false`)
|
|
398
|
+
- Pre-v1: `decode("ABC")` and `decode("abc")` were equivalent
|
|
399
|
+
- v1.0.0+: These produce different results unless `downcase: true`
|
|
400
|
+
3. **Salt requirement**: Sqids (default) doesn't require salt; Hashids still requires salt
|
|
401
|
+
4. **Migration**: For backwards compatibility with pre-v1:
|
|
402
|
+
```ruby
|
|
403
|
+
coder = EncodedId::ReversibleId.hashid(salt: "your-salt")
|
|
404
|
+
decoded = coder.decode(id, downcase: true)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Security Notes
|
|
408
|
+
|
|
409
|
+
**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.
|
|
410
|
+
|
|
411
|
+
Use encoded IDs for:
|
|
412
|
+
- Hiding sequential database IDs
|
|
413
|
+
- Creating user-friendly URLs
|
|
414
|
+
- Preventing ID enumeration attacks
|
|
415
|
+
- Obscuring business metrics (user counts, order volumes)
|
|
416
|
+
|
|
417
|
+
Do NOT use for:
|
|
418
|
+
- Secure tokens
|
|
419
|
+
- Authentication
|
|
420
|
+
- Sensitive data protection
|
|
421
|
+
- Cryptographic purposes
|
|
422
|
+
|
|
423
|
+
## Installation
|
|
424
|
+
|
|
425
|
+
```ruby
|
|
426
|
+
# Gemfile
|
|
427
|
+
gem 'encoded_id'
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## Best Practices
|
|
431
|
+
|
|
432
|
+
1. **Consistent Configuration**: Once in production, don't change salt, encoder, or alphabet
|
|
433
|
+
2. **Error Handling**: Always handle potential exceptions when decoding user input
|
|
434
|
+
3. **Length Limits**: Set appropriate max_length to prevent DoS attacks
|
|
435
|
+
4. **Validation**: Validate decoded IDs before using them in database queries
|
|
436
|
+
5. **Blocklist Mode**: Use `:length_threshold` (default) for production - best performance/safety balance
|
|
437
|
+
6. **Factory Methods**: Prefer `ReversibleId.sqids()` and `ReversibleId.hashid()` over constructor
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
module EncodedId
|
|
6
|
+
module Rails
|
|
7
|
+
# This module overrides standard ActiveRecord finder methods to automatically decode encoded IDs.
|
|
8
|
+
# Important: This module should NOT be used with models that use string-based primary keys (e.g., UUIDs)
|
|
9
|
+
# as it will cause conflicts between string IDs and encoded IDs.
|
|
10
|
+
module ActiveRecordFinders
|
|
11
|
+
extend ActiveSupport::Concern
|
|
12
|
+
|
|
13
|
+
# @rbs!
|
|
14
|
+
# include ::ActiveRecord::FinderMethods
|
|
15
|
+
# extend ::ActiveRecord::QueryMethods
|
|
16
|
+
|
|
17
|
+
included do
|
|
18
|
+
if columns_hash["id"]&.type == :string
|
|
19
|
+
::Rails.logger.warn("EncodedId::Rails::ActiveRecordFinders has been included in #{name}, but this model uses string-based IDs. This may cause conflicts with encoded ID handling.")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Class methods for overriding ActiveRecord's finder methods to decode encoded IDs.
|
|
24
|
+
module ClassMethods
|
|
25
|
+
# @rbs (*untyped args) -> untyped
|
|
26
|
+
def find(*args)
|
|
27
|
+
first_arg = args.first
|
|
28
|
+
return super unless args.size == 1 && first_arg.is_a?(String)
|
|
29
|
+
|
|
30
|
+
decoded_ids = decode_encoded_id(first_arg)
|
|
31
|
+
|
|
32
|
+
if decoded_ids.blank?
|
|
33
|
+
raise ::ActiveRecord::RecordNotFound
|
|
34
|
+
elsif decoded_ids.size == 1
|
|
35
|
+
super(decoded_ids.first)
|
|
36
|
+
else
|
|
37
|
+
super(decoded_ids)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Override find_by_id to handle encoded IDs
|
|
42
|
+
# @rbs (untyped id) -> untyped
|
|
43
|
+
def find_by_id(id)
|
|
44
|
+
if id.is_a?(String)
|
|
45
|
+
decoded_ids = decode_encoded_id(id)
|
|
46
|
+
return nil if decoded_ids.blank?
|
|
47
|
+
return super(decoded_ids.first)
|
|
48
|
+
end
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -1,21 +1,26 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# rbs_inline: enabled
|
|
4
4
|
|
|
5
5
|
module EncodedId
|
|
6
6
|
module Rails
|
|
7
|
-
|
|
7
|
+
# Represents an encoded ID with an annotation prefix (e.g., "user_ABC123").
|
|
8
|
+
class AnnotatedId < CompositeIdBase
|
|
9
|
+
# @rbs (annotation: String, id_part: String, ?separator: String) -> void
|
|
8
10
|
def initialize(annotation:, id_part:, separator: "_")
|
|
9
|
-
|
|
10
|
-
@id_part = id_part
|
|
11
|
-
@separator = separator
|
|
11
|
+
super(first_part: annotation, id_part: id_part, separator: separator)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
+
# @rbs return: String
|
|
14
15
|
def annotated_id
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
build_composite_id
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
# @rbs return: String
|
|
22
|
+
def invalid_id_error_message
|
|
23
|
+
"The model does not return a valid ID and/or annotation"
|
|
19
24
|
end
|
|
20
25
|
end
|
|
21
26
|
end
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
3
5
|
module EncodedId
|
|
4
6
|
module Rails
|
|
7
|
+
# Parses an annotated ID into its annotation and ID components.
|
|
5
8
|
class AnnotatedIdParser
|
|
9
|
+
# @rbs @annotation: String?
|
|
10
|
+
# @rbs @id: String
|
|
11
|
+
|
|
12
|
+
# @rbs (String annotated_id, ?separator: String) -> void
|
|
6
13
|
def initialize(annotated_id, separator: "_")
|
|
7
14
|
if separator && annotated_id.include?(separator)
|
|
8
15
|
parts = annotated_id.split(separator)
|
|
@@ -13,7 +20,8 @@ module EncodedId
|
|
|
13
20
|
end
|
|
14
21
|
end
|
|
15
22
|
|
|
16
|
-
attr_reader :annotation
|
|
23
|
+
attr_reader :annotation #: String?
|
|
24
|
+
attr_reader :id #: String
|
|
17
25
|
end
|
|
18
26
|
end
|
|
19
27
|
end
|
|
@@ -1,36 +1,81 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
3
5
|
module EncodedId
|
|
4
6
|
module Rails
|
|
7
|
+
# Encodes and decodes IDs using the configured encoder and settings.
|
|
5
8
|
class Coder
|
|
6
|
-
|
|
9
|
+
# @rbs @salt: String?
|
|
10
|
+
# @rbs @id_length: Integer
|
|
11
|
+
# @rbs @character_group_size: Integer
|
|
12
|
+
# @rbs @separator: String
|
|
13
|
+
# @rbs @alphabet: ::EncodedId::Alphabet
|
|
14
|
+
# @rbs @encoder: Symbol
|
|
15
|
+
# @rbs @blocklist: ::EncodedId::Blocklist?
|
|
16
|
+
# @rbs @blocklist_mode: Symbol
|
|
17
|
+
# @rbs @blocklist_max_length: Integer
|
|
18
|
+
# @rbs @downcase_on_decode: bool
|
|
19
|
+
|
|
20
|
+
# @rbs (salt: String?, id_length: Integer, character_group_size: Integer, separator: String, alphabet: ::EncodedId::Alphabet, ?encoder: Symbol?, ?blocklist: ::EncodedId::Blocklist?, ?blocklist_mode: Symbol?, ?blocklist_max_length: Integer?, ?downcase_on_decode: bool?) -> void
|
|
21
|
+
def initialize(salt:, id_length:, character_group_size:, separator:, alphabet:, encoder: nil, blocklist: nil, blocklist_mode: nil, blocklist_max_length: nil, downcase_on_decode: nil)
|
|
7
22
|
@salt = salt
|
|
8
23
|
@id_length = id_length
|
|
9
24
|
@character_group_size = character_group_size
|
|
10
25
|
@separator = separator
|
|
11
26
|
@alphabet = alphabet
|
|
27
|
+
config = EncodedId::Rails.configuration
|
|
28
|
+
@encoder = encoder || config.encoder
|
|
29
|
+
@blocklist = blocklist || config.blocklist
|
|
30
|
+
@blocklist_mode = blocklist_mode || config.blocklist_mode
|
|
31
|
+
@blocklist_max_length = blocklist_max_length || config.blocklist_max_length
|
|
32
|
+
@downcase_on_decode = downcase_on_decode.nil? ? config.downcase_on_decode : downcase_on_decode
|
|
12
33
|
end
|
|
13
34
|
|
|
35
|
+
# @rbs (Integer | Array[Integer] id) -> String
|
|
14
36
|
def encode(id)
|
|
15
37
|
coder.encode(id)
|
|
16
38
|
end
|
|
17
39
|
|
|
40
|
+
# @rbs (String encoded_id) -> Array[Integer]
|
|
18
41
|
def decode(encoded_id)
|
|
19
|
-
coder.decode(encoded_id)
|
|
42
|
+
coder.decode(encoded_id, downcase: @downcase_on_decode)
|
|
20
43
|
rescue EncodedId::EncodedIdFormatError, EncodedId::InvalidInputError
|
|
21
|
-
|
|
44
|
+
[]
|
|
22
45
|
end
|
|
23
46
|
|
|
24
47
|
private
|
|
25
48
|
|
|
49
|
+
# @rbs return: ::EncodedId::ReversibleId
|
|
26
50
|
def coder
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
51
|
+
# Build the appropriate configuration based on encoder type
|
|
52
|
+
config = case @encoder
|
|
53
|
+
when :hashids
|
|
54
|
+
::EncodedId::Encoders::HashidConfiguration.new(
|
|
55
|
+
salt: @salt || raise(ArgumentError, "Salt is required for hashids encoder"),
|
|
56
|
+
min_length: @id_length,
|
|
57
|
+
split_at: @character_group_size,
|
|
58
|
+
split_with: @separator,
|
|
59
|
+
alphabet: @alphabet,
|
|
60
|
+
blocklist: @blocklist,
|
|
61
|
+
blocklist_mode: @blocklist_mode,
|
|
62
|
+
blocklist_max_length: @blocklist_max_length
|
|
63
|
+
)
|
|
64
|
+
when :sqids
|
|
65
|
+
::EncodedId::Encoders::SqidsConfiguration.new(
|
|
66
|
+
min_length: @id_length,
|
|
67
|
+
split_at: @character_group_size,
|
|
68
|
+
split_with: @separator,
|
|
69
|
+
alphabet: @alphabet,
|
|
70
|
+
blocklist: @blocklist,
|
|
71
|
+
blocklist_mode: @blocklist_mode,
|
|
72
|
+
blocklist_max_length: @blocklist_max_length
|
|
73
|
+
)
|
|
74
|
+
else
|
|
75
|
+
raise ArgumentError, "Unknown encoder type: #{@encoder}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
::EncodedId::ReversibleId.new(config)
|
|
34
79
|
end
|
|
35
80
|
end
|
|
36
81
|
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# rbs_inline: enabled
|
|
4
|
+
|
|
5
|
+
require "cgi"
|
|
6
|
+
|
|
7
|
+
module EncodedId
|
|
8
|
+
module Rails
|
|
9
|
+
# Base class for composite IDs (slugged and annotated)
|
|
10
|
+
class CompositeIdBase
|
|
11
|
+
# @rbs @first_part: String
|
|
12
|
+
# @rbs @id_part: String
|
|
13
|
+
# @rbs @separator: String
|
|
14
|
+
|
|
15
|
+
# @rbs (first_part: String, id_part: String, separator: String) -> void
|
|
16
|
+
def initialize(first_part:, id_part:, separator:)
|
|
17
|
+
@first_part = first_part
|
|
18
|
+
@id_part = id_part
|
|
19
|
+
@separator = separator
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
# @rbs return: String
|
|
25
|
+
def build_composite_id
|
|
26
|
+
unless @id_part.present? && @first_part.present?
|
|
27
|
+
raise ::StandardError, invalid_id_error_message
|
|
28
|
+
end
|
|
29
|
+
"#{@first_part.to_s.parameterize}#{CGI.escape(@separator)}#{@id_part}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Default error message. Subclasses can override for more specific messages.
|
|
33
|
+
# @rbs return: String
|
|
34
|
+
def invalid_id_error_message
|
|
35
|
+
"The model does not return a valid ID and/or prefix"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|