URL Shortener

#go #dropshare #minio

Mit Dropshare habe ich ein Programm gefunden, mit dem ich einfach und schnell Dateien in einen Cloud Speicher bewegen kann. Ich betreibe seit einiger Zeit einen eigenen S3 Server mit Minio, um diverse Dateien temporär zu parken oder schnell mit Freunden zu teilen. Dropshare biete die Möglichkeit einen S3 API kompatiblen Server für den Upload zu konfigurieren und funktioniert somit auch mit Minio.

Also einfach eine Datei mit Dropshare in den eigenen Cloud Speicher bewegen und Dropshare legt die Download URL in’s Clipboard. Soweit der Plan. Mein S3 Bucket ist allerdings privat und die so erzeugte Download URL wird beim Aufruf mit “du kommst hier nicht rein” quittiert. Um Dateien aus einem privaten Bucket zu teilen, ohne die eigenen Credentials weiterzugeben, benötigt man presigned URL’s, die temporär gültig sind.

Wie kommt nun Dropshare an diese presigned URL? Nativ unterstützt Dropshare diese Funktion nicht. Allerdings kann man in Dropshare einen eigenen URL Shortener konfigurieren. Keiner sagt das so ein Shortener nur URL’s kürzen muss. Er kann als Zwischenschritt ja auch eine presigned URL beim S3 Server anfragen.

HTTP Server

Als erstes benötigen wir einen einfachen HTTP Server der zwei Anfragen verarbeiten kann. Eine zum kürzen einer URL und eine für die Rückauflösung der gekürzten URL zum ursprünglichen Ziel.

func main() {
	// snip 
	http.Handle("/short/", http.HandlerFunc(handler.encode))
	http.Handle("/go/", http.HandlerFunc(handler.redirect))
	log.Printf("Listening at port %d", port)
	if err := http.ListenAndServe(fmt.Sprintf(":%d", port), nil); err != http.ErrServerClosed {
		log.Printf("%v", err)
	} else {
		log.Println("Server closed!")
	}
}

Encode

Encode ermittelt die zu kürzende URL aus dem Query Parameter <SERVER>:<PORT>/short/?url=xyz, prüft ob wir einen Bucket Handler haben und übergibt die URL dem Bucket Handler. Anschließend gehts weiter zum Store Handler der eine ID für eine URL erzeugt und das Mapping speichert. Als Antwort erhält der Aufrufer im Erfolgsfall ein Json Objekt {"success":true, "shorturl":"<SERVER>:<PORT>/go/id"}.

