package git_pages

import (
	"crypto/sha256"
	"encoding/base64"
	"encoding/json"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/url"
	"slices"
	"strings"
	"time"

	"golang.org/x/net/idna"
)

type AuthError struct {
	code  int
	error string
}

func (e AuthError) Error() string {
	return e.error
}

func IsUnauthorized(err error) bool {
	var authErr AuthError
	if errors.As(err, &authErr) {
		return authErr.code == http.StatusUnauthorized
	}
	return false
}

func authorizeInsecure(r *http.Request) *Authorization {
	if config.Insecure { // for testing only
		logc.Println(r.Context(), "auth: INSECURE mode")
		return &Authorization{
			repoURLs: nil,
			branch:   "pages",
		}
	}
	return nil
}

var idnaProfile = idna.New(idna.MapForLookup(), idna.BidiRule())

func GetHost(r *http.Request) (string, error) {
	host, _, err := net.SplitHostPort(r.Host)
	if err != nil {
		host = r.Host
	}
	// this also rejects invalid characters and labels
	host, err = idnaProfile.ToASCII(host)
	if err != nil {
		if config.Feature("relaxed-idna") {
			// unfortunately, the go IDNA library has some significant issues around its
			// Unicode TR46 implementation: https://github.com/golang/go/issues/76804
			// we would like to allow *just* the _ here, but adding `idna.StrictDomainName(false)`
			// would also accept domains like `*.foo.bar` which should clearly be disallowed.
			// as a workaround, accept a domain name if it is valid with all `_` characters
			// replaced with an alphanumeric character (we use `a`); this allows e.g. `foo_bar.xxx`
			// and `foo__bar.xxx`, as well as `_foo.xxx` and `foo_.xxx`. labels starting with
			// an underscore are explicitly rejected below.
			_, err = idnaProfile.ToASCII(strings.ReplaceAll(host, "_", "a"))
		}
		if err != nil {
			return "", AuthError{http.StatusBadRequest,
				fmt.Sprintf("malformed host name %q", host)}
		}
	}
	if strings.HasPrefix(host, ".") || strings.HasPrefix(host, "_") {
		return "", AuthError{http.StatusBadRequest,
			fmt.Sprintf("reserved host name %q", host)}
	}
	host = strings.TrimSuffix(host, ".")
	return host, nil
}

func ValidateProjectName(name string) error {
	if strings.HasPrefix(name, ".") {
		return fmt.Errorf("must not start with %q", ".")
	}

	forbiddenChars := "%*\\"
	if strings.ContainsAny(name, forbiddenChars) {
		return fmt.Errorf("must not contain any of %q", forbiddenChars)
	}

	return nil
}

func GetProjectName(r *http.Request) (string, error) {
	// path must be either `/` or `/foo/` (`/foo` is accepted as an alias)
	path := strings.TrimPrefix(strings.TrimSuffix(r.URL.Path, "/"), "/")
	if err := ValidateProjectName(path); err != nil {
		return "", AuthError{http.StatusBadRequest,
			fmt.Sprintf("directory name: %v", err)}
	} else if strings.Contains(path, "/") {
		return "", AuthError{http.StatusBadRequest,
			"directories nested too deep"}
	}

	if path == "" {
		// path `/` corresponds to pseudo-project `.index`
		return ".index", nil
	} else {
		return path, nil
	}
}

type Authorization struct {
	// If `nil`, any URL is allowed. If not, only those in the set are allowed.
	repoURLs []string
	// Only the exact branch is allowed.
	branch string
	// The authorized forge user.
	forgeUser *ForgeUser
}

func (auth *Authorization) ForgeRepoURL() string {
	if auth.forgeUser != nil && len(auth.repoURLs) == 1 {
		return auth.repoURLs[0]
	}
	return ""
}

