Initial commit

This commit is contained in:
Daan Sprenkels 2019-08-25 21:33:56 +02:00
parent a9771a889b
commit 061bb23625
8 changed files with 381 additions and 0 deletions

2
.gitignore vendored
View File

@ -17,3 +17,5 @@
# Generated code from assets using bindata
bindata.go
# Output binary
/rushlink

10
assets/index.txt Normal file
View File

@ -0,0 +1,10 @@
#RU URL SHORTENER
=================
Based on https://0x0.st/, this site allows you to easily shorten URLs using
the command line.
## USAGE
# Shorten a URL
curl -F'shorten=http://example.com/some/long/url' https://hashru.link

97
db.go Normal file
View File

@ -0,0 +1,97 @@
package main
import (
"fmt"
"log"
gobmarsh "gitea.hashru.nl/dsprenkels/rushlink/gobmarsh"
bolt "go.etcd.io/bbolt"
)
// The current database version
//
// If we alter the database format, we bump this number and write a new
// database migration in migrateDatabase().
const CURRENT_MIGRATE_VERSION = 1
// Bucket storing everything that is not a bulk value. This includes stuff like
// the database version, secret site-wide keys.
const BUCKET_CONF = "conf"
// The main bucket for URL shortening values
const BUCKET_SHORTEN = "shorten"
// This value stores the current migration version. If this value is less than
// CURRENT_MIGRATE_VERSION, the database has to be migrated.
const KEY_MIGRATE_VERSION = "migrate_version"
// Initialize and migrate the database to the current version
func migrateDatabase(tx *bolt.Tx) error {
dbVersion, err := dbVersion(tx)
if err != nil {
return err
}
// Migrate the database to version 1
if dbVersion < 1 {
log.Println("migrating database to version 1")
// Create conf bucket
_, err := tx.CreateBucket([]byte(BUCKET_CONF))
if err != nil {
return err
}
// Create URL shortening bucket
_, err = tx.CreateBucket([]byte(BUCKET_SHORTEN))
if err != nil {
return err
}
// Update the version number
if err := setDBVersion(tx, 1); err != nil {
return err
}
}
return nil
}
// Get the current migrate version from the database
func dbVersion(tx *bolt.Tx) (int, error) {
conf := tx.Bucket([]byte(BUCKET_CONF))
if conf == nil {
return 0, nil
}
dbVersionBytes := conf.Get([]byte(KEY_MIGRATE_VERSION))
if dbVersionBytes == nil {
return 0, nil
}
// Version was already stored
var dbVersion int
if err := gobmarsh.Unmarshal(dbVersionBytes, &dbVersion); err != nil {
return 0, err
}
if dbVersion == 0 {
return 0, fmt.Errorf("database version is invalid (%v)", dbVersion)
}
if dbVersion > CURRENT_MIGRATE_VERSION {
return 0, fmt.Errorf("database version is too recent (%v > %v)", dbVersion, CURRENT_MIGRATE_VERSION)
}
return dbVersion, nil
}
// Update the current migrate version in the database
func setDBVersion(tx *bolt.Tx, version int) error {
conf, err := tx.CreateBucketIfNotExists([]byte(BUCKET_CONF))
if err != nil {
return err
}
versionBytes, err := gobmarsh.Marshal(version)
if err != nil {
return err
}
return conf.Put([]byte(KEY_MIGRATE_VERSION), versionBytes)
}

7
go.mod
View File

@ -1,3 +1,10 @@
module gitea.hashru.nl/dsprenkels/rushlink
go 1.12
require (
github.com/go-bindata/go-bindata v3.1.2+incompatible // indirect
github.com/gorilla/mux v1.7.3
github.com/pkg/errors v0.8.1
go.etcd.io/bbolt v1.3.3
)

10
go.sum Normal file
View File

@ -0,0 +1,10 @@
github.com/go-bindata/go-bindata v3.1.2+incompatible h1:5vjJMVhowQdPzjE1LdxyFF7YFTXg5IgGVW4gBr5IbvE=
github.com/go-bindata/go-bindata v3.1.2+incompatible/go.mod h1:xK8Dsgwmeed+BBsSy2XTopBn/8uK2HWuGSnA11C3Joo=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/joomcode/errorx v1.0.0 h1:RJAKLTy1Sv2Tszhu14m5RZP4VGRlhXutG/XlL1En5VM=
github.com/joomcode/errorx v1.0.0/go.mod h1:kgco15ekB6cs+4Xjzo7SPeXzx38PbJzBwbnu9qfVNHQ=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
go.etcd.io/bbolt v1.3.3 h1:MUGmc65QhB3pIlaQ5bB4LwqSj6GIonVJXpZiaKNyaKk=
go.etcd.io/bbolt v1.3.3/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=

View File

