diff --git a/CHANGELOG.md b/CHANGELOG.md index d7194c7a..4679b096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Supermetry is a self-hosted web analytics product: one Go binary and PostgreSQL, ## Unreleased +### Changed + +- On managed instances (`SUPERMETRY_MANAGED=true`) a site's host must now look like a domain: it needs a dot (`example.com`, `blog.example.co.uk`, `*.example.com`). `localhost` and IP addresses are still accepted for testing, and other single-label hosts such as `afaridar`, which the tracker can never match, are rejected with 400 (`host "afaridar" is not a domain: use something like example.com`). The new-site form and the site settings show the same hint. Self-hosted instances are unchanged and keep intranet names like `wiki`. Existing sites are untouched, and saving a site that already stores a single-label host keeps working. + ## v0.1.18 (2026-10-28) ### Added diff --git a/cmd/supermetry/serve.go b/cmd/supermetry/serve.go index 83a1b922..da1efb57 100644 --- a/cmd/supermetry/serve.go +++ b/cmd/supermetry/serve.go @@ -330,6 +330,7 @@ func serve(ctx context.Context, cfg config.Config, options ...serveOption) error siteService := sitesvc.New(platformStore, databaseRegistry, cfg.Tracking.Mode) siteService.SetClock(settings.clock.Now) siteService.SetSiteLimit(cfg.MaxSitesPerOrg) + siteService.SetRequireDomain(cfg.Managed) siteService.SetRetentionPolicy(cfg.DefaultRetentionDays, cfg.RetentionForever) siteService.SetQueryInvalidator(engine.InvalidateSite) overviewService := sitesvc.NewOverview(platformStore, sessions) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 5ef5a622..5f901992 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -42,7 +42,7 @@ Gotchas: | `SUPERMETRY_DEFAULT_RETENTION_DAYS` | Raw-event retention, in days, that a new site gets when its creator does not choose one; the dashboard's site form preselects it. One of `7`, `30`, `90`, `365`, or `0` for keep forever. Applies only to new sites. | `30` | `90` | | `SUPERMETRY_RETENTION_FOREVER` | Boolean. Whether "keep forever" (`retention_days: 0`) is offered. When `false`, creating or updating a site with `retention_days: 0`, including through the admin API, is rejected with 400, and the option is hidden in the dashboard; existing forever sites keep their setting until changed. Requires `SUPERMETRY_DEFAULT_RETENTION_DAYS` other than `0`. | `true` | `false` | | `SUPERMETRY_SUPPORT_URL` | Optional help link shown on the sign-in, error and not-found pages as "Contact support". `https`, `http` or `mailto:` only. Empty hides the link. | empty | `https://help.supermetry.com/contact` | -| `SUPERMETRY_MANAGED` | Boolean. Marks an instance run by an operator, such as a hosted service, where the operator rather than the in-app admin administers the instance. When `true`, the dashboard hides every link to the instance administration pages and shows "This instance is managed by its operator." at `/app/admin/*`, and `POST /api/private/admin/update/check` and `/apply` answer 409 (the instance never replaces its own binary, even with the admin API key); the update status reports `update_supported: false`. It does not change authorization: instance admins stay instance admins, the rest of the admin API (including the admin API key) works as before, and organization pages, account settings and members are untouched. It is reported as `managed` by `GET /api/private/auth/config`. | `false` | `true` | +| `SUPERMETRY_MANAGED` | Boolean. Marks an instance run by an operator, such as a hosted service, where the operator rather than the in-app admin administers the instance. When `true`, the dashboard hides every link to the instance administration pages and shows "This instance is managed by its operator." at `/app/admin/*`, and `POST /api/private/admin/update/check` and `/apply` answer 409 (the instance never replaces its own binary, even with the admin API key); the update status reports `update_supported: false`. It does not change authorization: instance admins stay instance admins, the rest of the admin API (including the admin API key) works as before, and organization pages, account settings and members are untouched. It also requires every new site host to look like a domain (contain a dot; `localhost` and IP addresses stay allowed), because the tracker can never match a single-label name; hosts a site already stores are not re-checked. It is reported as `managed` by `GET /api/private/auth/config`. | `false` | `true` | | `SUPERMETRY_ALLOW_PRIVATE_WEBHOOKS` | Boolean (Go `ParseBool`: `1`, `t`, `true`, `0`, `f`, `false`, ...). Passed to the alert webhook client as "allow private". Exact effect: see source, `internal/alerts` and `cmd/supermetry/serve.go`. | `false` | `false` | Notification destinations, including Telegram, are configured in the application rather than through environment variables. See [Notification channels](notifications.md) for routing, secret handling, testing, and Telegram setup. diff --git a/internal/api/openapi.gen.go b/internal/api/openapi.gen.go index e9b8beff..a0b4a8d3 100644 --- a/internal/api/openapi.gen.go +++ b/internal/api/openapi.gen.go @@ -2422,6 +2422,7 @@ type CreateShareRequest struct { // CreateSiteRequest hosts are required when kind is web (the default); hosts and revenue_currency are ignored for pixel. type CreateSiteRequest struct { + // Hosts Hostnames the tracker may report. On a managed instance (SUPERMETRY_MANAGED) each new host must look like a domain (contain a dot, for example example.com or *.example.com); localhost and IPv4 addresses are also accepted, and any other single-label host is rejected with 400. Hosts *[]string `json:"hosts,omitempty"` // Kind Fixed at creation. diff --git a/internal/api/openapi.yaml b/internal/api/openapi.yaml index f01af600..43b68b8f 100644 --- a/internal/api/openapi.yaml +++ b/internal/api/openapi.yaml @@ -5024,6 +5024,7 @@ components: hosts: type: array items: { type: string } + description: Hostnames the tracker may report. On a managed instance (SUPERMETRY_MANAGED) each new host must look like a domain (contain a dot, for example example.com or *.example.com); localhost and IPv4 addresses are also accepted, and any other single-label host is rejected with 400. timezone: { type: string } retention_days: type: integer diff --git a/internal/platform/http/handlers_sites_domain_test.go b/internal/platform/http/handlers_sites_domain_test.go new file mode 100644 index 00000000..2dbbec7f --- /dev/null +++ b/internal/platform/http/handlers_sites_domain_test.go @@ -0,0 +1,55 @@ +package platformhttp_test + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "supermetry/internal/db" + "supermetry/internal/platform/authz" + platformhttp "supermetry/internal/platform/http" + "supermetry/internal/platform/sitesvc" + "supermetry/internal/platform/store" + "supermetry/internal/testpg" +) + +func TestCreateSiteManagedRejectsSingleLabelHost(t *testing.T) { + pool, _ := testpg.New(t) + ctx := context.Background() + if err := db.Migrate(ctx, pool); err != nil { + t.Fatal(err) + } + var userID, orgID int64 + if err := pool.QueryRow(ctx, `insert into users(email,name,password_hash) values ('owner@example.test','Owner','hash') returning id`).Scan(&userID); err != nil { + t.Fatal(err) + } + if err := pool.QueryRow(ctx, `insert into orgs(name,slug) values ('Managed','managed-host') returning id`).Scan(&orgID); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, `insert into memberships(org_id,user_id,role) values ($1,$2,'owner')`, orgID, userID); err != nil { + t.Fatal(err) + } + data := &store.Store{Pool: pool} + registry := sitesvc.NewRegistry(data, 0) + t.Cleanup(registry.Close) + service := sitesvc.New(data, registry) + service.SetRequireDomain(true) + principal := authz.Principal{UserID: userID, OrgID: orgID, Role: authz.RoleOwner} + handler := platformhttp.New(platformhttp.Deps{ + Authenticator: actorAuthenticator{"owner": principal}, Sites: registry, + Mutations: platformhttp.Services{Sites: service}, Store: data, + }) + post := func(host string) *httptest.ResponseRecorder { + response := httptest.NewRecorder() + handler.ServeHTTP(response, authenticatedRequest(http.MethodPost, "/api/private/sites", `{"name":"S","hosts":["`+host+`"],"timezone":"UTC","retention_days":30,"revenue_currency":"USD"}`)) + return response + } + if response := post("afaridar"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), `host \"afaridar\" is not a domain: use something like example.com`) { + t.Fatalf("status=%d body=%s", response.Code, response.Body) + } + if response := post("example.com"); response.Code != http.StatusCreated { + t.Fatalf("status=%d body=%s", response.Code, response.Body) + } +} diff --git a/internal/platform/sitesvc/sites.go b/internal/platform/sitesvc/sites.go index 50b9d10b..60d42307 100644 --- a/internal/platform/sitesvc/sites.go +++ b/internal/platform/sitesvc/sites.go @@ -64,13 +64,15 @@ type SitePatch struct { } type Sites struct { - trackingMode privacy.Mode - store *store.Store - registry *Registry - now func() time.Time - discoverIcon func(context.Context, string) string - invalidateSite func(int32) - maxSites int + trackingMode privacy.Mode + store *store.Store + registry *Registry + now func() time.Time + discoverIcon func(context.Context, string) string + invalidateSite func(int32) + maxSites int + // requireDomain rejects new single-label hosts (managed instances only). + requireDomain bool restoreInvalidator interface{ Invalidate() error } // defaultRetention is the retention a new site gets when the request names none. defaultRetention int @@ -112,6 +114,10 @@ func (s *Sites) checkForever(days int) error { // by anyone who is not an instance admin. Zero means no limit. func (s *Sites) SetSiteLimit(max int) { s.maxSites = max } +// SetRequireDomain makes new hosts have to look like a domain (contain a dot), +// except localhost and IP literals. Hosts a site already stores stay valid. +func (s *Sites) SetRequireDomain(v bool) { s.requireDomain = v } + // SetInvalidator replaces the post-restore registry invalidator. func (s *Sites) SetInvalidator(invalidator interface{ Invalidate() error }) { s.restoreInvalidator = invalidator @@ -168,7 +174,7 @@ func (s *Sites) Create(ctx context.Context, principal authz.Principal, input any ) switch in.Kind { case coresites.KindWeb: - name, hosts, location, retention, err = validateSite(in.Name, in.Hosts, in.Timezone, in.RetentionDays) + name, hosts, location, retention, err = validateSite(in.Name, in.Hosts, in.Timezone, in.RetentionDays, s.requireDomain, nil) // Without a currency the project stays unconfigured; an owner picks it once later. if in.RevenueCurrency == "" { state = string(coresites.RevenueCurrencyUnconfigured) @@ -176,7 +182,7 @@ func (s *Sites) Create(ctx context.Context, principal authz.Principal, input any err = fmt.Errorf("%w: revenue currency must be three uppercase letters", ErrInvalid) } case coresites.KindPixel: - name, _, location, retention, err = validateSite(in.Name, []string{"pixel.invalid"}, in.Timezone, in.RetentionDays) + name, _, location, retention, err = validateSite(in.Name, []string{"pixel.invalid"}, in.Timezone, in.RetentionDays, false, nil) hosts, in.RevenueCurrency, state = []string{}, "", string(coresites.RevenueCurrencyUnconfigured) default: err = fmt.Errorf("%w: kind must be web or pixel", ErrInvalid) @@ -257,6 +263,7 @@ func (s *Sites) Update(ctx context.Context, principal authz.Principal, id int32, if patch.Name != nil { current.Name = *patch.Name } + storedHosts := append([]string(nil), current.AllowedHosts...) if patch.Hosts != nil { current.AllowedHosts = append([]string(nil), (*patch.Hosts)...) } @@ -337,7 +344,7 @@ func (s *Sites) Update(ctx context.Context, principal authz.Principal, id int32, validationHosts = []string{"pixel.invalid"} } name, hosts, location, retention, err := validateSite( - current.Name, validationHosts, current.Timezone, int(current.RetentionClass), + current.Name, validationHosts, current.Timezone, int(current.RetentionClass), s.requireDomain, storedHosts, ) if err != nil { return err @@ -438,7 +445,7 @@ func (s *Sites) AdminSetRetention(ctx context.Context, actorUserID int64, siteID if current.Kind == coresites.KindPixel { validationHosts = []string{"pixel.invalid"} } - _, _, _, retention, err := validateSite(current.Name, validationHosts, current.Timezone, days) + _, _, _, retention, err := validateSite(current.Name, validationHosts, current.Timezone, days, false, nil) if err != nil { return err } @@ -654,7 +661,7 @@ func (s *Sites) List(ctx context.Context, principal authz.Principal, cursor stri return result, next, nil } -func validateSite(name string, hosts []string, timezone string, retentionDays int) (string, []string, *time.Location, db.RetentionClass, error) { +func validateSite(name string, hosts []string, timezone string, retentionDays int, requireDomain bool, existing []string) (string, []string, *time.Location, db.RetentionClass, error) { name = strings.TrimSpace(name) if name == "" { return "", nil, nil, 0, fmt.Errorf("%w: name is required", ErrInvalid) @@ -667,14 +674,17 @@ func validateSite(name string, hosts []string, timezone string, retentionDays in if !ok { return "", nil, nil, 0, fmt.Errorf("%w: unsupported retention", ErrInvalid) } - hosts, err = normalizeHosts(hosts) + hosts, err = normalizeHosts(hosts, requireDomain, existing) if err != nil { return "", nil, nil, 0, err } return name, hosts, location, retention, nil } -func normalizeHosts(hosts []string) ([]string, error) { +// normalizeHosts lowercases, validates and de-duplicates hosts. With requireDomain a +// host must contain a dot unless it is localhost or an IP literal, or already +// appears in existing (stored sites keep working). +func normalizeHosts(hosts []string, requireDomain bool, existing []string) ([]string, error) { if len(hosts) == 0 { return nil, fmt.Errorf("%w: at least one host is required", ErrInvalid) } @@ -689,6 +699,9 @@ func normalizeHosts(hosts []string) ([]string, error) { if !validHostname(name) || strings.ContainsAny(host, ":/?#@") { return nil, fmt.Errorf("%w: invalid host %q", ErrInvalid, raw) } + if requireDomain && !looksLikeDomain(name) && !containsHost(existing, host) { + return nil, fmt.Errorf("%w: host %q is not a domain: use something like example.com", ErrInvalid, strings.TrimSpace(raw)) + } if _, ok := seen[host]; ok { continue } @@ -698,6 +711,20 @@ func normalizeHosts(hosts []string) ([]string, error) { return normalized, nil } +func containsHost(list []string, host string) bool { + for _, h := range list { + if strings.ToLower(strings.TrimSpace(h)) == host { + return true + } + } + return false +} + +// looksLikeDomain reports whether a validated hostname has a dot, is localhost or an IP literal. +func looksLikeDomain(name string) bool { + return strings.Contains(name, ".") || name == "localhost" +} + func validHostname(host string) bool { if host == "" || len(host) > 253 || strings.HasPrefix(host, ".") || strings.HasSuffix(host, ".") { return false diff --git a/internal/platform/sitesvc/sites_domain_test.go b/internal/platform/sitesvc/sites_domain_test.go new file mode 100644 index 00000000..014617fc --- /dev/null +++ b/internal/platform/sitesvc/sites_domain_test.go @@ -0,0 +1,76 @@ +package sitesvc + +import ( + "context" + "errors" + "strings" + "testing" +) + +func TestNormalizeHostsRequireDomain(t *testing.T) { + tests := []struct { + name string + hosts []string + managed bool + existing []string + want []string + wantErr string + }{ + {name: "self-hosted single label", hosts: []string{"wiki"}, want: []string{"wiki"}}, + {name: "managed single label", hosts: []string{"afaridar"}, managed: true, wantErr: `host "afaridar" is not a domain: use something like example.com`}, + {name: "managed domain", hosts: []string{"example.com"}, managed: true, want: []string{"example.com"}}, + {name: "managed subdomain co.uk", hosts: []string{"blog.example.co.uk"}, managed: true, want: []string{"blog.example.co.uk"}}, + {name: "managed wildcard domain", hosts: []string{"*.example.com"}, managed: true, want: []string{"*.example.com"}}, + {name: "managed wildcard single label", hosts: []string{"*.intranet"}, managed: true, wantErr: "is not a domain"}, + {name: "managed localhost", hosts: []string{"localhost"}, managed: true, want: []string{"localhost"}}, + {name: "managed IPv4", hosts: []string{"127.0.0.1"}, managed: true, want: []string{"127.0.0.1"}}, + {name: "managed IPv6 stays rejected", hosts: []string{"::1"}, managed: true, wantErr: "invalid host"}, + {name: "managed uppercase and spaces", hosts: []string{" Example.COM "}, managed: true, want: []string{"example.com"}}, + {name: "managed uppercase single label", hosts: []string{" AfaRidar "}, managed: true, wantErr: "is not a domain"}, + {name: "one bad host fails the list", hosts: []string{"example.com", "wiki"}, managed: true, wantErr: "is not a domain"}, + {name: "existing single label kept", hosts: []string{"wiki", "example.com"}, managed: true, existing: []string{"Wiki"}, want: []string{"wiki", "example.com"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := normalizeHosts(test.hosts, test.managed, test.existing) + if test.wantErr != "" { + if !errors.Is(err, ErrInvalid) || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("err = %v, want ErrInvalid containing %q", err, test.wantErr) + } + return + } + if err != nil || strings.Join(got, ",") != strings.Join(test.want, ",") { + t.Fatalf("got %v, %v; want %v", got, err, test.want) + } + }) + } +} + +func TestManagedUpdateKeepsStoredSingleLabelHost(t *testing.T) { + service, _, owner, _ := newSites(t) + ctx := context.Background() + site, err := service.Create(ctx, owner, CreateInput{Name: "Old", Hosts: []string{"wiki"}, Timezone: "UTC", RetentionDays: 30, RevenueCurrency: "USD"}) + if err != nil { + t.Fatal(err) + } + service.SetRequireDomain(true) + if _, err := service.Create(ctx, owner, CreateInput{Name: "New", Hosts: []string{"afaridar"}, Timezone: "UTC", RetentionDays: 30, RevenueCurrency: "USD"}); !errors.Is(err, ErrInvalid) { + t.Fatalf("managed create err = %v, want ErrInvalid", err) + } + name := "Renamed" + if _, err := service.Update(ctx, owner, site.ID, SitePatch{Name: &name}); err != nil { + t.Fatalf("rename with stored single-label host: %v", err) + } + same := []string{"wiki"} + if _, err := service.Update(ctx, owner, site.ID, SitePatch{Hosts: &same}); err != nil { + t.Fatalf("resubmitting unchanged host: %v", err) + } + added := []string{"wiki", "other"} + if _, err := service.Update(ctx, owner, site.ID, SitePatch{Hosts: &added}); !errors.Is(err, ErrInvalid) { + t.Fatalf("adding new single-label host err = %v, want ErrInvalid", err) + } + added = []string{"wiki", "other.example.com"} + if _, err := service.Update(ctx, owner, site.ID, SitePatch{Hosts: &added}); err != nil { + t.Fatalf("adding domain: %v", err) + } +} diff --git a/web/packages/api-client/src/schema.ts b/web/packages/api-client/src/schema.ts index c7f9d1b2..07af3e76 100644 --- a/web/packages/api-client/src/schema.ts +++ b/web/packages/api-client/src/schema.ts @@ -3803,6 +3803,7 @@ export interface components { * @enum {string} */ kind: "web" | "pixel"; + /** @description Hostnames the tracker may report. On a managed instance (SUPERMETRY_MANAGED) each new host must look like a domain (contain a dot, for example example.com or *.example.com); localhost and IPv4 addresses are also accepted, and any other single-label host is rejected with 400. */ hosts?: string[]; timezone: string; /** diff --git a/web/src/features/settings/SettingsPages.tsx b/web/src/features/settings/SettingsPages.tsx index 06c4f9bf..7ce78b5a 100644 --- a/web/src/features/settings/SettingsPages.tsx +++ b/web/src/features/settings/SettingsPages.tsx @@ -295,6 +295,7 @@ export function SiteGeneralPage({ /> Separate multiple hosts with commas. + {config.data?.managed === true ? ` ${uiFlows.site.domainHintManaged}` : ""} diff --git a/web/src/features/sites/Onboarding.tsx b/web/src/features/sites/Onboarding.tsx index 386d6795..3dd4e551 100644 --- a/web/src/features/sites/Onboarding.tsx +++ b/web/src/features/sites/Onboarding.tsx @@ -61,6 +61,7 @@ export function Onboarding({ me, createSite, navigate }: { { const site = await (createSite ?? ((value) => createManagedSite(value, membership.org.id)))(input) navigate(`/app/${membership.org.slug}/${site.slug ?? site.public_id}/settings/installation`) diff --git a/web/src/features/sites/SiteForm.tsx b/web/src/features/sites/SiteForm.tsx index bec92f87..222f98b8 100644 --- a/web/src/features/sites/SiteForm.tsx +++ b/web/src/features/sites/SiteForm.tsx @@ -33,6 +33,17 @@ export function siteErrorField(message: string): FieldName { return 'domain' } +/** + * The first host that is not a domain: no dot, and not localhost. Managed instances reject these + * because the tracker could never match them. Mirrors the server rule; `*.` wildcards are looked through. + */ +export function firstNonDomainHost(hosts: string[]): string | undefined { + return hosts.find((host) => { + const name = host.trim().toLowerCase().replace(/^\*\./, '') + return !name.includes('.') && name !== 'localhost' + }) +} + /** The site name a hostname suggests: supermetry.com for www.supermetry.com. */ export function nameFromDomain(host: string): string { return host.replace(/^www\./i, '') @@ -47,12 +58,15 @@ export function SiteForm({ onSubmit, secondaryAction, retention, + requireDomain = false, }: { submitLabel: string onSubmit: (input: WebSiteInput) => Promise secondaryAction?: React.ReactNode /** The instance policy once loaded. Until then keep-forever stays hidden and the server's default applies. */ retention?: RetentionPolicy + /** True on managed instances, where a host must look like a domain. Self-hosted sites may use single-label names. */ + requireDomain?: boolean }) { const policy = retention ?? { ...retentionPolicy(undefined), forever: false } // Only a retention the user picked is sent; otherwise the server applies the instance default. @@ -77,6 +91,13 @@ export function SiteForm({ .split(',') .map((host) => host.trim().replace(/^https?:\/\//i, '').replace(/\/.*$/, '')) .filter(Boolean) + const bad = requireDomain ? firstNonDomainHost(hosts) : undefined + if (bad) { + setError({ field: 'domain', message: uiFlows.site.notDomain(bad) }) + focusField('domain') + setSubmitting(false) + return + } const currency = String(data.get('revenue_currency') ?? '').trim().toUpperCase() try { await onSubmit({ @@ -118,7 +139,7 @@ export function SiteForm({ aria-describedby={domainError ? 'site-domains-hint site-domains-error' : 'site-domains-hint'} required /> - {uiFlows.site.domainHint} + {uiFlows.site.domainHint}{requireDomain ? ` ${uiFlows.site.domainHintManaged}` : ''} {domainError ? {domainError} : null} diff --git a/web/src/features/sites/SiteFormDomain.test.tsx b/web/src/features/sites/SiteFormDomain.test.tsx new file mode 100644 index 00000000..9efaec77 --- /dev/null +++ b/web/src/features/sites/SiteFormDomain.test.tsx @@ -0,0 +1,51 @@ +import '@testing-library/jest-dom/vitest' + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' + +import { SiteForm, firstNonDomainHost } from './SiteForm' + +afterEach(cleanup) + +describe('firstNonDomainHost', () => { + it('flags single-label hosts but not domains, wildcards, localhost or IPs', () => { + expect(firstNonDomainHost(['example.com', '*.example.com', 'localhost', '127.0.0.1'])).toBeUndefined() + expect(firstNonDomainHost(['example.com', 'afaridar'])).toBe('afaridar') + expect(firstNonDomainHost(['*.intranet'])).toBe('*.intranet') + }) +}) + +describe('SiteForm on a managed instance', () => { + beforeAll(() => { + Element.prototype.scrollIntoView ??= vi.fn() + }) + + it('shows the domain hint and blocks a single-label host before calling the server', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + render() + expect(screen.getByText(/with a dot, such as example\.com/)).toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'afaridar' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add site' })) + expect(await screen.findByText('host "afaridar" is not a domain: use something like example.com')).toBeInTheDocument() + expect(onSubmit).not.toHaveBeenCalled() + }) + + it('accepts a real domain', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + render() + fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'example.com' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add site' })) + await waitFor(() => expect(onSubmit).toHaveBeenCalled()) + }) +}) + +describe('SiteForm on a self-hosted instance', () => { + it('has no extra hint and still sends a single-label host', async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined) + render() + expect(screen.queryByText(/with a dot, such as example\.com/)).not.toBeInTheDocument() + fireEvent.change(screen.getByLabelText('Domain'), { target: { value: 'wiki' } }) + fireEvent.click(screen.getByRole('button', { name: 'Add site' })) + await waitFor(() => expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ hosts: ['wiki'] }))) + }) +}) diff --git a/web/src/lib/api.gen.ts b/web/src/lib/api.gen.ts index c7f9d1b2..07af3e76 100644 --- a/web/src/lib/api.gen.ts +++ b/web/src/lib/api.gen.ts @@ -3803,6 +3803,7 @@ export interface components { * @enum {string} */ kind: "web" | "pixel"; + /** @description Hostnames the tracker may report. On a managed instance (SUPERMETRY_MANAGED) each new host must look like a domain (contain a dot, for example example.com or *.example.com); localhost and IPv4 addresses are also accepted, and any other single-label host is rejected with 400. */ hosts?: string[]; timezone: string; /** diff --git a/web/src/messages/locales/bn.uiFlows.ts b/web/src/messages/locales/bn.uiFlows.ts index cce49fbb..877c9cc9 100644 --- a/web/src/messages/locales/bn.uiFlows.ts +++ b/web/src/messages/locales/bn.uiFlows.ts @@ -77,6 +77,8 @@ const messages: MessageTree = { site: { domain: 'ডোমেইন', domainHint: 'আপনার সাইটের হোস্টনেম, https:// বা পাথ ছাড়া, যেমন supermetry.com। পরে সাইট সেটিংসে আরও হোস্টনেম যোগ করতে পারবেন।', + domainHintManaged: 'ডট সহ পূর্ণ ডোমেইন নাম ব্যবহার করুন, যেমন example.com বা blog.example.co.uk।', + notDomain: (host: string) => `হোস্ট "${host}" কোনো ডোমেইন নয়: example.com-এর মতো কিছু ব্যবহার করুন`, timezone: 'টাইম জোন', timezoneHint: 'রিপোর্টে প্রতিটি দিন এই টাইম জোনের মধ্যরাতে শুরু ও শেষ হয়।', timezoneSearch: 'টাইম জোন খুঁজুন…', diff --git a/web/src/messages/locales/de.uiFlows.ts b/web/src/messages/locales/de.uiFlows.ts index 32db5121..3e4ba8f2 100644 --- a/web/src/messages/locales/de.uiFlows.ts +++ b/web/src/messages/locales/de.uiFlows.ts @@ -77,6 +77,8 @@ const messages: MessageTree = { site: { domain: 'Domain', domainHint: 'Der Hostname deiner Website ohne https:// und ohne Pfad, zum Beispiel supermetry.com. Weitere Hostnamen fügst du später in den Website-Einstellungen hinzu.', + domainHintManaged: 'Verwende einen vollständigen Domainnamen mit Punkt, etwa example.com oder blog.example.co.uk.', + notDomain: (host: string) => `Host "${host}" ist keine Domain: Verwende etwas wie example.com`, timezone: 'Zeitzone', timezoneHint: 'Berichte beginnen und enden jeden Tag um Mitternacht in dieser Zeitzone.', timezoneSearch: 'Zeitzonen durchsuchen…', diff --git a/web/src/messages/locales/es.uiFlows.ts b/web/src/messages/locales/es.uiFlows.ts index 7be1c99d..c1a61de0 100644 --- a/web/src/messages/locales/es.uiFlows.ts +++ b/web/src/messages/locales/es.uiFlows.ts @@ -77,6 +77,8 @@ const messages: MessageTree = { site: { domain: 'Dominio', domainHint: 'El nombre de host de tu sitio, sin https:// ni ruta; por ejemplo, supermetry.com. Puedes añadir más nombres de host después en Ajustes del sitio.', + domainHintManaged: 'Usa un nombre de dominio completo con un punto, como example.com o blog.example.co.uk.', + notDomain: (host: string) => `el host "${host}" no es un dominio: usa algo como example.com`, timezone: 'Zona horaria', timezoneHint: 'Los informes empiezan y terminan cada día a medianoche en esta zona horaria.', timezoneSearch: 'Buscar zonas horarias…', diff --git a/web/src/messages/uiFlows.ts b/web/src/messages/uiFlows.ts index 2925a906..c9b872e1 100644 --- a/web/src/messages/uiFlows.ts +++ b/web/src/messages/uiFlows.ts @@ -77,6 +77,8 @@ export const uiFlows = { site: { domain: 'Domain', domainHint: 'Your site’s hostname, without https:// or a path, for example supermetry.com. You can add more hostnames later in Site settings.', + domainHintManaged: 'Use a full domain name with a dot, such as example.com or blog.example.co.uk.', + notDomain: (host: string) => `host "${host}" is not a domain: use something like example.com`, timezone: 'Time zone', timezoneHint: 'Reports start and end each day at midnight in this time zone.', timezoneSearch: 'Search time zones…', diff --git a/web/src/routes/app.$org.sites.new.tsx b/web/src/routes/app.$org.sites.new.tsx index fd3a7a24..18f84ad3 100644 --- a/web/src/routes/app.$org.sites.new.tsx +++ b/web/src/routes/app.$org.sites.new.tsx @@ -46,7 +46,7 @@ function NewSiteRoute() { {kind === 'pixel' ? - : } + : } )