// 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 . 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" }