func authorizeDNSChallenge(r *http.Request) (*Authorization, error) {
	host, err := GetHost(r)
	if err != nil {
		return nil, err
	}

	authorization := r.Header.Get("Authorization")
	if authorization == "" {
		return nil, AuthError{http.StatusUnauthorized,
			"missing Authorization header"}
	}

	scheme, param, success := strings.Cut(authorization, " ")
	if !success {
		return nil, AuthError{http.StatusBadRequest,
			"malformed Authorization header"}
	}

	if scheme != "Pages" && scheme != "Basic" {
		return nil, AuthError{http.StatusBadRequest,
			"unknown Authorization scheme"}
	}

	// services like GitHub and Gogs cannot send a custom Authorization: header, but supplying
	// username and password in the URL is basically just as good
	if scheme == "Basic" {
		basicParam, err := base64.StdEncoding.DecodeString(param)
		if err != nil {
			return nil, AuthError{http.StatusBadRequest,
				"malformed Authorization: Basic header"}
		}

		username, password, found := strings.Cut(string(basicParam), ":")
		if !found {
			return nil, AuthError{http.StatusBadRequest,
				"malformed Authorization: Basic parameter"}
		}

		if username != "Pages" {
			return nil, AuthError{http.StatusUnauthorized,
				"unexpected Authorization: Basic username"}
		}

		param = password
	}

	challengeHostname := fmt.Sprintf("_git-pages-challenge.%s", host)
	actualChallenges, err := net.LookupTXT(challengeHostname)
	if err != nil {
		return nil, AuthError{http.StatusUnauthorized,
			fmt.Sprintf("failed to look up DNS challenge: %s TXT", challengeHostname)}
	}

	expectedChallenge := fmt.Sprintf("%x", sha256.Sum256(fmt.Appendf(nil, "%s %s", host, param)))
	if !slices.Contains(actualChallenges, expectedChallenge) {
		return nil, AuthError{http.StatusUnauthorized, fmt.Sprintf(
			"defeated by DNS challenge: %s TXT %v does not include %s",
			challengeHostname,
			actualChallenges,
			expectedChallenge,
		)}
	}

	return &Authorization{
		repoURLs: nil, // any
		branch:   "pages",
	}, nil
}

func authorizeDNSAllowlist(r *http.Request, scope string) (*Authorization, error) {
	host, err := GetHost(r)
	if err != nil {
		return nil, err
	}

	projectName, err := GetProjectName(r)
	if err != nil {
		return nil, err
	}

	allowlistHostname := fmt.Sprintf("_%s.%s", scope, host)
	records, err := net.LookupTXT(allowlistHostname)
	if err != nil {
		return nil, AuthError{http.StatusUnauthorized,
			fmt.Sprintf("failed to look up DNS repository allowlist: %s TXT", allowlistHostname)}
	}

	if projectName != ".index" {
		return nil, AuthError{http.StatusUnauthorized,
			"DNS repository allowlist only authorizes index site"}
	}

	var (
		repoURLs []string
		errs     []error
	)
	for _, record := range records {
		if parsedURL, err := url.Parse(record); err != nil {
			errs = append(errs, fmt.Errorf("failed to parse URL: %s TXT %q", allowlistHostname, record))
		} else if !parsedURL.IsAbs() {
			errs = append(errs, fmt.Errorf("repository URL is not absolute: %s TXT %q", allowlistHostname, record))
		} else {
			repoURLs = append(repoURLs, record)
		}
	}

	if len(repoURLs) == 0 {
		if len(records) > 0 {
			errs = append([]error{AuthError{http.StatusUnauthorized,
				fmt.Sprintf("no valid DNS TXT records for %s", allowlistHostname)}},
				errs...)
			return nil, joinErrors(errs...)
		} else {
			return nil, AuthError{http.StatusUnauthorized,
				fmt.Sprintf("no DNS TXT records found for %s", allowlistHostname)}
		}
	}

	return &Authorization{
		repoURLs: repoURLs,
		branch:   "pages",
	}, err
}

