Skip to content

Commit b48ccec

Browse files
committed
Support plain-HTTP self-hosted forges
--host and FORGE_HOST now accept a full http://host:port URL, and a scheme = http key is accepted under [domain] in config (settable via forge auth login --scheme). The API base URL is built from that scheme instead of hardcoding https, so a local Forgejo/Gitea on a private IP without TLS is reachable. DetectForgeType and Client.RegisterDomain accept an optional scheme prefix on the domain argument for the same reason; the bare host is still used as the registry key.
1 parent d0d5d69 commit b48ccec

11 files changed

Lines changed: 493 additions & 26 deletions

File tree

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,16 @@ type = gitlab
128128

129129
This tells forge that the project uses GitLab and that `gitlab.internal.dev` is a GitLab instance, so contributors don't each need `--forge-type` or `FORGE_HOST`.
130130

131+
For a self-hosted instance served over plain HTTP (a local Forgejo in Docker, say), add `scheme = http` to its section in `~/.config/forge/config`, use `forge auth login --scheme http`, or pass a full URL to `--host`/`FORGE_HOST`:
132+
133+
```ini
134+
[172.30.0.10:3000]
135+
type = forgejo
136+
scheme = http
137+
```
138+
139+
Committed `.forge` files cannot set the API scheme.
140+
131141
Precedence from highest to lowest: CLI flags, environment variables, `.forge`, `~/.config/forge/config`, built-in defaults.
132142

133143
## Library

