mirror of https://github.com/veonik/eokvin
commit
b648598bba
5 changed files with 429 additions and 0 deletions
@ -0,0 +1,21 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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()} |
||||
} |
||||
|
Loading…
Reference in new issue