// used for `/.git-pages/...` metadata
func authorizeWildcardMatchHost(r *http.Request, pattern *WildcardPattern) (*Authorization, error) {
	host, err := GetHost(r)
	if err != nil {
		return nil, err
	}

	if _, found := pattern.Matches(host); found {
		return &Authorization{
			repoURLs: []string{},
			branch:   "",
		}, nil
	} else {
		return nil, AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()),
		}
	}
}

// used for updates to site content
func authorizeWildcardMatchSite(r *http.Request, pattern *WildcardPattern) (*Authorization, error) {
	host, err := GetHost(r)
	if err != nil {
		return nil, err
	}

	projectName, err := GetProjectName(r)
	if err != nil {
		return nil, err
	}

	if userName, found := pattern.Matches(host); found {
		repoURL, branch := pattern.ApplyTemplate(userName, projectName)
		return &Authorization{repoURLs: []string{repoURL}, branch: branch}, nil
	} else {
		return nil, AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("domain %s does not match wildcard %s", host, pattern.GetHost()),
		}
	}
}

// used for compatibility with Codeberg Pages v2
// see https://docs.codeberg.org/codeberg-pages/using-custom-domain/
func authorizeCodebergPagesV2(r *http.Request) (*Authorization, error) {
	host, err := GetHost(r)
	if err != nil {
		return nil, err
	}

	dnsRecords := []string{}

	cnameRecord, err := net.LookupCNAME(host)
	// "LookupCNAME does not return an error if host does not contain DNS "CNAME" records,
	// as long as host resolves to address records.
	if err == nil && cnameRecord != host {
		// LookupCNAME() returns a domain with the root label, i.e. `username.codeberg.page.`,
		// with the trailing dot
		dnsRecords = append(dnsRecords, strings.TrimSuffix(cnameRecord, "."))
	}

	txtRecords, err := net.LookupTXT(host)
	if err == nil {
		dnsRecords = append(dnsRecords, txtRecords...)
	}

	if len(dnsRecords) > 0 {
		logc.Printf(r.Context(), "auth: %s TXT/CNAME: %q\n", host, dnsRecords)
	}

	for _, dnsRecord := range dnsRecords {
		domainParts := strings.Split(dnsRecord, ".")
		slices.Reverse(domainParts)
		if domainParts[0] == "" {
			domainParts = domainParts[1:]
		}
		if len(domainParts) >= 3 && len(domainParts) <= 5 {
			if domainParts[0] == "page" && domainParts[1] == "codeberg" {
				// map of domain names to allowed repository and branch:
				//  * {username}.codeberg.page =>
				//      https://codeberg.org/{username}/pages.git#pages
				//  * {reponame}.{username}.codeberg.page =>
				//      https://codeberg.org/{username}/{reponame}.git#pages
				//  * {branch}.{reponame}.{username}.codeberg.page =>
				//      https://codeberg.org/{username}/{reponame}.git#{branch}
				username := domainParts[2]
				reponame := "pages"
				branch := "pages"
				if len(domainParts) >= 4 {
					reponame = domainParts[3]
				}
				if len(domainParts) == 5 {
					branch = domainParts[4]
				}
				return &Authorization{
					repoURLs: []string{
						fmt.Sprintf("https://codeberg.org/%s/%s.git", username, reponame),
					},
					branch: branch,
				}, nil
			}
		}
	}

	return nil, AuthError{
		http.StatusUnauthorized,
		fmt.Sprintf("domain %s does not have Codeberg Pages TXT or CNAME records", host),
	}
}

