Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions cmd/nimbo-gui/brandserver_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package main

import (
"testing"

"github.com/otherworld/nimbo/internal/brand"
"github.com/otherworld/nimbo/internal/policy"
)

// A partner build's baked-in server lock applies with no licence or registry
// policy; an admin policy's own address wins; the stock build changes nothing
// (Deck d1010).
func TestWithBrandServer(t *testing.T) {
acme := brand.Brand{ServerURL: "https://cloud.acme.example", LockServer: true}

p := withBrandServer(policy.Policy{AllowSignOut: true}, acme)
if !p.LockServer || p.ServerURL != "https://cloud.acme.example" || !p.AllowSignOut {
t.Errorf("partner lock not applied: %+v", p)
}

p = withBrandServer(policy.Policy{ServerURL: "https://admin.example"}, acme)
if p.ServerURL != "https://admin.example" || !p.LockServer {
t.Errorf("admin address should win, lock should hold: %+v", p)
}

p = withBrandServer(policy.Policy{AllowSignOut: true}, brand.Brand{ServerURL: "https://preset.example"})
if p.ServerURL != "https://preset.example" || p.LockServer {
t.Errorf("a preset without lockServer must not lock: %+v", p)
}

in := policy.Policy{AllowSignOut: true}
if p := withBrandServer(in, brand.Brand{Name: "OtherSync"}); p != in {
t.Errorf("stock brand changed the policy: %+v", p)
}
}

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cmd/nimbo-gui/frontend/dist/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/style.css" />
<title>Wails + Svelte + TS</title>
<script type="module" crossorigin src="/assets/index-B4DLXM4K.js"></script>
<script type="module" crossorigin src="/assets/index-CEyEhSMa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-COewU4CJ.css">
</head>
<body>
Expand Down
22 changes: 18 additions & 4 deletions cmd/nimbo-gui/frontend/src/Login.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,29 @@
let different = $state(false);
const again = $derived(!!target && !different);

// A server preset (admin policy, or a partner build's brand.json) fills the
// address in; a locked one also stops it being changed, since sign-in goes
// to that server whatever is typed (Deck d1010).
let preset = $state("");
let locked = $state(false);

async function loadTarget() {
const t = await App.SignInTarget();
target = t?.id ? t : null;
different = false;
if (!busy) {
server = target ? target.server : "";
server = target ? target.server : preset;
msg = target ? "" : startMsg;
}
}
loadTarget();
(async () => {
const p = await App.PolicyInfo();
if (!p?.serverUrl) return;
preset = p.serverUrl;
locked = p.lockServer;
if (!busy && !target && (locked || !server)) server = preset;
})();
Events.On("login:target", loadTarget);

Events.On("login:error", (e: any) => { if (!busy) return; msg = "Sign-in failed: " + e.data; busy = false; });
Expand All @@ -48,7 +61,7 @@
busy = true;
msg = "Connecting…";
const url = await App.BeginLogin(server);
if (url.startsWith("error:")) { msg = url; busy = false; return; }
if (url.startsWith("error:")) { msg = "Sign-in failed: " + url.slice(6).trim(); busy = false; return; }
// The window minimises itself meanwhile (it covered the browser's Grant
// access button) and comes back for setup or an error.
msg = "Finish signing in in the browser that just opened. This window steps aside until you have.";
Expand All @@ -65,7 +78,7 @@
function signInSomeoneElse() {
App.SignInSomeoneElse(); // the grant may now be for any account
different = true;
server = "";
server = preset;
msg = startMsg;
}
</script>
Expand All @@ -83,7 +96,8 @@
<button class="primary" onclick={login} disabled={busy}>Sign in again</button>
{:else}
<h1>Sign in to Nextcloud</h1>
<input placeholder="https://cloud.example.com" bind:value={server} disabled={busy}
<input placeholder="https://cloud.example.com" bind:value={server} disabled={busy || locked}
title={locked ? "This server is set for you" : ""}
onkeydown={(e) => e.key === "Enter" && login()} />
<button class="primary" onclick={login} disabled={busy}>Log in</button>
{/if}
Expand Down
17 changes: 16 additions & 1 deletion cmd/nimbo-gui/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -778,14 +778,29 @@ func (a *App) refreshLicence() {
if info.Allows(license.TierBusiness) {
pol = policy.Load() // admin policy is a business-tier capability
}
pol = withBrandServer(pol, brand.Current)
a.licMu.Lock()
a.lic = info
a.pol = pol
a.licMu.Unlock()
}

// withBrandServer adds a partner build's baked-in server preset and lock to
// the policy (Deck d1010). It needs no licence: the partner paid for the build.
// An admin policy's own server address wins over the brand's; a lock from
// either side holds.
func withBrandServer(p policy.Policy, b brand.Brand) policy.Policy {
if p.ServerURL == "" {
p.ServerURL = b.ServerURL
}
if b.LockServer && p.ServerURL != "" {
p.LockServer = true
}
return p
}

