From 5abb3b74372c58bd38f9710a268f48996d2186e6 Mon Sep 17 00:00:00 2001 From: Tyler Sommer Date: Thu, 7 Mar 2019 16:17:33 -0700 Subject: [PATCH] Finish up initial mvp --- client.go | 76 +++++++++++++++++++++++++++++++++++ cmd/eokify/main.go | 48 +++++++++++++++++++++++ cmd/eokvin/http.go | 18 ++++----- cmd/eokvin/main.go | 98 +++++++++++++++++++++++++++++++--------------- 4 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 client.go create mode 100644 cmd/eokify/main.go diff --git a/client.go b/client.go new file mode 100644 index 0000000..30e6727 --- /dev/null +++ b/client.go @@ -0,0 +1,76 @@ +package eokvin // import "github.com/veonik/eokvin" + +import ( + "crypto/tls" + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "net/url" +) + +type ShortURL struct { + url.URL + + Original *url.URL +} + +type Client struct { + http *http.Client + + Endpoint string + Token string +} + +func NewClient(token string) *Client { + return &Client{ + http: &http.Client{}, + Endpoint: "https://eok.vin/new", + Token: token, + } +} + +func NewInsecureClient(token string) *Client { + return &Client{ + http: &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}}, + Endpoint: "https://localhost:3000/new", + Token: token, + } +} + +func (c *Client) NewShortURLString(lu string) (*ShortURL, error) { + ou, err := url.Parse(lu) + if err != nil { + return nil, err + } + return c.NewShortURL(ou) +} + +func (c *Client) NewShortURL(ou *url.URL) (*ShortURL, error) { + resp, err := c.http.PostForm( + c.Endpoint, + url.Values{"token": {c.Token}, "url": {ou.String()}}) + if err != nil { + return nil, err + } + defer resp.Body.Close() + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + r := struct { + Error string `json:"error"` + ShortURL string `json:"short-url"` + }{} + if err := json.Unmarshal(b, &r); err != nil { + return nil, err + } + if r.Error != "" { + return nil, errors.New(r.Error) + } + u, err := url.Parse(r.ShortURL) + if err != nil { + return nil, err + } + return &ShortURL{URL: *u, Original: ou}, nil +} diff --git a/cmd/eokify/main.go b/cmd/eokify/main.go new file mode 100644 index 0000000..5e50091 --- /dev/null +++ b/cmd/eokify/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "flag" + "fmt" + "os" + "log" + + "github.com/veonik/eokvin" +) + +var token string +var endpoint string +var insecure bool + +func main() { + flag.StringVar(&token, "token", "", "Secret token, required") + flag.StringVar(&endpoint, "endpoint", "https://eok.vin/new", "URL for the running eokvin server") + flag.BoolVar(&insecure, "insecure", false,"If enabled, allows endpoint to be insecure") + flag.Parse() + + if token == "" { + log.Fatal("token cannot be blank") + } + if endpoint == "" { + log.Fatal("endpoint cannot be blank") + } + + args := flag.Args() + if len(args) != 1 { + log.Fatalf("expected 1 argument, not %d", len(os.Args)) + } + + u := args[0] + + var c *eokvin.Client + if !insecure { + c = eokvin.NewClient(token) + } else { + c = eokvin.NewInsecureClient(token) + } + c.Endpoint = endpoint + s, err := c.NewShortURLString(u) + if err != nil { + log.Fatalf("error creating short URL: %s", err.Error()) + } + fmt.Println(s.String()) +} diff --git a/cmd/eokvin/http.go b/cmd/eokvin/http.go index acfe564..6261106 100644 --- a/cmd/eokvin/http.go +++ b/cmd/eokvin/http.go @@ -3,16 +3,16 @@ package main import ( "crypto/sha256" "crypto/subtle" - "crypto/tls" "encoding/json" "fmt" - "golang.org/x/crypto/acme/autocert" "log" "net/http" "strings" + + "golang.org/x/crypto/acme/autocert" ) -func listenAndServe() error { +func listenAndServeRedirect() error { l := fmt.Sprintf("%s:%d", listenHost, listenPortHTTP) srv := &http.Server{Addr: l, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { redirectToCanonicalHost(w, r) @@ -31,7 +31,7 @@ func listenAndServeTLS() error { HostPolicy: autocert.HostWhitelist(hosts...), Cache: autocert.DirCache("certs"), } - srv.TLSConfig = &tls.Config{GetCertificate: m.GetCertificate} + srv.TLSConfig = m.TLSConfig() } return srv.ListenAndServeTLS(tlsCertFile, tlsKeyFile) } @@ -100,7 +100,7 @@ func redirectToCanonicalHost(w http.ResponseWriter, r *http.Request) { } // newHandler is an http.Handler that creates a new item in the urlStore store. -var newHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +var newHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { link := r.PostFormValue("url") if len(link) == 0 { w.WriteHeader(http.StatusBadRequest) @@ -114,7 +114,7 @@ var newHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *ht urlStore.mu.Lock() urlStore.entries[k] = newItem(link) urlStore.mu.Unlock() - b, err := json.Marshal(map[string]string{"short-url": canonicalHost + k.String()}) + b, err := json.Marshal(map[string]string{"short-url": canonicalHost + "/" + k.String()}) if err != nil { w.WriteHeader(http.StatusInternalServerError) _, err = fmt.Fprintf(w, `{"error": "%s"}\n`, strings.Replace(err.Error(), `"`, `'`, -1)) @@ -128,11 +128,11 @@ var newHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *ht log.Println("error writing response:", err.Error()) } return -}) +} // indexHandler is a catch-all http.Handler that attempts to lookup items in // the store based on request path. -var indexHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { +var indexHandler http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { key := strings.TrimLeft(r.URL.Path, "/") if len(key) == 0 { w.WriteHeader(http.StatusBadRequest) @@ -157,4 +157,4 @@ var indexHandler http.Handler = http.HandlerFunc(func(w http.ResponseWriter, r * } w.WriteHeader(http.StatusNotFound) return -}) \ No newline at end of file +} \ No newline at end of file diff --git a/cmd/eokvin/main.go b/cmd/eokvin/main.go index 70d4a22..bb3a470 100644 --- a/cmd/eokvin/main.go +++ b/cmd/eokvin/main.go @@ -1,11 +1,20 @@ +// Command eokvin is a private, single-user, self-hosted URL shortening server. +// +// Short URLs expire after a time and are created by way of HTTP request +// with a static secret token required for authentication. Upon creation, the +// requester will receive the newly generated short URL which can be freely +// accessed by anyone with the link until it expires. package main import ( "crypto/sha256" + "errors" "flag" "fmt" "log" "os" + "strconv" + "strings" "time" ) @@ -15,75 +24,100 @@ var listenPortHTTPS int var listenPortHTTP int var canonicalHost string var tokenSHA256 string +var urlTTL time.Duration var tlsKeyFile string var tlsCertFile string var rawToken string -// urlTTL contains the time.Duration describing how long a short URL shall -// remain accessible. -const urlTTL = 10 * time.Minute +// ShortURLTTL the duration a short URL shall remain accessible. +const ShortURLTTL = 30 * time.Minute + // urlStore contains URL values that map to short, random string keys. -var urlStore = &store{entries: make(map[itemID]item), ttl: urlTTL} +var urlStore = &store{entries: make(map[itemID]item), ttl: ShortURLTTL} func init() { flag.StringVar(&listenHost, "host", "eok.vin", "Listen hostname") flag.IntVar(&listenPortHTTPS, "port", 443, "HTTPS listen port") flag.IntVar(&listenPortHTTP, "http-port", 80, "HTTP listen port") + flag.DurationVar(&urlTTL, "url-ttl", 30, "Short URLs expire after this delay") flag.StringVar(&tlsKeyFile, "key-file", "", "TLS private key file, blank for autocert") flag.StringVar(&tlsCertFile, "cert-file", "", "TLS certificate chain file, blank for autocert") flag.StringVar(&tokenSHA256, "token", "", "SHA256 of the secret token, used to authenticate") - flag.StringVar(&rawToken, "hash-token", "", "If given, the sha256 of the given value will be printed") -} - -func validate() { - flag.Parse() - - if len(rawToken) > 0 { - fmt.Printf("%x\n", sha256.Sum256([]byte(rawToken))) - os.Exit(0) - return - } - - if len(listenHost) == 0 { - log.Fatal("host cannot be blank") - } - if listenPortHTTPS <= 0 { - log.Fatal("port must be > 0") - } - if listenPortHTTP <= 0 { - log.Fatal("http-port must be > 0") - } - if len(tokenSHA256) != 64 { - log.Fatal("token must be a valid sha256 sum") - } - canonicalHost = fmt.Sprintf("https://%s:%d/", listenHost, listenPortHTTPS) + flag.StringVar(&rawToken, "hash-token", "", "If given, the sha256 of the value will be printed") } func main() { - validate() + // Read and validate configuration. + if err := parseFlags(); err != nil { + log.Fatal(err) + } - // Launch the HTTP redirect server. + // Launch the HTTP->HTTPS redirect server. go func() { - if err := listenAndServe(); err != nil { + log.Println("starting HTTP") + if err := listenAndServeRedirect(); err != nil { log.Println(err.Error()) } }() // Launch the HTTPS main server. go func() { + log.Println("starting TLS") if err := listenAndServeTLS(); err != nil { log.Println(err.Error()) } }() // Launch the expired entry reaper. go func() { + log.Println("starting expired item reaper") if err := urlStore.expiredItemReaper(); err != nil { log.Println(err.Error()) } }() + log.Println("started all goroutines") // Yield forever. select {} } +// parseFlags reads command line options and ensures the program state is +// configured properly, returning an error if it is not. +func parseFlags() error { + flag.Parse() + + if len(rawToken) > 0 { + fmt.Printf("%x\n", sha256.Sum256([]byte(rawToken))) + os.Exit(0) + return nil + } + + if len(listenHost) == 0 { + return errors.New("host cannot be blank") + } + if listenPortHTTPS <= 0 { + return errors.New("port must be > 0") + } + if listenPortHTTP <= 0 { + return errors.New("http-port must be > 0") + } + if len(tokenSHA256) != 64 { + return errors.New("token must be a valid sha256 sum") + } + + parts := []string{"https://", listenHost} + if listenPortHTTPS != 443 { + parts = append(parts, ":", strconv.Itoa(listenPortHTTPS)) + } + + canonicalHost = fmt.Sprintf("%s", strings.Join(parts, "")) + + log.Println("listen host:", listenHost) + log.Println("listen tls port:", listenPortHTTPS) + log.Println("listen http port:", listenPortHTTP) + log.Println("canonical host:", canonicalHost) + log.Println("url ttl:", urlTTL) + log.Println("token:", tokenSHA256) + + return nil +}