// Checks whether an operation that enables enumerating site contents is allowed.
func AuthorizeMetadataRetrieval(r *http.Request, hasBasicAuth bool) (*Authorization, error) {
	causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}

	auth := authorizeInsecure(r)
	if auth != nil {
		return auth, nil
	}

	auth, err := authorizeDNSChallenge(r)
	if err != nil && IsUnauthorized(err) {
		causes = append(causes, err)
	} else if err != nil { // bad request
		return nil, err
	} else {
		logc.Println(r.Context(), "auth: DNS challenge")
		return auth, nil
	}

	// Normally, sites that correspond to a forge via a wildcard match are considered completely
	// public and safe to retrieve without authorization. However, this is no longer the case if
	// they have password-protected sections.
	if !hasBasicAuth {
		for _, pattern := range wildcards {
			auth, err = authorizeWildcardMatchHost(r, pattern)
			if err != nil && IsUnauthorized(err) {
				causes = append(causes, err)
			} else if err != nil { // bad request
				return nil, err
			} else {
				logc.Printf(r.Context(), "auth: wildcard %s\n", pattern.GetHost())
				return auth, nil
			}
		}

		if config.Feature("codeberg-pages-compat") {
			auth, err = authorizeCodebergPagesV2(r)
			if err != nil && IsUnauthorized(err) {
				causes = append(causes, err)
			} else if err != nil { // bad request
				return nil, err
			} else {
				logc.Printf(r.Context(), "auth: codeberg %s\n", r.Host)
				return auth, nil
			}
		}
	}

	return nil, joinErrors(causes...)
}

func AuthorizeUpdateFromRepository(r *http.Request) (*Authorization, error) {
	causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}

	if err := CheckForbiddenDomain(r); err != nil {
		return nil, err
	}

	auth := authorizeInsecure(r)
	if auth != nil {
		return auth, nil
	}

	// DNS challenge gives absolute authority.
	auth, err := authorizeDNSChallenge(r)
	if err != nil && IsUnauthorized(err) {
		causes = append(causes, err)
	} else if err != nil { // bad request
		return nil, err
	} else {
		logc.Println(r.Context(), "auth: DNS challenge: allow *")
		return auth, nil
	}

	// DNS allowlist gives authority to update but not delete.
	if r.Method == http.MethodPut || r.Method == http.MethodPost {
		auth, err = authorizeDNSAllowlist(r, "git-pages-repository")
		if err != nil && IsUnauthorized(err) {
			causes = append(causes, err)
		} else if err != nil { // bad request
			return nil, err
		} else {
			logc.Printf(r.Context(), "auth: DNS allowlist: allow %v\n", auth.repoURLs)
			return auth, nil
		}
	}

	// Wildcard match is only available for webhooks, not the REST API.
	if r.Method == http.MethodPost {
		for _, pattern := range wildcards {
			auth, err = authorizeWildcardMatchSite(r, pattern)
			if err != nil && IsUnauthorized(err) {
				causes = append(causes, err)
			} else if err != nil { // bad request
				return nil, err
			} else {
				logc.Printf(r.Context(), "auth: wildcard %s: allow %v\n", pattern.GetHost(), auth.repoURLs)
				return auth, nil
			}
		}

		if config.Feature("codeberg-pages-compat") {
			auth, err = authorizeCodebergPagesV2(r)
			if err != nil && IsUnauthorized(err) {
				causes = append(causes, err)
			} else if err != nil { // bad request
				return nil, err
			} else {
				logc.Printf(r.Context(), "auth: codeberg %s: allow %v branch %s\n",
					r.Host, auth.repoURLs, auth.branch)
				return auth, nil
			}
		}
	}

	return nil, joinErrors(causes...)
}

func checkAllowedURLPrefixes(repoURLs ...string) error {
	if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 {
		for _, repoURL := range repoURLs {
			allowedPrefix := false
			repoURL = strings.ToLower(repoURL)
			for _, allowedRepoURLPrefix := range config.Limits.AllowedRepositoryURLPrefixes {
				if strings.HasPrefix(repoURL, strings.ToLower(allowedRepoURLPrefix)) {
					allowedPrefix = true
					break
				}
			}
			if !allowedPrefix {
				return AuthError{
					http.StatusUnauthorized,
					fmt.Sprintf("clone URL %v not in prefix allowlist %v",
						repoURL, config.Limits.AllowedRepositoryURLPrefixes),
				}
			}
		}
	}

	return nil
}

var repoURLSchemeAllowlist []string = []string{"ssh", "http", "https"}