func (h *Handler) encode(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet && r.Method != http.MethodPost {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	var url = ""
	query := r.URL.RawQuery
	if query != "" && strings.HasPrefix(query, "url=") {
		url = strings.TrimPrefix(query, "url=")
	}
	if url == "" {
		w.WriteHeader(http.StatusBadRequest)
		return
	}
	if h.minio != nil { // exist minio handler
		url, _ = h.minio.Process(url)
	}
	key, err := h.store.Add(url)
	if err != nil || key == "" {
		w.WriteHeader(http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(http.StatusOK)
	json, _ := sjson.Set("", "success", true)
	json, _ = sjson.Set(json, "shorturl", h.prefix+key)
	fmt.Fprint(w, json)
}

Redirect

Redirect übernimmt die Rückauflösung der gekürzten URL <SERVER>:<PORT>/go/id zum ursprünglichen Ziel und schickt diese als HTTP 301 Antwort zum Aufrufer zurück, der automatisch weitergeleitet wird.

func (h *Handler) redirect(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}
	key := r.URL.Path[len("/go/"):]
	url, err := h.store.Get(key)
	if err != nil {
		w.WriteHeader(http.StatusNotFound)
		return
	}
	if !strings.HasPrefix(url, "http") {
		url = "http://" + url
	}
	http.Redirect(w, r, url, http.StatusMovedPermanently)
}

Bucket Handler

Initialisierung des Minio Client. Wenn der S3 Server über TLS erreichbar ist, kann man optional ein CA Zertifikat konfigurieren, welches dann als vertrauenswürdig in den Transporthandler integriert wird. So mußte ich für mein Let’s Encrypt Zertifikat das CA explizit einbinden, da sonst die Verbindung zum S3 Server mit einem Zertifikatsfehler abgelehnt wurde.

func New(c Config) *Handler {
	if c.Endpoint == "" || c.AccessKeyID == "" || c.SecretAccessKey == "" {
		return nil
	}
	useSSL := strings.HasPrefix(c.Endpoint, "https")
	host := c.Endpoint
	c.Endpoint = strings.TrimPrefix(c.Endpoint, "http://")
	c.Endpoint = strings.TrimPrefix(c.Endpoint, "https://")
	minioClient, _ := minio.New(c.Endpoint, c.AccessKeyID, c.SecretAccessKey, useSSL)
	if c.CaCert != "" {
		caCert, err := ioutil.ReadFile(c.CaCert)
		if err != nil {
			log.Println(err)
		}
		rootCAs := x509.NewCertPool()
		rootCAs.AppendCertsFromPEM(caCert)
		transport := &http.Transport{
			TLSClientConfig: &tls.Config{RootCAs: rootCAs},
			Proxy:           http.ProxyFromEnvironment,
			DialContext: (&net.Dialer{
				Timeout:   30 * time.Second,
				KeepAlive: 30 * time.Second,
			}).DialContext,
			MaxIdleConns:          100,
			IdleConnTimeout:       90 * time.Second,
			TLSHandshakeTimeout:   10 * time.Second,
			ExpectContinueTimeout: 1 * time.Second,
			DisableCompression:    true,
		}
		minioClient.SetCustomTransport(transport)
		if err != nil {
			log.Println(err)
		}
	}
	return &Handler{minioClient, host + "/"}
}

Die Process Funktion nimmt als Parameter die zu kürzende URL auf, prüft ob diese zum eigenen Bucket passt, ermittelt das Bucket und den Dateinamen, prüft nochmal beim S3 Server ob beides existiert und fragt dann beim S3 Server nach einer presigned URL für diese Datei an und übergibt sie wieder dem Aufrufer. Sollte die URL nicht zum eigenen Bucket passen, wird die original URL zurückgeliefert.

func (p *Handler) Process(file string) (string, error) {
	filename := file
	if strings.HasPrefix(filename, p.host) != true {
		return file, nil
	}
	filename = strings.TrimPrefix(filename, p.host)
	index := strings.Index(filename, "/")
	if index == -1 {
		return file, nil
	}
	bucket := filename[0:index]
	filename = filename[index:len(filename)]
	filename = strings.TrimPrefix(filename, "/")
	if filename == "" || bucket == "" {
		return file, nil
	}
	_, err := p.client.StatObject(bucket, filename, minio.StatObjectOptions{})
	if err != nil {
		log.Println(err)
		return file, err
	}
	reqParams := make(url.Values)
	reqParams.Set("response-content-disposition", "attachment; filename=\""+filename+"\"")
	presignedURL, err := p.client.PresignedGetObject(bucket, filename, time.Second*24*60*60*7, reqParams)
	if err != nil {
		log.Println(err)
		return file, err
	}
	return presignedURL.String(), nil
} 

Setup

$ ./short -help
  -minio string
        minio config (default "./.minio.conf")
  -port int
        server port (default 8080)
  -prefix string
        url prefix (default "localhost:8080/go/")

$ cat .minio.conf
{
	"Endpoint": "https://<HOST>:<PORT>",
	"AccessKeyID": "<S3 Credentials>",
	"SecretAccessKey": "<S3 Credentials>",
	"CaCert": "./lets-encrypt-x3-cross-signed.pem"
}

Der Shortener nimmt 3 optionale Parameter auf. Den Port auf dem der HTTP Server lauschen soll, ein URL Prefix und ggf. die Minio Konfiguration.

./short -port 63359 -prefix https://example.de:63359/go/

Test

Die Funktion des Shortener läßt sich dann einfach mit cURL testen.

$ curl -X POST "https://<HOST>:<PORT>/short/?url=google.de"
{"success":true,"shorturl":"https://<HOST>:<PORT>/go/ZQRjcO"}

Wenn ich die Zeit finde werde ich die Sourcen über Github zugänglich machen. Bis dahin stehen sie auf Anfrage zur Verfügung.