// policyNow returns the effective admin policy (empty/permissive unless a
// business licence is active).
// business licence is active), plus a partner build's server lock.
func (a *App) policyNow() policy.Policy {
a.licMu.Lock()
defer a.licMu.Unlock()
Expand Down
13 changes: 9 additions & 4 deletions internal/account/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
Expand Down Expand Up @@ -65,12 +66,14 @@ func InitLogin(ctx context.Context, serverURL string) (*Flow, error) {
hc := &http.Client{Timeout: 30 * time.Second}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("start login flow: %w", err)
slog.Warn("start login flow failed", "server", serverURL, "err", err)
return nil, explainLoginStart(serverURL, fmt.Errorf("start login flow: %w", err))
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<12))
return nil, fmt.Errorf("start login flow: server returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
slog.Warn("start login flow refused", "server", serverURL, "status", resp.Status, "body", strings.TrimSpace(string(body)))
return nil, explainLoginStatus(serverURL, resp.StatusCode, resp.Status, strings.TrimSpace(string(body)))
}

var out struct {
Expand All @@ -81,10 +84,12 @@ func InitLogin(ctx context.Context, serverURL string) (*Flow, error) {
Login string `json:"login"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("decode login flow response: %w", err)
slog.Warn("start login flow: not a login-flow answer", "server", serverURL, "err", err)
return nil, notNextcloud(serverURL, fmt.Errorf("decode login flow response: %w", err))
}
if out.Login == "" || out.Poll.Token == "" || out.Poll.Endpoint == "" {
return nil, fmt.Errorf("start login flow: incomplete response from server")
slog.Warn("start login flow: incomplete answer", "server", serverURL)
return nil, notNextcloud(serverURL, errors.New("start login flow: incomplete response from server"))
}

return &Flow{
Expand Down
3 changes: 2 additions & 1 deletion internal/account/login_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package account
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
Expand Down Expand Up @@ -37,7 +38,7 @@ func TestInitLoginDefaultsToHTTPS(t *testing.T) {
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
_, err := InitLogin(context.Background(), host)
if err == nil || !strings.Contains(err.Error(), "HTTPS client") {
if err == nil || !strings.Contains(errors.Unwrap(err).Error(), "HTTPS client") {
t.Fatalf("want https-vs-http transport error proving scheme default, got: %v", err)
}
}
Expand Down
98 changes: 98 additions & 0 deletions internal/account/loginerr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package account

import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"net"
"net/url"
"strings"
"syscall"
)

// loginError is what InitLogin returns when it can't start a sign-in: a short
// sentence a user can act on, wrapping the transport or server error so logs
// and errors.Is keep the detail. The sign-in screens show Error() as it is, and
// the raw Go error ("start login flow: Post …: context deadline exceeded")
// told nobody anything (Deck d907).
type loginError struct {
msg string
err error
}

func (e *loginError) Error() string { return e.msg }
func (e *loginError) Unwrap() error { return e.err }

// hostOf names the server the way the user typed it, without the scheme.
func hostOf(serverURL string) string {
if u, err := url.Parse(serverURL); err == nil && u.Host != "" {
return u.Host + strings.TrimRight(u.Path, "/")
}
return serverURL
}

// wsaConnRefused is WSAECONNREFUSED: Windows reports a refused connection with
// its own code, which syscall.ECONNREFUSED doesn't match there.
const wsaConnRefused = syscall.Errno(10061)

// explainLoginStart turns a failed request to start the login flow into a
// plain message. A cancelled attempt is passed through untouched: nothing went
// wrong that the user needs telling about.
func explainLoginStart(serverURL string, err error) error {
if errors.Is(err, context.Canceled) {
return err
}
host := hostOf(serverURL)
var (
certErr *tls.CertificateVerificationError
unknownCA x509.UnknownAuthorityError
badHost x509.HostnameError
invalidCert x509.CertificateInvalidError
dnsErr *net.DNSError
netErr net.Error
)
var msg string
switch {
case errors.As(err, &certErr), errors.As(err, &unknownCA), errors.As(err, &badHost), errors.As(err, &invalidCert):
msg = fmt.Sprintf("Can't connect securely to %s: its certificate isn't trusted. An expired or self-signed certificate is the usual cause.", host)
case strings.Contains(err.Error(), "server gave HTTP response to HTTPS client"):
msg = fmt.Sprintf("%s doesn't answer on https. If your server really has no https, type the address starting with http://", host)
case errors.As(err, &dnsErr):
msg = fmt.Sprintf("Can't find %s. Check the address, and that this device is online.", host)
case errors.Is(err, syscall.ECONNREFUSED), errors.Is(err, wsaConnRefused):
msg = fmt.Sprintf("%s turned the connection down. Check the address (and the port, if it has one), then try again.", host)
case errors.Is(err, context.DeadlineExceeded), errors.As(err, &netErr) && netErr.Timeout():
msg = fmt.Sprintf("%s didn't answer in time. Check your connection and the address, then try again.", host)
default:
msg = fmt.Sprintf("Can't reach %s. Check your connection and the address, then try again.", host)
}
return &loginError{msg: msg, err: err}
}

// explainLoginStatus is the plain message for a server that answered the
// login-flow request with something other than 200. The body (often an HTML
// error page) only goes in the wrapped error.
func explainLoginStatus(serverURL string, status int, statusText, body string) error {
host := hostOf(serverURL)
raw := fmt.Errorf("start login flow: server returned %s: %s", statusText, body)
var msg string
switch {
case status == 404:
msg = fmt.Sprintf("No Nextcloud sign-in found at %s. Check the address; if Nextcloud isn't at the top of the site, include its folder, e.g. example.com/nextcloud.", host)
case status >= 500:
msg = fmt.Sprintf("%s had a problem starting the sign-in (%s). Try again in a moment.", host, statusText)
default:
msg = fmt.Sprintf("%s wouldn't start a sign-in (%s).", host, statusText)
}
return &loginError{msg: msg, err: raw}
}

// notNextcloud is the plain message for a 200 answer that isn't a login flow.
func notNextcloud(serverURL string, err error) error {
return &loginError{
msg: fmt.Sprintf("%s answered, but not like a Nextcloud server. Check the address.", hostOf(serverURL)),
err: err,
}
}
100 changes: 100 additions & 0 deletions internal/account/loginerr_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package account

import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"syscall"
"testing"
)

// Each common way a sign-in can fail to start reads as a short plain sentence
// naming the server, never the raw Go error, and still unwraps to the original
// so logs and errors.Is keep the detail (Deck d907).
func TestExplainLoginStart(t *testing.T) {
wrap := func(err error) error {
return &url.Error{Op: "Post", URL: "https://cloud.example.com/index.php/login/v2", Err: err}
}
op := func(err error) error { return wrap(&net.OpError{Op: "dial", Net: "tcp", Err: err}) }
cases := []struct {
name string
err error
want string
}{
{"timeout", wrap(context.DeadlineExceeded), "didn't answer in time"},
{"dns", op(&net.DNSError{Err: "no such host", Name: "cloud.example.com", IsNotFound: true}), "Can't find cloud.example.com"},
{"refused", op(os.NewSyscallError("connect", syscall.ECONNREFUSED)), "turned the connection down"},
{"refused-windows", op(os.NewSyscallError("connectex", syscall.Errno(10061))), "turned the connection down"},
{"unknown-ca", wrap(&tls.CertificateVerificationError{Err: x509.UnknownAuthorityError{}}), "certificate"},
{"hostname", wrap(x509.HostnameError{Host: "cloud.example.com"}), "certificate"},
{"plain-http", wrap(errors.New("http: server gave HTTP response to HTTPS client")), "http://"},
{"other-net", op(errors.New("network is unreachable")), "Can't reach cloud.example.com"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := explainLoginStart("https://cloud.example.com", c.err)
if !strings.Contains(got.Error(), c.want) {
t.Errorf("message %q, want it to contain %q", got.Error(), c.want)
}
if strings.Contains(got.Error(), "login/v2") || strings.Contains(got.Error(), "Post ") {
t.Errorf("message %q leaks the raw error", got.Error())
}
if !errors.Is(got, c.err) {
t.Errorf("message does not unwrap to the original error")
}
})
}
}

// A caller cancelling the attempt is not a failure to explain.
func TestExplainLoginStartKeepsCancel(t *testing.T) {
err := &url.Error{Op: "Post", URL: "https://x/index.php/login/v2", Err: context.Canceled}
if got := explainLoginStart("https://x", err); got != err {
t.Errorf("got %v, want the original cancel error", got)
}
}

// A server that isn't Nextcloud (or the wrong path) answers 404: say so plainly
// instead of dumping its HTML error page.
func TestInitLoginNotFoundIsPlain(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "<html><body>Not here</body></html>", http.StatusNotFound)
}))
defer srv.Close()
_, err := InitLogin(context.Background(), srv.URL)
if err == nil {
t.Fatal("want an error")
}
if !strings.Contains(err.Error(), "No Nextcloud sign-in") || strings.Contains(err.Error(), "<html>") {
t.Errorf("got %q", err.Error())
}
}

func TestInitLoginServerErrorIsPlain(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "stack trace…", http.StatusServiceUnavailable)
}))
defer srv.Close()
_, err := InitLogin(context.Background(), srv.URL)
if err == nil || !strings.Contains(err.Error(), "503") || strings.Contains(err.Error(), "stack trace") {
t.Errorf("got %v", err)
}
}

func TestInitLoginNotNextcloudIsPlain(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html>welcome to my blog</html>"))
}))
defer srv.Close()
_, err := InitLogin(context.Background(), srv.URL)
if err == nil || !strings.Contains(err.Error(), "not like a Nextcloud server") {
t.Errorf("got %v", err)
}
}
Loading
Loading