func AuthorizeRepository(repoURL string, auth *Authorization) error {
	// Regardless of any other authorization, only the allowlisted URL schemes
	// may ever be cloned from, so this check has to come first.
	parsedRepoURL, err := url.Parse(repoURL)
	if err != nil {
		if strings.HasPrefix(repoURL, "git@") {
			return AuthError{http.StatusBadRequest, "malformed clone URL; use ssh:// scheme"}
		} else {
			return AuthError{http.StatusBadRequest, "malformed clone URL"}
		}
	}
	if !slices.Contains(repoURLSchemeAllowlist, parsedRepoURL.Scheme) {
		return AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("clone URL scheme not in allowlist %v",
				repoURLSchemeAllowlist),
		}
	}

	if auth.repoURLs == nil {
		return nil // any
	}

	if err = checkAllowedURLPrefixes(repoURL); err != nil {
		return err
	}

	allowed := false
	repoURL = strings.ToLower(repoURL)
	for _, allowedRepoURL := range auth.repoURLs {
		if repoURL == strings.ToLower(allowedRepoURL) {
			allowed = true
			break
		}
	}
	if !allowed {
		return AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("clone URL not in allowlist %v", auth.repoURLs),
		}
	}

	return nil
}

// The purpose of `allowRepoURLs` is to make sure that only authorized content is deployed
// to the site despite the fact that the non-shared-secret authorization methods allow anyone
// to impersonate the legitimate webhook sender. (If switching to another repository URL would
// be catastrophic, then so would be switching to a different branch.)
func AuthorizeBranch(branch string, auth *Authorization) error {
	if auth.repoURLs == nil {
		return nil // any
	}

	if branch == auth.branch {
		return nil
	} else {
		return AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("branch %s not in allowlist %v", branch, []string{auth.branch}),
		}
	}
}

// Gogs, Gitea, and Forgejo all support the same API here.
func checkGogsRepositoryPushPermission(baseURL *url.URL, authorization string) error {
	ownerAndRepo := strings.TrimSuffix(strings.TrimPrefix(baseURL.Path, "/"), ".git")
	request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{
		Path: fmt.Sprintf("/api/v1/repos/%s", ownerAndRepo),
	}).String(), nil)
	if err != nil {
		panic(err) // misconfiguration
	}
	request.Header.Set("Accept", "application/json")
	request.Header.Set("Authorization", authorization)

	httpClient := http.Client{Timeout: 5 * time.Second}
	response, err := httpClient.Do(request)
	if err != nil {
		return AuthError{
			http.StatusServiceUnavailable,
			fmt.Sprintf("cannot check repository permissions: %s", err),
		}
	}
	defer response.Body.Close()

	if response.StatusCode == http.StatusNotFound {
		return AuthError{
			http.StatusNotFound,
			fmt.Sprintf("no repository %s", ownerAndRepo),
		}
	} else if response.StatusCode == http.StatusUnauthorized {
		return AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("no access to %s or invalid token", ownerAndRepo),
		}
	} else if response.StatusCode != http.StatusOK {
		return AuthError{
			http.StatusServiceUnavailable,
			fmt.Sprintf(
				"cannot check repository permissions: GET %s returned %s",
				request.URL,
				response.Status,
			),
		}
	}
	decoder := json.NewDecoder(response.Body)

	var repositoryInfo struct{ Permissions struct{ Push bool } }
	if err = decoder.Decode(&repositoryInfo); err != nil {
		return errors.Join(AuthError{
			http.StatusServiceUnavailable,
			fmt.Sprintf(
				"cannot check repository permissions: GET %s returned malformed JSON",
				request.URL,
			),
		}, err)
	}

	if !repositoryInfo.Permissions.Push {
		return AuthError{
			http.StatusUnauthorized,
			fmt.Sprintf("no push permission for %s", ownerAndRepo),
		}
	}

	// this token authorizes pushing to the repo, yay!
	return nil
}

