furl - furl.go
1 // Package Furl provides a drop-in http.Handler that provides short url
2 // redirects for longer URLs.
3 package furl
4
5 import (
6 "encoding/base64"
7 "encoding/json"
8 "encoding/xml"
9 "fmt"
10 "io"
11 "math/rand"
12 "net/http"
13 "path"
14 "strings"
15 "time"
16 )
17
18 const (
19 defaultKeyLength = 6
20 defaultRetries = 100
21 maxURLLength = 2048
22 maxKeyLength = 2048
23
24 unrecognisedContentType = "unrecognised content-type"
25 failedReadRequest = "failed to read request"
26 invalidURL = "invalid url"
27 failedKeyGeneration = "failed to generate key"
28 invalidKey = "invalid key"
29 keyExists = "key exists"
30
31 optionsPost = "OPTIONS, POST"
32 optionsGetHead = "OPTIONS, GET, HEAD"
33 )
34
35 var xmlStart = xml.StartElement{
36 Name: xml.Name{
37 Local: "furl",
38 },
39 }
40
41 func allValid(_ string) bool {
42 return true
43 }
44
45 // The Furl type represents a keystore of URLs to either generated or supplied
46 // keys.
47 type Furl struct {
48 urlValidator, keyValidator func(string) bool
49 keyLength, retries uint
50 rand *rand.Rand
51 index func(http.ResponseWriter, *http.Request, int, string)
52 store Store
53 }
54
55 // The New function creates a new instance of Furl, with the following defaults
56 // that can be changed by adding Option params.
57 //
58 // urlValidator: By default all strings are treated as valid URLs, this can be
59 // changed by using the URLValidator Option.
60 //
61 // keyValidator: By default all strings are treated as valid Keys, this can be
62 // changed by using the KeyValidator Option.
63 //
64 // keyLength: The default length of generated keys (before base64 encoding) is
65 // 6 and can be changed by using the KeyLength Option.
66 //
67 // retries: The default number of retries the key generator will before
68 // increasing the key length is 100 and can be changed by using the
69 // CollisionRetries Option.
70 //
71 // store: The default store is an empty map that will not permanently record
72 // the data. This can be changed by using the SetStore Option.
73 //
74 // index: By default, Furl offers no HTML output. This can be changed by using
75 // the Index Option.
76 func New(opts ...Option) *Furl {
77 f := &Furl{
78 urlValidator: allValid,
79 keyValidator: allValid,
80 keyLength: defaultKeyLength,
81 retries: defaultRetries,
82 }
83
84 for _, o := range opts {
85 o(f)
86 }
87
88 if f.store == nil {
89 f.store = NewStore()
90 }
91
92 if f.rand == nil {
93 f.rand = rand.New(rand.NewSource(time.Now().UnixMicro()))
94 }
95
96 return f
97 }
98
99 // The ServeHTTP method satisfies the http.Handler interface and provides the
100 // following endpoints:
101 // GET /[key] - Will redirect the call to the associated URL if it exists, or
102 //
103 // will return 404 Not Found if it doesn't exists and 422
104 // Unprocessable Entity if the key is invalid.
105 //
106 // POST / - The root can be used to add urls to the store with a generated
107 //
108 // key. The URL must be specified in the POST body as per the
109 // specification below.
110 //
111 // POST /[key] - Will attempt to create the specified path with the URL
112 //
113 // provided as below. If the key is invalid, will respond with
114 // 422 Unprocessable Entity. This method cannot be used on
115 // existing keys.
116 //
117 // The URL for the POST methods can be provided in a few content types:
118 // application/json: {"key": "KEY HERE", "url": "URL HERE"}
119 // text/xml: <furl><key>KEY HERE</key><url>URL HERE</url></furl>
120 // application/x-www-form-urlencoded: key=KEY+HERE&url=URL+HERE
121 // text/plain: URL HERE
122 //
123 // For the json, xml, and form content types, the key can be omitted if it has
124 // been supplied in the path or if the key is to be generated.
125 //
126 // The response type will be determined by the POST content type:
127 // application/json: {"key": "KEY HERE", "url": "URL HERE"}
128 // text/xml: <furl><key>KEY HERE</key><url>URL HERE</url></furl>
129 // text/plain: KEY HERE
130 //
131 // For application/x-www-form-urlencoded, the content type of the return will
132 // be text/html and the response will match that of text/plain.
133 func (f *Furl) ServeHTTP(w http.ResponseWriter, r *http.Request) {
134 switch r.Method {
135 case http.MethodGet, http.MethodHead:
136 f.get(w, r)
137 case http.MethodPost:
138 f.post(w, r)
139 case http.MethodOptions:
140 f.options(w, r)
141 }
142 }
143
144 func (f *Furl) get(w http.ResponseWriter, r *http.Request) {
145 key := path.Base(r.URL.Path)
146 if !f.keyValidator(key) {
147 if f.index != nil {
148 f.index(w, r, http.StatusUnprocessableEntity, invalidKey)
149 } else {
150 http.Error(w, invalidKey, http.StatusUnprocessableEntity)
151 }
152
153 return
154 }
155
156 url, ok := f.store.Get(key)
157 if ok {
158 http.Redirect(w, r, url, http.StatusMovedPermanently)
159 } else if f.index != nil {
160 f.index(w, r, http.StatusNotFound, "404 page not found")
161 } else {
162 http.NotFound(w, r)
163 }
164 }
165
166 func (f *Furl) writeResponse(w http.ResponseWriter, r *http.Request, status int, contentType, output string) {
167 var format string
168
169 switch contentType {
170 case "text/json", "application/json":
171 format = "{\"error\":%q}"
172 case "text/xml", "application/xml":
173 format = "<furl><error>%s</error></furl>"
174 case "text/html":
175 if f.index != nil {
176 f.index(w, r, status, output)
177 return
178 }
179
180 fallthrough
181 default:
182 format = "%s"
183 }
184
185 w.WriteHeader(status)
186 fmt.Fprintf(w, format, output)
187 }
188
189 type keyURL struct {
190 Key string `json:"key" xml:"key"`
191 URL string `json:"url" xml:"url"`
192 }
193
194 func (f *Furl) post(w http.ResponseWriter, r *http.Request) {
195 var (
196 data keyURL
197 err error
198 )
199
200 contentType := r.Header.Get("Content-Type")
201 switch contentType {
202 case "text/json", "application/json":
203 err = json.NewDecoder(r.Body).Decode(&data)
204 case "text/xml", "application/xml":
205 err = xml.NewDecoder(r.Body).Decode(&data)
206 case "application/x-www-form-urlencoded":
207 err = r.ParseForm()
208 data.Key = r.PostForm.Get("key")
209 data.URL = r.PostForm.Get("url")
210 contentType = "text/html"
211 case "text/plain":
212 var sb strings.Builder
213
214 _, err = io.Copy(&sb, r.Body)
215 data.URL = sb.String()
216 default:
217 http.Error(w, unrecognisedContentType, http.StatusUnsupportedMediaType)
218
219 return
220 }
221
222 w.Header().Set("Content-Type", contentType)
223
224 if err != nil {
225 f.writeResponse(w, r, http.StatusBadRequest, contentType, failedReadRequest)
226
227 return
228 } else if len(data.URL) > maxURLLength || data.URL == "" || !f.urlValidator(data.URL) {
229 f.writeResponse(w, r, http.StatusBadRequest, contentType, invalidURL)
230
231 return
232 } else if data.Key == "" {
233 data.Key = path.Base("/" + r.URL.Path) // see if suggested key in path
234 }
235
236 var (
237 errCode int
238 errString string
239 )
240 if data.Key == "" || data.Key == "/" || data.Key == "." || data.Key == ".." { // generate key
241 f.store.Tx(func(tx Tx) {
242 for idLength := f.keyLength; ; idLength++ {
243 keyBytes := make([]byte, idLength)
244
245 for i := uint(0); i < f.retries; i++ {
246 f.rand.Read(keyBytes) // NB: will never error
247 data.Key = base64.RawURLEncoding.EncodeToString(keyBytes)
248
249 if ok := tx.Has(data.Key); !ok && f.keyValidator(data.Key) {
250 tx.Set(data.Key, data.URL)
251
252 return
253 }
254 }
255 if idLength == maxKeyLength {
256 errCode = http.StatusInternalServerError
257 errString = failedKeyGeneration
258
259 return
260 }
261 }
262 })
263 } else if len(data.Key) > maxKeyLength || !f.keyValidator(data.Key) {
264 f.writeResponse(w, r, http.StatusUnprocessableEntity, contentType, invalidKey)
265
266 return
267 } else { // use suggested key
268 f.store.Tx(func(tx Tx) {
269 if ok := tx.Has(data.Key); ok {
270 errCode = http.StatusMethodNotAllowed
271 errString = keyExists
272 } else {
273 tx.Set(data.Key, data.URL)
274 }
275 })
276 }
277
278 if errCode != 0 {
279 f.writeResponse(w, r, errCode, contentType, errString)
280
281 return
282 }
283
284 switch contentType {
285 case "text/json", "application/json":
286 json.NewEncoder(w).Encode(data)
287 case "text/xml", "application/xml":
288 xml.NewEncoder(w).EncodeElement(data, xmlStart)
289 case "text/html":
290 if f.index != nil {
291 f.index(w, r, http.StatusOK, data.Key)
292 return
293 }
294
295 fallthrough
296 case "text/plain":
297 io.WriteString(w, data.Key)
298 }
299 }
300
301 func (f *Furl) options(w http.ResponseWriter, r *http.Request) {
302 key := path.Base(r.URL.Path)
303 if key == "" || key == "/" {
304 w.Header().Add("Allow", optionsPost)
305 } else if !f.keyValidator(key) {
306 http.Error(w, invalidKey, http.StatusUnprocessableEntity)
307
308 return
309 } else {
310 _, ok := f.store.Get(key)
311 if ok {
312 w.Header().Add("Allow", optionsGetHead)
313 } else {
314 w.Header().Add("Allow", optionsPost)
315 }
316 }
317
318 w.WriteHeader(http.StatusNoContent)
319 }
320