vikunja/veans/internal/client/discover.go

144 lines
4.6 KiB
Go

// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package client
import (
"context"
"errors"
"net/url"
"strings"
"code.vikunja.io/veans/internal/output"
)
// defaultAPIPort is what `VIKUNJA_SERVICE_INTERFACE` ships with — handy
// when the user types just `myhost.example.com` for a default install
// running on an unusual port.
const defaultAPIPort = "3456"
// DiscoverServer normalizes `input` and probes a small set of plausible
// URLs for /api/v1/info, returning the canonical base URL (without the
// /api/v1 suffix — that's what client.New expects) and the parsed Info.
//
// Mirrors the discovery the Vikunja web frontend does in
// helpers/checkAndSetApiUrl.ts: try the URL as-given, with /api/v1
// appended, and with the default :3456 port — across http / https. The
// first response that parses as Info wins.
func DiscoverServer(ctx context.Context, input string) (string, *Info, error) {
input = strings.TrimSpace(input)
if input == "" {
return "", nil, output.New(output.CodeValidation, "server URL is required")
}
candidates, err := serverCandidates(input)
if err != nil {
return "", nil, output.Wrap(output.CodeValidation, err,
"can't parse server URL %q: %v", input, err)
}
var attempts []string
var lastErr error
for _, base := range candidates {
attempts = append(attempts, base+"/api/v1/info")
info, err := New(base, "").Info(ctx)
if err == nil && info != nil {
return base, info, nil
}
lastErr = err
}
return "", nil, output.New(output.CodeValidation,
"couldn't find a Vikunja instance reachable from %q — tried:\n - %s\nlast error: %v",
input, strings.Join(attempts, "\n - "), lastErr)
}
// serverCandidates expands `input` into the ordered list of base URLs
// to probe for /api/v1/info. A "base URL" here is what client.New wants:
// the origin + the path that should sit BEFORE /api/v1 (typically empty
// or a reverse-proxy prefix). The probe itself adds /api/v1/info.
func serverCandidates(input string) ([]string, error) {
// Strip a trailing /api/v1[/] the user might have copied from a
// curl example. We add it back in the probe, and otherwise we'd
// end up calling /api/v1/api/v1/info.
trimmed := strings.TrimRight(input, "/")
trimmed = strings.TrimSuffix(trimmed, "/api/v1")
trimmed = strings.TrimRight(trimmed, "/")
withScheme := trimmed
if !strings.HasPrefix(withScheme, "http://") && !strings.HasPrefix(withScheme, "https://") {
withScheme = defaultScheme(trimmed) + "://" + trimmed
}
u, err := url.Parse(withScheme)
if err != nil {
return nil, err
}
if u.Host == "" {
return nil, errors.New("missing host")
}
// Build the candidate set, dedup-preserving-order. The order here
// is the search policy: as-given, with default port, then the
// opposite scheme for each. Stops on the first one that responds
// with a parseable Info.
var bases []string
add := func(scheme, host, path string) {
base := scheme + "://" + host + strings.TrimRight(path, "/")
base = strings.TrimRight(base, "/")
for _, existing := range bases {
if existing == base {
return
}
}
bases = append(bases, base)
}
hosts := []string{u.Host}
if u.Port() == "" {
hosts = append(hosts, u.Hostname()+":"+defaultAPIPort)
}
schemes := []string{u.Scheme}
if u.Scheme == "https" {
schemes = append(schemes, "http")
} else {
schemes = append(schemes, "https")
}
for _, s := range schemes {
for _, h := range hosts {
add(s, h, u.Path)
}
}
return bases, nil
}
// defaultScheme picks http for loopback hosts and https for everything
// else — matches the heuristic most CLIs use when a scheme isn't typed.
func defaultScheme(input string) string {
host := input
if i := strings.IndexByte(host, '/'); i >= 0 {
host = host[:i]
}
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
switch host {
case "localhost", "127.0.0.1", "[::1]", "::1":
return "http"
}
return "https"
}