// Gogs, Gitea, and Forgejo all support the same API here.
func fetchGogsAuthorizedUser(baseURL *url.URL, forgeToken string) (*ForgeUser, error) {
	request, err := http.NewRequest("GET", baseURL.ResolveReference(&url.URL{
		Path: "/api/v1/user",
	}).String(), nil)
	if err != nil {
		panic(err) // misconfiguration
	}
	request.Header.Set("Accept", "application/json")
	request.Header.Set("Authorization", forgeToken)

	httpClient := http.Client{Timeout: 5 * time.Second}
	response, err := httpClient.Do(request)
	if err != nil {
		return nil, AuthError{
			http.StatusServiceUnavailable,
			fmt.Sprintf("cannot fetch authorized forge user: %s", err),
		}
	}
	defer response.Body.Close()

	if response.StatusCode != http.StatusOK {
		return nil, AuthError{
			http.StatusServiceUnavailable,
			fmt.Sprintf(
				"cannot fetch authorized forge user: GET %s returned %s",
				request.URL,
				response.Status,
			),
		}
	}
	decoder := json.NewDecoder(response.Body)

	var userInfo struct {
		ID    int64
		Login string
	}
	if err = decoder.Decode(&userInfo); err != nil {
		return nil, errors.Join(AuthError{
			http.StatusServiceUnavailable,
			fmt.Sprintf(
				"cannot fetch authorized forge user: GET %s returned malformed JSON",
				request.URL,
			),
		}, err)
	}

	origin := request.URL.Hostname()
	return &ForgeUser{
		Origin: &origin,
		Id:     &userInfo.ID,
		Handle: &userInfo.Login,
	}, nil
}

// Check whether a forge token has access to a repository, and if it does, which user it
// belongs to. Precondition: `repoURL` is well-formed.
func authorizeGogsUser(repoURL string, forgeToken string) (*Authorization, error) {
	parsedRepoURL, err := url.Parse(repoURL)
	if err != nil {
		panic(err)
	}

	if err = checkGogsRepositoryPushPermission(parsedRepoURL, forgeToken); err != nil {
		return nil, err
	}

	authorizedUser, err := fetchGogsAuthorizedUser(parsedRepoURL, forgeToken)
	if err != nil {
		return nil, err
	}

	return &Authorization{
		repoURLs:  []string{repoURL},
		forgeUser: authorizedUser,
	}, nil
}

// Validates a provided forge token against a repository URL constructed by mapping the host
// and project name via the `[[wildcard]]` section of the configuration file.
func authorizeForgeWildcard(r *http.Request) (*Authorization, error) {
	forgeToken := r.Header.Get("Forge-Authorization")
	if forgeToken == "" {
		return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"}
	}

	host, err := GetHost(r)
	if err != nil {
		return nil, err
	}

	projectName, err := GetProjectName(r)
	if err != nil {
		return nil, err
	}

	var errs []error
	for _, pattern := range wildcards {
		if pattern.Authorization {
			if userName, found := pattern.Matches(host); found {
				repoURL, branch := pattern.ApplyTemplate(userName, projectName)
				auth, err := authorizeGogsUser(repoURL, forgeToken)
				if err != nil {
					errs = append(errs, err)
				} else {
					auth.branch = branch
					return auth, nil
				}
			}
		}
	}
	if len(errs) == 0 {
		errs = append(errs, AuthError{http.StatusUnauthorized, "no matching wildcard domain"})
	}

	errs = append([]error{
		AuthError{http.StatusUnauthorized, "not authorized by forge (wildcard)"},
	}, errs...)
	return nil, joinErrors(errs...)
}