detect.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ import (
1212

1313
// DetectForgeType probes a domain to identify which forge software it runs.
1414
// It checks HTTP response headers first, then falls back to API endpoints.
15-
// If hc is nil, http.DefaultClient is used.
15+
// The domain may include an http:// or https:// prefix; without one, https is
16+
// assumed. If hc is nil, http.DefaultClient is used.
1617
func DetectForgeType(ctx context.Context, domain string, hc ...*http.Client) (ForgeType, error) {
1718
client := http.DefaultClient
1819
if len(hc) > 0 && hc[0] != nil {
1920
client = hc[0]
2021
}
21-
baseURL := "https://" + domain
22+
baseURL, _, err := normalizeBaseURL(domain)
23+
if err != nil {
24+
return Unknown, err
25+
}
2226

2327
ft, err := detectFromHeaders(ctx, client, baseURL)
2428
if err != nil {

forge.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -131,15 +131,50 @@ func (c *Client) HTTPClient() *http.Client {
131131
return c.httpClient
132132
}
133133

134+
// normalizeBaseURL accepts either a bare host[:port] or a full http(s) URL and
135+
// returns (baseURL, host). A bare host is assumed https.
136+
func normalizeBaseURL(s string) (baseURL, host string, err error) {
137+
if !strings.Contains(s, "://") {
138+
s = "https://" + s
139+
}
140+
141+
u, err := url.Parse(s)
142+
if err != nil {
143+
return "", "", fmt.Errorf("invalid forge URL %q: %w", s, err)
144+
}
145+
u.Scheme = strings.ToLower(u.Scheme)
146+
if u.Scheme != "http" && u.Scheme != "https" {
147+
return "", "", fmt.Errorf("invalid forge URL scheme %q: must be http or https", u.Scheme)
148+
}
149+
if u.Host == "" {
150+
return "", "", fmt.Errorf("invalid forge URL %q: host is required", s)
151+
}
152+
if u.User != nil {
153+
return "", "", fmt.Errorf("invalid forge URL %q: user information is not supported", s)
154+
}
155+
if u.ForceQuery || u.RawQuery != "" || u.Fragment != "" {
156+
return "", "", fmt.Errorf("invalid forge URL %q: query and fragment are not supported", s)
157+
}
158+
159+
u.Path = strings.TrimRight(u.Path, "/")
160+
u.RawPath = strings.TrimRight(u.RawPath, "/")
161+
return u.String(), u.Host, nil
162+
}
163+
134164
// RegisterDomain detects the forge type for a domain and registers the
135-
// appropriate Forge using the provided builder functions.
165+
// appropriate Forge using the provided builder functions. The domain may
166+
// include an http:// or https:// prefix; without one, https is assumed.
167+
// The bare host[:port] is used as the registry key.
136168
func (c *Client) RegisterDomain(ctx context.Context, domain, token string, builders ForgeBuilders) error {
137-
ft, err := DetectForgeType(ctx, domain, c.httpClient)
169+
baseURL, domain, err := normalizeBaseURL(domain)
170+
if err != nil {
171+
return err
172+
}
173+
ft, err := DetectForgeType(ctx, baseURL, c.httpClient)
138174
if err != nil {
139175
return fmt.Errorf("detecting forge type for %s: %w", domain, err)
140176
}
141177
c.tokens[domain] = token
142-
baseURL := "https://" + domain
143178
switch ft {
144179
case GitHub:
145180
c.forges[domain] = builders.GitHub(baseURL, token, c.httpClient)

forges_test.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,66 @@ func TestDetectForgeTypeUsesProvidedClient(t *testing.T) {
200200
}
201201
}
202202

203+
func TestDetectForgeTypeAcceptsHTTPURL(t *testing.T) {
204+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
205+
w.Header().Set("X-Forgejo-Version", "7.0.0")
206+
w.WriteHeader(http.StatusOK)
207+
}))
208+
defer srv.Close()
209+
210+
// srv.URL is http://127.0.0.1:PORT — passing it directly must not be
211+
// rewritten to https.
212+
ft, err := DetectForgeType(context.Background(), srv.URL)
213+
if err != nil {
214+
t.Fatalf("unexpected error: %v", err)
215+
}
216+
if ft != Forgejo {
217+
t.Errorf("want Forgejo, got %s", ft)
218+
}
219+
}
220+
221+
func TestRegisterDomainAcceptsHTTPURL(t *testing.T) {
222+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
223+
w.Header().Set("X-Forgejo-Version", "7.0.0")
224+
w.WriteHeader(http.StatusOK)
225+
}))
226+
defer srv.Close()
227+
228+
var gotBase string
229+
c := NewClient()
230+
inputURL := srv.URL + "/forge/"
231+
err := c.RegisterDomain(context.Background(), inputURL, "tok", ForgeBuilders{
232+
Gitea: func(baseURL, token string, hc *http.Client) Forge {
233+
gotBase = baseURL
234+
return nil
235+
},
236+
})
237+
if err != nil {
238+
t.Fatalf("RegisterDomain: %v", err)
239+
}
240+
if want := srv.URL + "/forge"; gotBase != want {
241+
t.Errorf("builder got base %q, want %q", gotBase, want)
242+
}
243+
// Registry key must be the bare host, not the full URL.
244+
host := strings.TrimPrefix(srv.URL, "http://")
245+
if _, err := c.ForgeFor(host); err != nil {
246+
t.Errorf("ForgeFor(%q) after RegisterDomain(%q): %v", host, inputURL, err)
247+
}
248+
}
249+
250+
func TestNormalizeBaseURLRejectsUnsupportedURLParts(t *testing.T) {
251+
for _, input := range []string{
252+
"ftp://forge.example.com",
253+
"https://user@forge.example.com",
254+
"https://forge.example.com?query=value",
255+
"https://forge.example.com#fragment",
256+
} {
257+
if _, _, err := normalizeBaseURL(input); err == nil {
258+
t.Errorf("normalizeBaseURL(%q) should return an error", input)
259+
}
260+
}
261+
}
262+
203263
func TestDetectForgeTypeHeaders(t *testing.T) {
204264
tests := []struct {
205265
header string

internal/cli/auth.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ func authLoginCmd() *cobra.Command {
3131
token string
3232
tokenCmd string
3333
forgeType string
34+
scheme string
3435
)
3536

3637
cmd := &cobra.Command{
@@ -69,7 +70,12 @@ func authLoginCmd() *cobra.Command {
6970
}
7071
}
7172

72-
if err := config.SetDomain(domain, token, tokenCmd, forgeType); err != nil {
73+
scheme = strings.ToLower(scheme)
74+
if scheme != "" && scheme != "http" && scheme != "https" {
75+
return fmt.Errorf("invalid --scheme %q: must be http or https", scheme)
76+
}
77+
78+
if err := config.SetDomain(domain, token, tokenCmd, forgeType, scheme); err != nil {
7379
return fmt.Errorf("saving config: %w", err)
7480
}
7581

@@ -86,6 +92,7 @@ func authLoginCmd() *cobra.Command {
8692
cmd.Flags().StringVar(&token, "token", "", "API token")
8793
cmd.Flags().StringVar(&tokenCmd, "token-cmd", "", "Shell command whose stdout is used as the token")
8894
cmd.Flags().StringVar(&forgeType, "type", "", "Forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled")
95+
cmd.Flags().StringVar(&scheme, "scheme", "", "API scheme: http or https (default https). Use http for plain-HTTP self-hosted instances.")
8996
cmd.MarkFlagsMutuallyExclusive("token", "token-cmd")
9097
return cmd
9198
}

internal/cli/auth_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,33 @@ func TestAuthLoginNonInteractive(t *testing.T) {
113113
}
114114
}
115115

116+
func TestAuthLoginNormalizesScheme(t *testing.T) {
117+
resetCmd(rootCmd)
118+
dir := t.TempDir()
119+
t.Setenv("XDG_CONFIG_HOME", dir)
120+
config.ResetCache()
121+
defer config.ResetCache()
122+
123+
rootCmd.SetArgs([]string{
124+
"auth", "login",
125+
"--domain", "forgejo.example.com",
126+
"--token", "test_token_123",
127+
"--scheme", "HTTP",
128+
})
129+
130+
if err := rootCmd.Execute(); err != nil {
131+
t.Fatalf("auth login: %v", err)
132+
}
133+
134+
data, err := os.ReadFile(filepath.Join(dir, "forge", "config"))
135+
if err != nil {
136+
t.Fatalf("reading config: %v", err)
137+
}
138+
if !strings.Contains(string(data), "scheme = http") {
139+
t.Errorf("expected normalized scheme, got:\n%s", data)
140+
}
141+
}
142+
116143
func TestAuthLoginTokenCmd(t *testing.T) {
117144
resetCmd(rootCmd)
118145
dir := t.TempDir()

internal/cli/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ func Execute() error {
4646
func init() {
4747
rootCmd.PersistentFlags().StringVarP(&flagRepo, "repo", "R", "", "Select a repository (OWNER/REPO or HOST/OWNER/REPO)")
4848
rootCmd.PersistentFlags().StringVar(&flagForgeType, "forge-type", "", "Force forge type: github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled")
49-
rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com); overrides FORGE_HOST and remote detection")
49+
rootCmd.PersistentFlags().StringVar(&flagHost, "host", "", "Force forge host (e.g. gitea.com, http://forgejo.local:3000); overrides FORGE_HOST and remote detection")
5050
rootCmd.PersistentFlags().StringVarP(&flagOutput, "output", "o", "table", "Output format: table, json, plain")
5151
rootCmd.PersistentFlags().StringVar(&flagRemote, "remote", "", "Git remote to use when not specifying -R (default origin)")
5252
}

internal/config/config.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ type DefaultSection struct {
3131

3232
type DomainSection struct {
3333
Type string // github, gitlab, gitea, forgejo, bitbucket, gerrit, tangled
34+
Scheme string // http or https; only from user config (empty = https)
3435
Token string // resolved token value; only from user config, never .forge
3536
TokenExec string // non-empty when token is retrieved via a shell command (from "token-cmd" config key)
3637
SSHHost string // alternate host for git-over-ssh; the section name remains the API host
@@ -94,6 +95,17 @@ func GitProtocolFor(domain string) string {
9495
return "https"
9596
}
9697

98+
func parseScheme(v string) (string, error) {
99+
switch strings.ToLower(v) {
100+
case "http":
101+
return "http", nil
102+
case "https":
103+
return "https", nil
104+
default:
105+
return "", fmt.Errorf("invalid scheme %q: must be \"http\" or \"https\"", v)
106+
}
107+
}
108+
97109
func parseGitProtocol(v string) (string, error) {
98110
switch strings.ToLower(v) {
99111
case "ssh":
@@ -166,7 +178,7 @@ func load() (*Config, error) {
166178
return cfg, nil
167179
}
168180

169-
func loadFile(cfg *Config, path string, allowTokens bool) error {
181+
func loadFile(cfg *Config, path string, userConfig bool) error {
170182
f, err := os.Open(path)
171183
if os.IsNotExist(err) {
172184
return nil
@@ -205,19 +217,28 @@ func loadFile(cfg *Config, path string, allowTokens bool) error {
205217
if v, ok := kv["type"]; ok {
206218
ds.Type = v
207219
}
220+
if userConfig {
221+
if v, ok := kv["scheme"]; ok {
222+
s, err := parseScheme(v)
223+
if err != nil {
224+
return fmt.Errorf("%s: [%s] %w", path, name, err)
225+
}
226+
ds.Scheme = s
227+
}
228+
}
208229
if v, ok := kv["git_protocol"]; ok {
209230
p, err := parseGitProtocol(v)
210231
if err != nil {
211232
return fmt.Errorf("%s: [%s] %w", path, name, err)
212233
}
213234
ds.GitProtocol = p
214235
}
215-
if allowTokens {
236+
if userConfig {
216237
if v, ok := kv["ssh_host"]; ok {
217238
ds.SSHHost = v
218239
}
219240
}
220-
if allowTokens {
241+
if userConfig {
221242
_, hasToken := kv["token"]
222243
_, hasTokenCmd := kv["token-cmd"]
223244
if hasToken && hasTokenCmd {
@@ -317,7 +338,15 @@ func findProjectConfig(dir string) string {
317338
// SetDomain updates or adds a domain section in the user config file.
318339
// Creates the config directory if needed. Sets file permissions to 0600
319340
// since the file may contain tokens.
320-
func SetDomain(domain, token, tokenCmd, forgeType string) error {
341+
func SetDomain(domain, token, tokenCmd, forgeType, scheme string) error {
342+
if scheme != "" {
343+
normalizedScheme, err := parseScheme(scheme)
344+
if err != nil {
345+
return err
346+
}
347+
scheme = normalizedScheme
348+
}
349+
321350
path := UserConfigPath()
322351
if path == "" {
323352
return fmt.Errorf("cannot determine config path")
@@ -351,6 +380,9 @@ func SetDomain(domain, token, tokenCmd, forgeType string) error {
351380
if forgeType != "" {
352381
sections[domain]["type"] = forgeType
353382
}
383+
if scheme != "" {
384+
sections[domain]["scheme"] = scheme
385+
}
354386

355387
return writeINI(path, sections)
356388
}

0 commit comments

Comments
 (0)