mirror of https://github.com/veonik/eokvin
commit
b648598bba
@ -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()}
|
||||
}
|
||||
|
Loading…
Reference in new issue