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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions cmd/supermetry/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions internal/api/openapi.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions internal/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions internal/platform/http/handlers_sites_domain_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
55 changes: 41 additions & 14 deletions internal/platform/sitesvc/sites.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -168,15 +174,15 @@ 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)
} else if err == nil && !coresites.ValidCurrency(in.RevenueCurrency) {
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)
Expand Down Expand Up @@ -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)...)
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand All @@ -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
}
Expand All @@ -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
Expand Down
76 changes: 76 additions & 0 deletions internal/platform/sitesvc/sites_domain_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
1 change: 1 addition & 0 deletions web/packages/api-client/src/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
1 change: 1 addition & 0 deletions web/src/features/settings/SettingsPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ export function SiteGeneralPage({
/>
<FieldDescription>
Separate multiple hosts with commas.
{config.data?.managed === true ? ` ${uiFlows.site.domainHintManaged}` : ""}
</FieldDescription>
</Field>
<TimezoneSelect key={site.data.timezone} defaultValue={site.data.timezone} hint={uiFlows.site.timezoneHint} />
Expand Down
1 change: 1 addition & 0 deletions web/src/features/sites/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export function Onboarding({ me, createSite, navigate }: {
<SiteForm
submitLabel="Create site"
retention={config.data ? retentionPolicy(config.data) : undefined}
requireDomain={config.data?.managed === true}
onSubmit={async (input) => {
const site = await (createSite ?? ((value) => createManagedSite(value, membership.org.id)))(input)
navigate(`/app/${membership.org.slug}/${site.slug ?? site.public_id}/settings/installation`)
Expand Down
Loading
Loading