// Package lure builds and resolves the short recipient identifiers that appear // in a lure URL, for example https://example.com/account/4H7K9QM2XR3T. // // A code is stored and matched byte for byte. Nothing is folded, uppercased or // stripped, so an operator who picks Special-42 gets exactly that link and it // stays distinct from special-42. This is required by base58, whose two cases // are different symbols, and applying the same rule everywhere keeps one // matching behaviour rather than one per algorithm. package lure import "strings" // Alphabet is the Crockford base32 encoding alphabet. It omits I, L, O and U // so that a code read aloud or transcribed from a message cannot be confused // with a visually similar character or spell an unintended word. const Alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" // Base58Alphabet is the Bitcoin base58 alphabet. It keeps both cases, which // makes a code shorter for the same number of combinations, and drops the four // glyphs that are hard to tell apart in print: 0, O, I and l. const Base58Alphabet = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" // MinLength and MaxLength bound the operator selectable length of a *generated* // code. The lower bound is a deliberate operator choice for short lived // campaigns where a guessable link is acceptable. const ( MinLength = 6 MaxLength = 16 DefaultLength = 12 ) // MaxCustomLength is the only limit placed on an operator written code, and it // exists because the column is varchar(64). const MaxCustomLength = 64 // IsCandidate reports whether a path segment or query value could be a lure // code and is therefore worth a database probe. // // Because an operator written code may be any text, this cannot check an // alphabet. It only rules out what could not be a code at all: anything too // long for the column, and anything that would have to be escaped to sit in a // single path segment. A dot is allowed, so a lure can end in something like // invoice.pdf, at the cost of one indexed probe on static asset requests. func IsCandidate(s string) bool { if s == "" || len(s) > MaxCustomLength { return false } // structural URL characters, which would split the segment or need escaping if strings.ContainsAny(s, "/%?#\\") { return false } // quotes and angle brackets, because the finished URL is written into a // mail body rendered with text/template, which does not escape it if strings.ContainsAny(s, "\"'<>") { return false } // control characters and whitespace. a code containing one could never be // matched back from a request path, so it would be silently unreachable for _, r := range s { if r <= 0x20 || r == 0x7f { return false } } return true } // IsValidCustom reports whether an operator supplied code can be stored. // // The code is kept byte for byte as written, so the only rules are the ones // that would otherwise make it unreachable: it has to fit the column and it has // to survive as a single path segment without escaping. A path with more than // one segment comes from the template URL path, not from the code. func IsValidCustom(s string) bool { return IsCandidate(s) }