kernelschmelze.de

URL Shortener

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"}