package handlers import ( "crypto/rand" "crypto/subtle" "encoding/base64" "encoding/hex" "fmt" "log" "net/http" "net/url" "strings" "time" "github.com/gorilla/mux" "github.com/pkg/errors" bolt "go.etcd.io/bbolt" "gitea.hashru.nl/dsprenkels/rushlink/db" "gitea.hashru.nl/dsprenkels/rushlink/gobmarsh" ) type pasteType int type pasteState int type storedPaste struct { Type pasteType State pasteState Content []byte Key string DeleteToken [16]byte TimeCreated time.Time } const ( typeUndef pasteType = 0 typePaste = 1 typeRedirect = 2 ) const ( stateUndef pasteState = 0 statePresent = 1 stateDeleted = 2 ) type viewPaste uint const ( _ viewPaste = 1 << iota viewNoRedirect viewShowMeta ) const CookieDeleteToken = "owner_token" // These keys are designated reserved, and will not be randomly chosen var ReservedPasteKeys = []string{"xd42", "example"} // Base64 encoding and decoding var base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" var base64Encoder = base64.RawURLEncoding.WithPadding(base64.NoPadding) func (t pasteType) String() string { switch t { case typeUndef: return "unknown" case typePaste: return "paste" case typeRedirect: return "redirect" default: return "invalid" } } func (t pasteState) String() string { switch t { case stateUndef: return "unknown" case statePresent: return "present" case stateDeleted: return "deleted" default: return "invalid" } } func indexGetHandler(w http.ResponseWriter, r *http.Request) { render(w, r, "index", map[string]interface{}{}) } func viewPasteHandler(w http.ResponseWriter, r *http.Request) { viewPasteHandlerInner(w, r, 0) } func viewPasteHandlerNoRedirect(w http.ResponseWriter, r *http.Request) { viewPasteHandlerInner(w, r, viewNoRedirect) } func viewPasteHandlerMeta(w http.ResponseWriter, r *http.Request) { viewPasteHandlerInner(w, r, viewShowMeta) } func viewPasteHandlerInner(w http.ResponseWriter, r *http.Request, flags viewPaste) { vars := mux.Vars(r) key := vars["key"] var storedPaste *storedPaste if err := db.DB.View(func(tx *bolt.Tx) error { var err error storedPaste, err = getURL(tx, key) return err }); err != nil { log.Printf("error: %v\n", err) renderInternalServerError(w, r, err) return } if storedPaste == nil { renderError(w, r, http.StatusNotFound, "url key not found in the database") return } if flags&viewShowMeta != 0 { canDelete := struct { Bool bool String string }{Bool: false} deleteToken, err := getDeleteTokenFromRequest(r) if err != nil { canDelete.String = "invalid" } else if deleteToken == nil { canDelete.String = "undefined" } else { if subtle.ConstantTimeCompare(deleteToken[:], storedPaste.DeleteToken[:]) == 1 { canDelete.Bool = true canDelete.String = "correct" } else { canDelete.String = "invalid" } } data := map[string]interface{}{ "Paste": storedPaste, "CanDelete": canDelete, } render(w, r, "pasteMeta", data) return } switch storedPaste.State { case statePresent: if flags&viewNoRedirect == 0 { rawurl := string(storedPaste.Content) urlParse, err := url.Parse(rawurl) if err != nil { log.Printf("error: invalid URL ('%v') in database for key '%v': %v\n", rawurl, storedPaste.Key, err) renderInternalServerError(w, r, "invalid url in database") return } http.Redirect(w, r, urlParse.String(), http.StatusSeeOther) } w.Write(storedPaste.Content) case stateDeleted: renderError(w, r, http.StatusGone, "key has been deleted") default: log.Printf("error: invalid storedPaste.State (%v) for key '%v'\n", storedPaste.State, storedPaste.Key) msg := fmt.Sprintf("internal server error: invalid storedPaste.State (%v\n)", storedPaste.State) renderInternalServerError(w, r, msg) } } func newPasteHandler(w http.ResponseWriter, r *http.Request) { if err := r.ParseMultipartForm(50 * 1000 * 1000); err != nil { log.Printf("error: %v\n", err) renderInternalServerError(w, r, err) return } // Determine what kind of post this is, currently only `shorten=...` if len(r.PostForm) == 0 { renderError(w, r, http.StatusBadRequest, "empty body in POST request\n") return } shorten_values, prs := r.PostForm["shorten"] if !prs { renderError(w, r, http.StatusBadRequest, "no 'shorten' param given\n") return } if len(shorten_values) != 1 { renderError(w, r, http.StatusBadRequest, "only one 'shorten' param is allowed per request\n") return } newRedirectPasteHandler(w, r) } func newRedirectPasteHandler(w http.ResponseWriter, r *http.Request) { rawurl := r.PostForm.Get("shorten") userURL, err := url.ParseRequestURI(rawurl) if err != nil { msg := fmt.Sprintf("invalid url (%v): %v", err, rawurl) renderError(w, r, http.StatusBadRequest, msg) return } if userURL.Scheme == "" { renderError(w, r, http.StatusBadRequest, "invalid url (unspecified scheme)") return } if userURL.Host == "" { renderError(w, r, http.StatusBadRequest, "invalid url (unspecified host)") return } var storedPaste *storedPaste if err := db.DB.Update(func(tx *bolt.Tx) error { // Generate a new delete token for this paste deleteToken, err := generateDeleteToken() if err != nil { return errors.Wrap(err, "generating delete token") } sp, err := shortenURL(tx, userURL, deleteToken) storedPaste = sp return err }); err != nil { log.Printf("error: %v\n", err) renderInternalServerError(w, r, err) return } deleteToken := hex.EncodeToString(storedPaste.DeleteToken[:]) saveRawurl := fmt.Sprintf("%v/%v?deleteToken=%v", r.Host, string(storedPaste.Key), deleteToken) saveURL, err := r.URL.Parse(saveRawurl) if err != nil { err = errors.Wrap(err, "parsing url") log.Printf("error: %v\n", err) renderInternalServerError(w, r, err) return } // TODO(dsprenkels) Put this into a template w.WriteHeader(http.StatusOK) fmt.Fprintf(w, "%v\n", saveURL) } // Delete a URL from the database func deletePasteHandler(w http.ResponseWriter, r *http.Request) { // TODO(dsprenkels) LEFT HERE; this functionality still untested vars := mux.Vars(r) key := vars["key"] deleteToken, err := getDeleteTokenFromRequest(r) if err != nil { renderError(w, r, http.StatusBadRequest, "invalid delete token") return } else if deleteToken == nil { renderError(w, r, http.StatusBadRequest, "no delete token provided") return } var errorCode int if err := db.DB.Update(func(tx *bolt.Tx) error { paste, err := getURL(tx, key) if err != nil { errorCode = http.StatusNotFound return err } if subtle.ConstantTimeCompare(deleteToken[:], paste.DeleteToken[:]) == 1 { // Replace the old paste with a new empty paste return savePaste(tx, key, storedPaste{ Key: paste.Key, State: stateDeleted, DeleteToken: paste.DeleteToken, }) } errorCode = http.StatusForbidden return errors.New("invalid delete token") }); err != nil { log.Printf("error: %v\n", err) renderError(w, r, errorCode, fmt.Sprintf("error: %v", err)) return } } // Retrieve a URL from the database func getURL(tx *bolt.Tx, key string) (*storedPaste, error) { pastesBucket := tx.Bucket([]byte(db.BUCKET_PASTES)) if pastesBucket == nil { return nil, errors.Errorf("bucket %v does not exist", db.BUCKET_PASTES) } storedBytes := pastesBucket.Get([]byte(key)) if storedBytes == nil { return nil, nil } storedPaste := &storedPaste{} err := gobmarsh.Unmarshal(storedBytes, storedPaste) return storedPaste, err } // Add a new URL to the database // // Returns the new ID if the url was successfully shortened func shortenURL(tx *bolt.Tx, userURL *url.URL, deleteToken [16]byte) (*storedPaste, error) { pastesBucket := tx.Bucket([]byte(db.BUCKET_PASTES)) if pastesBucket == nil { return nil, errors.Errorf("bucket %v does not exist", db.BUCKET_PASTES) } // Generate a key until it is not in the database, this occurs in O(log N), // where N is the amount of keys stored in the url-shorten database. epoch := 0 var urlKey string for { var err error urlKey, err = generateURLKey(epoch) if err != nil { return nil, errors.Wrap(err, "url-key generation failed") } found := pastesBucket.Get([]byte(urlKey)) if found == nil { break } isReserved := false for _, reservedKey := range ReservedPasteKeys { if strings.HasPrefix(urlKey, reservedKey) { isReserved = true break } } if !isReserved { break } epoch++ } // Store the new key storedPaste := storedPaste{ Type: typeRedirect, State: statePresent, Content: []byte(userURL.String()), Key: urlKey, DeleteToken: deleteToken, TimeCreated: time.Now().UTC(), } if err := savePaste(tx, urlKey, storedPaste); err != nil { return nil, err } return &storedPaste, nil } func savePaste(tx *bolt.Tx, key string, paste storedPaste) error { bucket := tx.Bucket([]byte(db.BUCKET_PASTES)) if bucket == nil { return errors.Errorf("bucket %v does not exist", db.BUCKET_PASTES) } buf, err := gobmarsh.Marshal(paste) if err != nil { return errors.Wrap(err, "encoding for database failed") } if err := bucket.Put([]byte(key), buf); err != nil { return errors.Wrap(err, "database transaction failed") } return nil } func generateURLKey(epoch int) (string, error) { urlKey := make([]byte, 4+epoch) _, err := rand.Read(urlKey) if err != nil { return "", err } // Put all the values in the range 0..64 for easier base64-encoding for i := 0; i < len(urlKey); i++ { urlKey[i] &= 0x3F } // Implement truncate-resistance by forcing the prefix to // 0b111110xxxxxxxxxx // ^----- {epoch} ones followed by a single 0 // // Example when epoch is 1: prefix is 0b10. i := 0 for i < epoch { // Set this bit to 1 limb := i / 6 bit := i % 6 urlKey[limb] |= 1 << uint(5-bit) i++ } // Finally set the next bit to 0 limb := i / 6 bit := i % 6 urlKey[limb] &= ^(1 << uint(5-bit)) // Convert this ID to a canonical base64 notation for i := range urlKey { urlKey[i] = base64Alphabet[urlKey[i]] } return string(urlKey), nil } func generateDeleteToken() ([16]byte, error) { var deleteToken [16]byte _, err := rand.Read(deleteToken[:]) if err != nil { return deleteToken, err } return deleteToken, nil } func getDeleteTokenFromRequest(r *http.Request) (*[16]byte, error) { deleteTokenQuery := r.URL.Query().Get("deleteToken") if deleteTokenQuery == "" { return nil, nil } var deleteToken [16]byte n, err := hex.Decode(deleteToken[:], []byte(deleteTokenQuery)) if err != nil { return nil, errors.Wrap(err, "decoding hex") } else if n != 16 { return nil, errors.Errorf("invalid deleteToken length (%v bytes)", n) } return &deleteToken, nil }