mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11:25 +01:00
243 lines
8.6 KiB
Go
243 lines
8.6 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package settings
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// RemoteIsSSH reports whether a Git remote uses SSH: an ssh:// URL or an scp-style user@host:path.
|
|
func RemoteIsSSH(remote string) bool {
|
|
return strings.HasPrefix(remote, "ssh://") || scpLike(remote)
|
|
}
|
|
|
|
func scpLike(remote string) bool {
|
|
return !strings.Contains(remote, "://") && strings.Contains(remote, "@")
|
|
}
|
|
|
|
// ParseRemote parses a Git remote URL, reading scp-style user@host:path as ssh://user@host/path
|
|
// without interpreting shell syntax.
|
|
func ParseRemote(raw string) (*url.URL, error) {
|
|
if user, path, ok := strings.Cut(raw, ":"); ok && scpLike(raw) {
|
|
raw = "ssh://" + user + "/" + path
|
|
}
|
|
return url.Parse(raw)
|
|
}
|
|
|
|
// RepositoryURL resolves a repository URL or Git remote to a configured instance and its owner/name.
|
|
// Unconfigured HTTP(S) URLs take the final two path segments as the repository.
|
|
func RepositoryURL(raw string, hosts map[string]Host) (string, string, error) {
|
|
u, err := ParseRemote(raw)
|
|
if err != nil || u.Host == "" || u.RawQuery != "" || u.Fragment != "" || (u.Scheme != "https" && u.Scheme != "http" && u.Scheme != "ssh") {
|
|
return "", "", errors.New("invalid repository URL")
|
|
}
|
|
password := false
|
|
if u.User != nil {
|
|
_, password = u.User.Password()
|
|
}
|
|
if u.User != nil && (u.Scheme != "ssh" || password) {
|
|
return "", "", errors.New("repository URLs must not embed credentials")
|
|
}
|
|
slug, cloneURL := strings.CutSuffix(strings.Trim(u.Path, "/"), ".git")
|
|
bestHost, bestRepo, bestLen := "", "", -1
|
|
matchedHost := false
|
|
remoteSSH, remoteErr := SSHEndpoint(u.Host)
|
|
for instance, host := range hosts {
|
|
base, _ := url.Parse(instance)
|
|
sshHost := host.SSHHost
|
|
if sshHost == "" {
|
|
sshHost = base.Hostname()
|
|
if strings.Contains(sshHost, ":") {
|
|
sshHost = "[" + sshHost + "]"
|
|
}
|
|
}
|
|
prefix := strings.Trim(base.Path, "/")
|
|
configuredSSH, configuredErr := SSHEndpoint(sshHost)
|
|
match := (u.Scheme == "ssh" && remoteErr == nil && configuredErr == nil && remoteSSH == configuredSSH) || (u.Scheme == base.Scheme && strings.EqualFold(u.Host, base.Host))
|
|
if !match {
|
|
continue
|
|
}
|
|
matchedHost = true
|
|
candidate := slug
|
|
if u.Scheme != "ssh" && prefix != "" {
|
|
if !strings.HasPrefix(slug, prefix+"/") {
|
|
continue
|
|
}
|
|
candidate = strings.TrimPrefix(slug, prefix+"/")
|
|
}
|
|
if !ValidSlug(candidate) {
|
|
continue
|
|
}
|
|
if bestHost != "" && u.Scheme == "ssh" && bestHost != instance {
|
|
return "", "", errors.New("SSH remote matches multiple instances; select --host")
|
|
}
|
|
if len(prefix) == bestLen && bestHost != instance {
|
|
return "", "", errors.New("repository URL matches multiple instances; select --host")
|
|
}
|
|
if len(prefix) > bestLen {
|
|
bestHost, bestRepo, bestLen = instance, candidate, len(prefix)
|
|
}
|
|
}
|
|
if bestHost != "" {
|
|
return bestHost, bestRepo, nil
|
|
}
|
|
if u.Scheme == "ssh" {
|
|
return "", "", errors.New("SSH remote does not match a configured instance")
|
|
}
|
|
if matchedHost {
|
|
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
|
}
|
|
parts := strings.Split(slug, "/")
|
|
if len(parts) < 2 {
|
|
return "", "", errors.New("repository URL requires owner/repository")
|
|
}
|
|
repo := strings.Join(parts[len(parts)-2:], "/")
|
|
// A .git suffix marks a clone URL, whose repository may share its name with a web route.
|
|
if !ValidSlug(repo) || (!cloneURL && looksLikeWebResourcePath(parts)) {
|
|
return "", "", errors.New("repository URL path is ambiguous; use owner/repository or a clone URL")
|
|
}
|
|
u.Path = "/" + strings.Join(parts[:len(parts)-2], "/")
|
|
u.RawPath, u.RawQuery, u.Fragment, u.User = "", "", "", nil
|
|
instance, err := NormalizeHost(u.String())
|
|
return instance, repo, err
|
|
}
|
|
|
|
// looksLikeWebResourcePath detects Gitea HTML routes such as owner/repo/issues/7, which have a
|
|
// web route keyword in a segment after the possible owner/repo. Taking the final two segments
|
|
// from those paths would invent the wrong repository.
|
|
func looksLikeWebResourcePath(parts []string) bool {
|
|
if len(parts) < 3 {
|
|
return false
|
|
}
|
|
for _, part := range parts[2:] {
|
|
switch strings.ToLower(part) {
|
|
case "issues", "pulls", "pull", "commit", "commits", "src", "tree", "blob", "raw",
|
|
"compare", "releases", "tags", "wiki", "actions", "settings", "projects",
|
|
"packages", "labels", "milestones", "activities", "forks", "watchers", "stars":
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ValidSlug reports whether slug is exactly owner/name.
|
|
func ValidSlug(slug string) bool {
|
|
pieces := strings.Split(slug, "/")
|
|
return len(pieces) == 2 && validPiece(pieces[0]) && validPiece(pieces[1])
|
|
}
|
|
|
|
// validPiece reports whether piece can be an owner or repository name.
|
|
func validPiece(piece string) bool {
|
|
// ':' keeps scp-style remotes such as git@host:owner/name.git from passing as a slug.
|
|
return piece != "" && piece != "." && piece != ".." && !strings.ContainsAny(piece, ":?#\\ \t\r\n")
|
|
}
|
|
|
|
// RepositoryRef is a parsed repository argument.
|
|
type RepositoryRef struct {
|
|
// Host is the instance URL the argument names, or "" when it names none.
|
|
Host string
|
|
// Owner is "" for a bare NAME, which belongs to the active account.
|
|
Owner, Name string
|
|
}
|
|
|
|
// Slug returns owner/name, or the bare name when the owner is not known yet.
|
|
func (r RepositoryRef) Slug() string {
|
|
if r.Owner == "" {
|
|
return r.Name
|
|
}
|
|
return r.Owner + "/" + r.Name
|
|
}
|
|
|
|
// ParseRepository reads a repository argument: NAME, OWNER/NAME, HOST/OWNER/NAME (with an
|
|
// optional .git suffix), or a clone or web URL. HOST may carry a port and a configured
|
|
// instance's base path; it takes the scheme of the configured instance it names, else https.
|
|
func ParseRepository(raw string, hosts map[string]Host) (RepositoryRef, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if strings.Contains(raw, "://") || scpLike(raw) {
|
|
host, slug, err := RepositoryURL(raw, hosts)
|
|
if err != nil {
|
|
return RepositoryRef{}, err
|
|
}
|
|
owner, name, _ := strings.Cut(slug, "/")
|
|
return RepositoryRef{Host: host, Owner: owner, Name: name}, nil
|
|
}
|
|
invalid := fmt.Errorf("invalid repository %q: use OWNER/REPO, HOST/OWNER/REPO or a URL", raw)
|
|
trimmed := strings.TrimSuffix(raw, ".git")
|
|
pieces := strings.Split(trimmed, "/")
|
|
switch {
|
|
case len(pieces) == 1 && validPiece(pieces[0]):
|
|
return RepositoryRef{Name: pieces[0]}, nil
|
|
case len(pieces) == 2 && ValidSlug(trimmed):
|
|
return RepositoryRef{Owner: pieces[0], Name: pieces[1]}, nil
|
|
case len(pieces) < 3:
|
|
return RepositoryRef{}, invalid
|
|
}
|
|
// The longest configured instance prefix wins, so example.org/gitea/owner/name selects the
|
|
// instance installed below /gitea rather than example.org.
|
|
best, tied := "", false
|
|
for instance := range hosts {
|
|
prefix := bareInstance(instance)
|
|
if !strings.HasPrefix(strings.ToLower(trimmed), strings.ToLower(prefix)+"/") || !ValidSlug(trimmed[len(prefix)+1:]) {
|
|
continue
|
|
}
|
|
switch longest := len(bareInstance(best)); {
|
|
case len(prefix) > longest:
|
|
best, tied = instance, false
|
|
case len(prefix) == longest && best != "":
|
|
tied = true // http and https of one host: the scheme decides, not map order.
|
|
}
|
|
}
|
|
if tied {
|
|
return RepositoryRef{}, fmt.Errorf("instance %q is configured for both http and https; include the scheme", bareInstance(best))
|
|
}
|
|
if best != "" {
|
|
owner, name, _ := strings.Cut(trimmed[len(bareInstance(best))+1:], "/")
|
|
return RepositoryRef{Host: best, Owner: owner, Name: name}, nil
|
|
}
|
|
if len(pieces) != 3 || !ValidSlug(pieces[1]+"/"+pieces[2]) {
|
|
return RepositoryRef{}, invalid
|
|
}
|
|
host, err := ResolveHost(pieces[0], hosts)
|
|
if err != nil {
|
|
return RepositoryRef{}, invalid
|
|
}
|
|
return RepositoryRef{Host: host, Owner: pieces[1], Name: pieces[2]}, nil
|
|
}
|
|
|
|
// ResolveHost normalizes an instance given as a URL or as a bare HOST[:PORT][/PATH]. A bare
|
|
// host takes the scheme of the configured instance it names, else https.
|
|
func ResolveHost(raw string, hosts map[string]Host) (string, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if strings.Contains(raw, "://") {
|
|
return NormalizeHost(raw)
|
|
}
|
|
bare := strings.TrimSuffix(raw, "/")
|
|
var matches []string
|
|
for instance := range hosts {
|
|
if strings.EqualFold(bareInstance(instance), bare) {
|
|
matches = append(matches, instance)
|
|
}
|
|
}
|
|
switch len(matches) {
|
|
case 0:
|
|
instance, err := NormalizeHost("https://" + raw)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid instance %q: use HOST[:PORT][/PATH] or an http(s) URL", raw)
|
|
}
|
|
return instance, nil
|
|
case 1:
|
|
return matches[0], nil
|
|
}
|
|
return "", fmt.Errorf("instance %q is configured for both http and https; include the scheme", raw)
|
|
}
|
|
|
|
// bareInstance returns an instance URL without its scheme, such as localhost:3001/gitea.
|
|
func bareInstance(instance string) string {
|
|
_, bare, _ := strings.Cut(instance, "://")
|
|
return bare
|
|
}
|