@ -0,0 +1,21 @@ | |||
The MIT License (MIT) | |||
Copyright (c) 2019 Tyler Sommer <contact@tylersommer.pro> | |||
Permission is hereby granted, free of charge, to any person obtaining a copy | |||
of this software and associated documentation files (the "Software"), to deal | |||
in the Software without restriction, including without limitation the rights | |||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell | |||
copies of the Software, and to permit persons to whom the Software is | |||
furnished to do so, subject to the following conditions: | |||
The above copyright notice and this permission notice shall be included in all | |||
copies or substantial portions of the Software. | |||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR | |||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, | |||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE | |||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER | |||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, | |||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE | |||
SOFTWARE. |
@ -0,0 +1,66 @@ | |||
eok.vin | |||
======= | |||
Private, single-user, self-hosted URL shortening service. | |||
> "These are words." -- anonymous | |||
Usage | |||
----- | |||
eokvin enforces TLS, relying on LetsEncrypt to issue certificates | |||
automatically by default. Short URLs are created using a basic POST API | |||
and expire after a set time. | |||
``` | |||
Usage of ./eokvin: | |||
-cert-file string | |||
TLS certificate chain file, blank for autocert | |||
-hash-token string | |||
If given, the sha256 of the given value will be printed | |||
-host string | |||
Listen hostname (default "eok.vin") | |||
-http-port int | |||
HTTP listen port (default 80) | |||
-key-file string | |||
TLS private key file, blank for autocert | |||
-port int | |||
HTTPS listen port (default 443) | |||
-token string | |||
SHA256 of the secret token, used to authenticate | |||
``` | |||
### Important notes | |||
* A secret token is required to create new short URLs, given in the `-token` | |||
command-line option. This must be the hexadecimal string representation of | |||
the token (ie. 64 characters long) in question. | |||
* Use the `-hash-token` command-line option to print the SHA256 result | |||
(formatted as the `-hash` option expects) of a given phrase. | |||
``` | |||
$ ./eokvin -hash-token "this is a really long and arbitrary input" | |||
d1e6d468c926d9167693c190688d964fec0258c4ef4a4e1ed9cd87ea9c682156 | |||
``` | |||
* To avoid using LetsEncrypt (for example, when running locally), use a | |||
standard cert and key by using the `-cert-file` and `-key-file` | |||
command-line options. | |||
### Create a short URL | |||
Submit a POST request with Content-Type `application/x-www-form-encoded` | |||
to `/new` with the request body containing: | |||
1. The original, plaintext secret token as `token=<plain token>`. | |||
2. The real URL to redirect to for the new short URL as `url=<redirect-url>`. | |||
Upon success, the server will respond with a JSON blob. For example: | |||
```json | |||
{"short-url":"https://localhost:3000/yuzahnt5"} | |||
``` | |||
Visiting the generated URL in your browser will redirect you to the specified | |||
redirect URL. After the configured expiry duration passes (5 minutes at the | |||
time of writing), short URLs are no longer accessible and are removed from the | |||
in-memory data store. |
@ -0,0 +1,160 @@ | |||
package main | |||
import ( | |||
"crypto/sha256" | |||
"crypto/subtle" | |||
"crypto/tls" | |||
"encoding/json" | |||
"fmt" | |||
"golang.org/x/crypto/acme/autocert" | |||
"log" | |||
"net/http" | |||
"strings" | |||
) | |||
func listenAndServe() 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) | |||
return | |||
})} | |||
return srv.ListenAndServe() | |||
} | |||
func listenAndServeTLS() error { | |||
l := fmt.Sprintf("%s:%d", listenHost, listenPortHTTPS) | |||
srv := &http.Server{Addr: l, Handler: newServeMux()} | |||
if tlsKeyFile == "" && tlsCertFile == "" { | |||
hosts := append([]string{listenHost}, fmt.Sprintf("www.%s", listenHost)) | |||
m := autocert.Manager{ | |||
Prompt: autocert.AcceptTOS, | |||
HostPolicy: autocert.HostWhitelist(hosts...), | |||
Cache: autocert.DirCache("certs"), | |||
} | |||
srv.TLSConfig = &tls.Config{GetCertificate: m.GetCertificate} | |||
} | |||
return srv.ListenAndServeTLS(tlsCertFile, tlsKeyFile) | |||
} | |||
func newServeMux() *http.ServeMux { | |||
mux := http.NewServeMux() | |||
mux.Handle("/new", | |||
ensureCanonicalHost( | |||
acceptMethod("POST", | |||
requireToken(newHandler)))) | |||
mux.Handle("/", ensureCanonicalHost(indexHandler)) | |||
return mux | |||
} | |||
// ensureCanonicalHost decorates a http.Handler, ensuring that the | |||
// request being served is on the canonical hostname. | |||
func ensureCanonicalHost(h http.Handler) http.Handler { | |||
listenHostPort := fmt.Sprintf("%s:%d", listenHost, listenPortHTTPS) | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
if r.Host != listenHost && r.Host != listenHostPort { | |||
log.Println(r.Host, "doesnt equal", listenHost, "or", listenPortHTTPS) | |||
redirectToCanonicalHost(w, r) | |||
return | |||
} | |||
h.ServeHTTP(w, r) | |||
}) | |||
} | |||
// acceptMethod decorates a http.Handler, ensuring that the given HTTP method | |||
// is used in the request. | |||
func acceptMethod(m string, h http.Handler) http.Handler { | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
if r.Method != m { | |||
w.WriteHeader(http.StatusMethodNotAllowed) | |||
return | |||
} | |||
h.ServeHTTP(w, r) | |||
}) | |||
} | |||
func verifyToken(s string) bool { | |||
sv := fmt.Sprintf("%x", sha256.Sum256([]byte(s))) | |||
return subtle.ConstantTimeCompare([]byte(sv), []byte(tokenSHA256)) == 1 | |||
} | |||
// requireToken decorates a http.Handler, ensuring that the request has a valid | |||
// token identifier. | |||
func requireToken(h http.Handler) http.Handler { | |||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
if !verifyToken(r.PostFormValue("token")) { | |||
w.WriteHeader(http.StatusForbidden) | |||
return | |||
} | |||
h.ServeHTTP(w, r) | |||
}) | |||
} | |||
// redirectToCanonicalHost responds with a redirect to the canonical host. | |||
func redirectToCanonicalHost(w http.ResponseWriter, r *http.Request) { | |||
t := canonicalHost + r.URL.Path | |||
http.Redirect(w, r, t, http.StatusMovedPermanently) | |||
if _, err := fmt.Fprintf(w, `<a href="%s">Redirecting...</a>`, t); err != nil { | |||
log.Println("error writing response:", err.Error()) | |||
} | |||
} | |||
// 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) { | |||
link := r.PostFormValue("url") | |||
if len(link) == 0 { | |||
w.WriteHeader(http.StatusBadRequest) | |||
return | |||
} | |||
k, err := urlStore.newItemID() | |||
if err != nil { | |||
w.WriteHeader(http.StatusInternalServerError) | |||
return | |||
} | |||
urlStore.mu.Lock() | |||
urlStore.entries[k] = newItem(link) | |||
urlStore.mu.Unlock() | |||
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)) | |||
if err != nil { | |||
log.Println("error writing response:", err.Error()) | |||
} | |||
return | |||
} | |||
w.WriteHeader(http.StatusCreated) | |||
if _, err = fmt.Fprintln(w, string(b)); err != nil { | |||
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) { | |||
key := strings.TrimLeft(r.URL.Path, "/") | |||
if len(key) == 0 { | |||
w.WriteHeader(http.StatusBadRequest) | |||
return | |||
} | |||
k := itemID(key) | |||
urlStore.mu.RLock() | |||
v, ok := urlStore.entries[k] | |||
urlStore.mu.RUnlock() | |||
if ok { | |||
if urlStore.isExpired(v) { | |||
// rely on the reaper function to actually delete items. | |||
w.WriteHeader(http.StatusNotFound) | |||
return | |||
} | |||
u := v.String() | |||
http.Redirect(w, r, u, http.StatusMovedPermanently) | |||
if _, err := fmt.Fprintf(w, `<a href="%s">Redirecting...</a>`, u); err != nil { | |||
log.Println("error writing response:", err.Error()) | |||
} | |||
return | |||
} | |||
w.WriteHeader(http.StatusNotFound) | |||
return | |||
}) |
@ -0,0 +1,89 @@ | |||
package main | |||
import ( | |||
"crypto/sha256" | |||
"flag" | |||
"fmt" | |||
"log" | |||
"os" | |||
"time" | |||
) | |||
// Runtime configuration values. | |||
var listenHost string | |||
var listenPortHTTPS int | |||
var listenPortHTTP int | |||
var canonicalHost string | |||
var tokenSHA256 string | |||
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 | |||
// urlStore contains URL values that map to short, random string keys. | |||
var urlStore = &store{entries: make(map[itemID]item), ttl: urlTTL} | |||
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.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) | |||
} | |||
func main() { | |||
validate() | |||
// Launch the HTTP redirect server. | |||
go func() { | |||
if err := listenAndServe(); err != nil { | |||
log.Println(err.Error()) | |||
} | |||
}() | |||
// Launch the HTTPS main server. | |||
go func() { | |||
if err := listenAndServeTLS(); err != nil { | |||
log.Println(err.Error()) | |||
} | |||
}() | |||
// Launch the expired entry reaper. | |||
go func() { | |||
if err := urlStore.expiredItemReaper(); err != nil { | |||
log.Println(err.Error()) | |||
} | |||
}() | |||
// Yield forever. | |||
select {} | |||
} | |||
@ -0,0 +1,93 @@ | |||
package main | |||
import ( | |||
"crypto/rand" | |||
"errors" | |||
"math/big" | |||
"sync" | |||
"time" | |||
) | |||
// randSet is the set of characters used when generating a random key. | |||
const randSet = "abcdefghijklmnopqrstuvwxyz1234567890" | |||
const randSetLen = int64(len(randSet)) | |||
// An itemID is a unique key in a store. | |||
type itemID string | |||
func (c itemID) String() string { | |||
return string(c) | |||
} | |||
// An item is a single item, stored in a store. | |||
type item struct { | |||
value string | |||
insertedAt time.Time | |||
} | |||
func (c item) String() string { | |||
return c.value | |||
} | |||
// A store is an in-memory data store with expiring items. | |||
type store struct { | |||
mu sync.RWMutex | |||
entries map[itemID]item | |||
ttl time.Duration | |||
} | |||
// newItemID creates a new store key, ensuring it is unique. | |||
func (cch *store) newItemID() (itemID, error) { | |||
b := make([]byte, 8) | |||
l := big.NewInt(randSetLen) | |||
for i := 0; i < 8; i++ { | |||
n, err := rand.Int(rand.Reader, l) | |||
if err != nil { | |||
return "", err | |||
} | |||
b[i] = randSet[int(n.Int64())] | |||
} | |||
k := itemID(b) | |||
cch.mu.RLock() | |||
defer cch.mu.RUnlock() | |||
// Avoid overwriting existing entries | |||
if _, ok := cch.entries[k]; ok { | |||
return "", errors.New("cache: collision detected") | |||
} | |||
return k, nil | |||
} | |||
// isExpired returns true if the given item is expired. | |||
func (cch *store) isExpired(c item) bool { | |||
return c.insertedAt.Before(time.Now().Add(-1 * cch.ttl)) | |||
} | |||
// expiredItemReaper deletes expired entries from the store at regular | |||
// intervals. | |||
func (cch *store) expiredItemReaper() error { | |||
for { | |||
select { | |||
case <-time.After(30 * time.Second): | |||
var del []itemID | |||
cch.mu.RLock() | |||
for k, v := range cch.entries { | |||
if cch.isExpired(v) { | |||
del = append(del, k) | |||
} | |||
} | |||
cch.mu.RUnlock() | |||
if len(del) == 0 { | |||
continue | |||
} | |||
cch.mu.Lock() | |||
for _, k := range del { | |||
delete(cch.entries, k) | |||
} | |||
cch.mu.Unlock() | |||
} | |||
} | |||
} | |||
// newItem initializes a new item. | |||
func newItem(s string) item { | |||
return item{value: s, insertedAt: time.Now()} | |||
} | |||