// Validates a provided forge token against a repository URL extracted from the DNS allowlist
// records of the target domain specified in `_git-pages-forge-authorization.*`.
func authorizeForgeDNSAllowlist(r *http.Request) (*Authorization, error) {
	forgeToken := r.Header.Get("Forge-Authorization")
	if forgeToken == "" {
		return nil, AuthError{http.StatusUnauthorized, "missing Forge-Authorization header"}
	}

	var errs []error
	if dnsAuth, err := authorizeDNSAllowlist(r, "git-pages-forge-allowlist"); err != nil {
		errs = append(errs, err)
	} else if dnsAuth != nil {
		// DNS allows uploads from some repositories, but we don't know yet if the forge token
		// has a push permission to any of these repositories.
		for _, repoURL := range dnsAuth.repoURLs {
			auth, err := authorizeGogsUser(repoURL, forgeToken)
			if err != nil {
				errs = append(errs, err)
			} else {
				// There is both DNS authorization and forge authorization.
				return auth, nil
			}
		}
	}

	errs = append([]error{
		AuthError{http.StatusUnauthorized, "not authorized by forge (DNS allowlist)"},
	}, errs...)
	return nil, joinErrors(errs...)
}

func authorizeDNSChallengeOrForgeWithToken(r *http.Request) (*Authorization, error) {
	causes := []error{AuthError{http.StatusUnauthorized, "unauthorized"}}

	if err := CheckForbiddenDomain(r); err != nil {
		return nil, err
	}

	auth := authorizeInsecure(r)
	if auth != nil {
		return auth, nil
	}

	// DNS challenge gives absolute authority.
	auth, err := authorizeDNSChallenge(r)
	if err != nil && IsUnauthorized(err) {
		causes = append(causes, err)
	} else if err != nil { // bad request
		return nil, err
	} else {
		logc.Println(r.Context(), "auth: DNS challenge: allow *")
		return auth, nil
	}

	// Token authorization allows updating a site on a wildcard domain from an archive.
	// This sub-method uses the `[[wildcard]]` configuration section to derive repository URL.
	auth, err = authorizeForgeWildcard(r)
	if err != nil && IsUnauthorized(err) {
		causes = append(causes, err)
	} else if err != nil { // bad request
		return nil, err
	} else {
		logc.Printf(r.Context(), "auth: forge (wildcard): allow\n")
		return auth, nil
	}

	// Token authorization allows updating a site on a wildcard domain from an archive.
	// This sub-method uses the DNS allowlist authorization mechanism to derive repository URL.
	auth, err = authorizeForgeDNSAllowlist(r)
	if err != nil && IsUnauthorized(err) {
		causes = append(causes, err)
	} else if err != nil { // bad request
		return nil, err
	} else {
		logc.Printf(r.Context(), "auth: forge (DNS allowlist): allow\n")
		return auth, nil
	}

	return nil, joinErrors(causes...)
}

func AuthorizeUpdateFromArchive(r *http.Request) (*Authorization, error) {
	auth, err := authorizeDNSChallengeOrForgeWithToken(r)
	if err != nil {
		return nil, err
	}

	// If only uploads from specific repositories are allowed, then only forge authorization
	// is acceptable, and the repository must match the configured limits.
	if len(config.Limits.AllowedRepositoryURLPrefixes) > 0 {
		if len(auth.repoURLs) == 0 {
			logc.Println(r.Context(), "auth: DNS challenge: deny (limits)")
			return nil, AuthError{http.StatusUnauthorized, "DNS challenge not allowed"}
		}

		if err = checkAllowedURLPrefixes(auth.repoURLs...); err != nil {
			return nil, err
		}
	}

	return auth, nil
}

func AuthorizeDeletion(r *http.Request) (*Authorization, error) {
	return authorizeDNSChallengeOrForgeWithToken(r)
}

func CheckForbiddenDomain(r *http.Request) error {
	host, err := GetHost(r)
	if err != nil {
		return err
	}

	host = strings.ToLower(host)
	for _, reservedDomain := range config.Limits.ForbiddenDomains {
		reservedDomain = strings.ToLower(reservedDomain)
		if host == reservedDomain || strings.HasSuffix(host, fmt.Sprintf(".%s", reservedDomain)) {
			return AuthError{http.StatusForbidden, "forbidden domain"}
		}
	}

	return nil
}
