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