144 lines
4.6 KiB
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"
|
|
}
|