@ -1,5 +1,7 @@
package gobmarsh
// Easier marshalling to and from gob encoding
import (
"bytes"
"encoding/gob"

185
index.go Normal file
View File

@ -0,0 +1,185 @@
package main
//go:generate go get github.com/go-bindata/go-bindata
//go:generate go get -u github.com/go-bindata/go-bindata/...
//go:generate go-bindata -pkg $GOPACKAGE assets/
import (
"crypto/rand"
"fmt"
"io"
"log"
"net/http"
"net/url"
"time"
gobmarsh "gitea.hashru.nl/dsprenkels/rushlink/gobmarsh"
errors "github.com/pkg/errors"
bolt "go.etcd.io/bbolt"
)
type StoredURLState int
type StoredURL struct {
State StoredURLState
RawURL string
Key []byte
TimeCreated time.Time
}
const (
Present StoredURLState = iota
Deleted
)
var base64Alphabet = []byte("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_")
var indexContents = MustAsset("assets/index.txt")
func indexGetHandler(w http.ResponseWriter, r *http.Request) {
_, err := w.Write(indexContents)
if err != nil {
panic(err)
}
}
func indexPostHandler(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(50 * 1000 * 1000); err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "Internal server error: %v", err)
return
}
// Determine what kind of post this is, currently only `shorten=...`
if len(r.PostForm) == 0 {
w.WriteHeader(http.StatusBadRequest)
var buf []byte
r.Body.Read(buf)
io.WriteString(w, "empty body in POST request")
return
}
shorten_values, prs := r.PostForm["shorten"]
if !prs {
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, "no 'shorten' param supplied")
return
}
if len(shorten_values) != 1 {
w.WriteHeader(http.StatusBadRequest)
io.WriteString(w, "only one 'shorten' param is allowed per request")
return
}
shortenPostHandler(w, r)
}
func shortenPostHandler(w http.ResponseWriter, r *http.Request) {
rawurl := r.PostForm.Get("shorten")
userURL, err := url.ParseRequestURI(rawurl)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "invalid url (%v): %v", err, rawurl)
return
}
if userURL.Scheme == "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "invalid url (unspecified scheme)", rawurl)
return
}
if userURL.Host == "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, "invalid url (unspecified host)", rawurl)
return
}
var storedURL *StoredURL
if err := db.Update(func(tx *bolt.Tx) error {
u, err := shortenURL(tx, userURL)
storedURL = u
return err
}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, "internal server error: %v", err)
return
}
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "new URL saved: %+v", storedURL)
}
// 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) (*StoredURL, error) {
shortenBucket := tx.Bucket([]byte(BUCKET_SHORTEN))
if shortenBucket == nil {
return nil, fmt.Errorf("bucket %v does not exist", BUCKET_SHORTEN)
}
// 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 []byte
for {
var err error
urlKey, err = generateURLKey(epoch)
if err != nil {
return nil, errors.Wrap(err, "url-key generation failed")
}
found := shortenBucket.Get(urlKey)
if found == nil {
break
}
epoch++
}
// Store the new key
storedURL := StoredURL{
State: Present,
RawURL: userURL.String(),
Key: urlKey,
TimeCreated: time.Now().UTC(),
}
storedBytes, err := gobmarsh.Marshal(storedURL)
if err != nil {
return nil, errors.Wrap(err, "encoding for database failed")
}
log.Println(urlKey)
if err := shortenBucket.Put(urlKey, storedBytes); err != nil {
return nil, errors.Wrap(err, "database transaction failed")
}
return &storedURL, nil
}
func generateURLKey(epoch int) ([]byte, error) {
urlKey := make([]byte, 4+epoch)
_, err := rand.Read(urlKey)
if err != nil {
return nil, 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
return urlKey, nil
}

68
rushlink.go Normal file
View File

@ -0,0 +1,68 @@
package main
import (
"flag"
"log"
"net/http"
"time"
mux "github.com/gorilla/mux"
errors "github.com/pkg/errors"
bolt "go.etcd.io/bbolt"
)
type ParsedArguments struct {
databaseName string
}
var appConfig ParsedArguments
var db *bolt.DB
func main() {
// Parse the arguments and construct the ParsedArguments
appConfigRef, err := parseArguments()
if err != nil {
log.Fatal(err)
}
appConfig = *appConfigRef
// Open the bolt database
db, err = bolt.Open(appConfig.databaseName, 0666, &bolt.Options{Timeout: 1 * time.Second})
if err != nil {
decoratedErr := errors.Wrapf(err, "failed to open database at '%v'", appConfig.databaseName)
log.Fatal(decoratedErr)
}
defer db.Close()
err = db.Update(migrateDatabase)
if err != nil {
decoratedErr := errors.Wrapf(err, "failed to migrate database")
log.Fatal(decoratedErr)
}
// Initialize Gorilla router
router := mux.NewRouter()
router.HandleFunc("/", indexGetHandler).Methods("GET")
router.HandleFunc("/", indexPostHandler).Methods("POST")
// Start the server
srv := &http.Server{
Handler: router,
Addr: "127.0.0.1:8000",
WriteTimeout: 15 * time.Second,
ReadTimeout: 15 * time.Second,
}
log.Fatal(srv.ListenAndServe())
}
// Parse the input arguments and return the initialized application config struct
func parseArguments() (*ParsedArguments, error) {
config := ParsedArguments{}
flag.StringVar(&config.databaseName, "database", "", "Location of the database file")
flag.Parse()
if config.databaseName == "" {
return nil, errors.New("database not set")
}
return &config, nil
}