// Package sign provides utilities to generate signed URLs for Amazon CloudFront.
//
// More information about signed URLs and their structure can be found at:
// http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-creating-signed-url-canned-policy.html
//
// To sign a URL create a URLSigner with your private key and credential pair key ID.
// Once you have a URLSigner instance you can call Sign or SignWithPolicy to
// sign the URLs.
//
// Example:
//
//	// Sign URL to be valid for 1 hour from now.
//	signer := sign.NewURLSigner(keyID, privKey)
//	signedURL, err := signer.Sign(rawURL, time.Now().Add(1*time.Hour))
//	if err != nil {
//	    log.Fatalf("Failed to sign url, err: %s\n", err.Error())
//	}
package sign

import (
	"crypto/rsa"
	"fmt"
	"net/url"
	"strings"
	"time"
)

// An URLSigner provides URL signing utilities to sign URLs for Amazon CloudFront
// resources. Using a private key and Credential Key Pair key ID the URLSigner
// only needs to be created once per Credential Key Pair key ID and private key.
//
// The signer is safe to use concurrently.
type URLSigner struct {
	keyID   string
	privKey *rsa.PrivateKey
}

// NewURLSigner constructs and returns a new URLSigner to be used to for signing
// Amazon CloudFront URL resources with.
func NewURLSigner(keyID string, privKey *rsa.PrivateKey) *URLSigner {
	return &URLSigner{
		keyID:   keyID,
		privKey: privKey,
	}
}

// Sign will sign a single URL to expire at the time of expires sign using the
// Amazon CloudFront default Canned Policy. The URL will be signed with the
// private key and Credential Key Pair Key ID previously provided to URLSigner.
//
// This is the default method of signing Amazon CloudFront URLs. If extra policy
// conditions are need other than URL expiry use SignWithPolicy instead.
//
// Example:
//
//	// Sign URL to be valid for 1 hour from now.
//	signer := sign.NewURLSigner(keyID, privKey)
//	signedURL, err := signer.Sign(rawURL, time.Now().Add(1*time.Hour))
//	if err != nil {
//	    log.Fatalf("Failed to sign url, err: %s\n", err.Error())
//	}
func (s URLSigner) Sign(url string, expires time.Time) (string, error) {
	scheme, cleanedURL, err := cleanURLScheme(url)
	if err != nil {
		return "", err
	}

	resource, err := CreateResource(scheme, url)
	if err != nil {
		return "", err
	}

	return signURL(scheme, cleanedURL, s.keyID, NewCannedPolicy(resource, expires), false, s.privKey)
}

// SignWithPolicy will sign a URL with the Policy provided.  The URL will be
// signed with the private key and Credential Key Pair Key ID previously provided to URLSigner.
//
// Use this signing method if you are looking to sign a URL with more than just
// the URL's expiry time, or reusing Policies between multiple URL signings.
// If only the expiry time is needed you can use Sign and provide just the
// URL's expiry time. A minimum of at least one policy statement is required for a signed URL.
//
// Note: It is not safe to use Polices between multiple signers concurrently
//
// Example:
//
//	// Sign URL to be valid for 30 minutes from now, expires one hour from now, and
//	// restricted to the 192.0.2.0/24 IP address range.
//	policy := &sign.Policy{
//	    Statements: []sign.Statement{
//	        {
//	            Resource: rawURL,
//	            Condition: sign.Condition{
//	                // Optional IP source address range
//	                IPAddress: &sign.IPAddress{SourceIP: "192.0.2.0/24"},
//	                // Optional date URL is not valid until
//	                DateGreaterThan: &sign.AWSEpochTime{time.Now().Add(30 * time.Minute)},
//	                // Required date the URL will expire after
//	                DateLessThan: &sign.AWSEpochTime{time.Now().Add(1 * time.Hour)},
//	            },
//	        },
//	    },
//	}
//
//	signer := sign.NewURLSigner(keyID, privKey)
//	signedURL, err := signer.SignWithPolicy(rawURL, policy)
//	if err != nil {
//	    log.Fatalf("Failed to sign url, err: %s\n", err.Error())
//	}
func (s URLSigner) SignWithPolicy(url string, p *Policy) (string, error) {
	scheme, cleanedURL, err := cleanURLScheme(url)
	if err != nil {
		return "", err
	}

	return signURL(scheme, cleanedURL, s.keyID, p, true, s.privKey)
}

func signURL(scheme, url, keyID string, p *Policy, customPolicy bool, privKey *rsa.PrivateKey) (string, error) {
	// Validation URL elements
	if err := validateURL(url); err != nil {
		return "", err
	}

	b64Signature, b64Policy, err := p.Sign(privKey)
	if err != nil {
		return "", err
	}

	// build and return signed URL
	builtURL := buildSignedURL(url, keyID, p, customPolicy, b64Policy, b64Signature)
	if scheme == "rtmp" {
		return buildRTMPURL(builtURL)
	}

	return builtURL, nil
}

func buildSignedURL(baseURL, keyID string, p *Policy, customPolicy bool, b64Policy, b64Signature []byte) string {
	pred := "?"
	if strings.Contains(baseURL, "?") {
		pred = "&"
	}
	signedURL := baseURL + pred

	if customPolicy {
		signedURL += "Policy=" + string(b64Policy)
	} else {
		signedURL += fmt.Sprintf("Expires=%d", p.Statements[0].Condition.DateLessThan.UTC().Unix())
	}
	signedURL += fmt.Sprintf("&Signature=%s&Key-Pair-Id=%s", string(b64Signature), keyID)

	return signedURL
}

func buildRTMPURL(u string) (string, error) {
	parsed, err := url.Parse(u)
	if err != nil {
		return "", fmt.Errorf("unable to parse rtmp signed URL, err: %s", err)
	}

	rtmpURL := strings.TrimLeft(parsed.Path, "/")
	if parsed.RawQuery != "" {
		rtmpURL = fmt.Sprintf("%s?%s", rtmpURL, parsed.RawQuery)
	}

	return rtmpURL, nil
}

func cleanURLScheme(u string) (scheme, cleanedURL string, err error) {
	parts := strings.SplitN(u, "://", 2)
	if len(parts) != 2 {
		return "", "", fmt.Errorf("invalid URL, missing scheme and domain/path")
	}
	scheme = strings.Replace(parts[0], "*", "", 1)
	cleanedURL = fmt.Sprintf("%s://%s", scheme, parts[1])

	return strings.ToLower(scheme), cleanedURL, nil
}

var illegalQueryParms = []string{"Expires", "Policy", "Signature", "Key-Pair-Id"}

func validateURL(u string) error {
	parsed, err := url.Parse(u)
	if err != nil {
		return fmt.Errorf("unable to parse URL, err: %s", err.Error())
	}

	if parsed.Scheme == "" {
		return fmt.Errorf("URL missing valid scheme, %s", u)
	}

	q := parsed.Query()
	for _, p := range illegalQueryParms {
		if _, ok := q[p]; ok {
			return fmt.Errorf("%s cannot be a query parameter for a signed URL", p)
		}
	}

	return nil
}
