Skip to content
Open
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 .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ JWT_SECRET=hsjl]W;&ZcHxT&FK;s%bgIQF:#ch=~#Al4:5]N;7V<qPZ3e9lT4'%;go;LIkc%k
# private/internal network addresses (LAN, loopback, link-local). Self-hosted only.
# ALLOW_PRIVATE_NETWORK_TARGETS=true

# PORTAL_DIRECTORY_ENABLED=true
# Serves a public directory of this instance's portals at the root domain. Multi-tenant
# (HOST_MODE=multi) only. Lists every active, non-private portal by name and links to it.

LOG_LEVEL=DEBUG
LOG_CONSOLE=true
LOG_SQL=true
Expand Down
4 changes: 4 additions & 0 deletions app/cmd/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ func routes(r *web.Engine) *web.Engine {
r.Get("/oauth/:provider", handlers.SignInByOAuth())
r.Get("/oauth/:provider/callback", handlers.OAuthCallback())

// The root domain of a multi-tenant instance resolves no tenant, so RequireTenant below
// would 404 it. Serve the public portal directory there instead, when it's enabled.
r.Use(middlewares.RootDomainFallback(handlers.PortalDirectory()))

// Starting from this step, a Tenant is required
r.Use(middlewares.RequireTenant())

Expand Down
55 changes: 55 additions & 0 deletions app/handlers/portals.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package handlers

import (
"net/http"
"strings"

"github.com/getfider/fider/app/models/dto"
"github.com/getfider/fider/app/models/query"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/env"
"github.com/getfider/fider/app/pkg/web"
)

// PortalDirectory lists the public portals hosted on this instance. It is only reachable on
// the root domain of a multi-tenant instance, and only when the operator has enabled it.
func PortalDirectory() web.HandlerFunc {
return func(c *web.Context) error {
if !env.IsPortalDirectoryEnabled() {
return c.NotFound()
}

publicTenants := &query.GetPublicTenants{}
if err := bus.Dispatch(c, publicTenants); err != nil {
return c.Failure(err)
}

portals := make([]dto.PortalSummary, 0, len(publicTenants.Result))
for _, tenant := range publicTenants.Result {
url := web.TenantBaseURL(c, tenant)
portals = append(portals, dto.PortalSummary{
Name: tenant.Name,
URL: url,
Host: hostOf(url),
LogoURL: web.TenantLogoURL(c, tenant),
})
}

return c.Page(http.StatusOK, web.Props{
Page: "PortalDirectory/PortalDirectory.page",
Title: "Portals",
Description: "Browse the feedback portals hosted here.",
Data: web.Map{
"portals": portals,
},
})
}
}

// hostOf strips the scheme from a base URL, leaving the host (and port, when present).
func hostOf(url string) string {
if index := strings.Index(url, "://"); index >= 0 {
return url[index+3:]
}
return url
}
117 changes: 117 additions & 0 deletions app/handlers/portals_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package handlers_test

import (
"context"
"net/http"
"testing"

"github.com/getfider/fider/app/handlers"
"github.com/getfider/fider/app/models/entity"
"github.com/getfider/fider/app/models/enum"
"github.com/getfider/fider/app/models/query"
. "github.com/getfider/fider/app/pkg/assert"
"github.com/getfider/fider/app/pkg/bus"
"github.com/getfider/fider/app/pkg/env"
"github.com/getfider/fider/app/pkg/mock"
)

func TestPortalDirectory_Disabled(t *testing.T) {
RegisterT(t)

server := mock.NewServer()
env.Config.PortalDirectoryEnabled = false

code, _ := server.
WithURL("http://test.fider.io/").
Execute(handlers.PortalDirectory())

Expect(code).Equals(http.StatusNotFound)
}

func TestPortalDirectory_ListsPublicPortals(t *testing.T) {
RegisterT(t)

bus.AddHandler(func(ctx context.Context, q *query.GetPublicTenants) error {
q.Result = []*entity.Tenant{
{ID: 2, Name: "Avengers", Subdomain: "avengers", Status: enum.TenantActive, LogoBlobKey: "logos/avengers.png"},
{ID: 1, Name: "Demonstration", Subdomain: "demo", Status: enum.TenantActive},
}
return nil
})

server := mock.NewServer()
env.Config.PortalDirectoryEnabled = true
defer func() { env.Config.PortalDirectoryEnabled = false }()

code, props := server.
WithURL("http://test.fider.io/").
ExecuteAsPage(handlers.PortalDirectory())

Expect(code).Equals(http.StatusOK)

portals, ok := props.Data["portals"].([]any)
Expect(ok).IsTrue()
Expect(portals).HasLen(2)

avengers := portals[0].(map[string]any)
Expect(avengers["name"]).Equals("Avengers")
Expect(avengers["url"]).Equals("http://avengers.test.fider.io")
Expect(avengers["host"]).Equals("avengers.test.fider.io")
Expect(avengers["logoURL"]).Equals("http://avengers.test.fider.io/static/images/logos/avengers.png?size=200")

demo := portals[1].(map[string]any)
Expect(demo["name"]).Equals("Demonstration")
Expect(demo["url"]).Equals("http://demo.test.fider.io")
Expect(demo["host"]).Equals("demo.test.fider.io")
Expect(demo["logoURL"]).IsNil()
}

func TestPortalDirectory_NoPortals(t *testing.T) {
RegisterT(t)

bus.AddHandler(func(ctx context.Context, q *query.GetPublicTenants) error {
q.Result = []*entity.Tenant{}
return nil
})

server := mock.NewServer()
env.Config.PortalDirectoryEnabled = true
defer func() { env.Config.PortalDirectoryEnabled = false }()

code, props := server.
WithURL("http://test.fider.io/").
ExecuteAsPage(handlers.PortalDirectory())

Expect(code).Equals(http.StatusOK)

portals, ok := props.Data["portals"].([]any)
Expect(ok).IsTrue()
Expect(portals).HasLen(0)
}

func TestPortalDirectory_ServerRendersForCrawlers(t *testing.T) {
RegisterT(t)

bus.AddHandler(func(ctx context.Context, q *query.GetPublicTenants) error {
q.Result = []*entity.Tenant{
{ID: 2, Name: "Avengers", Subdomain: "avengers", Status: enum.TenantActive},
}
return nil
})

server := mock.NewServer()
env.Config.PortalDirectoryEnabled = true
defer func() { env.Config.PortalDirectoryEnabled = false }()

code, response := server.
WithURL("http://test.fider.io/").
AddHeader("User-Agent", "Googlebot/2.1").
Execute(handlers.PortalDirectory())

Expect(code).Equals(http.StatusOK)

// The card markup only appears when the page was rendered server-side: the client bundle
// never runs in a test. This is what a crawler indexing the root domain receives, and it
// only works while the page stays registered in public/ssr.tsx.
Expect(response.Body.String()).ContainsSubstring("c-portal-card")
}
22 changes: 22 additions & 0 deletions app/middlewares/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ func RequireTenant() web.MiddlewareFunc {
}
}

// RootDomainFallback serves the given handler on the root path of a multi-tenant instance's
// root domain, where no tenant resolves from the hostname and RequireTenant would otherwise
// return 404. Every other path, and every request that did resolve a tenant, is passed
// through untouched.
//
// The hostname must be exactly the configured root domain. A tenant-shaped host that
// resolved nothing — an unknown or disabled subdomain, or a custom domain matching no
// tenant — must keep its 404 instead of being handed the directory.
func RootDomainFallback(handler web.HandlerFunc) web.MiddlewareFunc {
return func(next web.HandlerFunc) web.HandlerFunc {
return func(c *web.Context) error {
isRootDomain := c.Request.URL.Hostname() == env.Config.HostDomain

if c.Tenant() == nil && isRootDomain && c.Request.URL.Path == "/" {
return handler(c)
}

return next(c)
}
}
}

// BlockPendingTenants blocks requests for pending tenants
func BlockPendingTenants() web.MiddlewareFunc {
return func(next web.HandlerFunc) web.HandlerFunc {
Expand Down
125 changes: 125 additions & 0 deletions app/middlewares/tenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -436,3 +436,128 @@ func TestBlockLockedTenants_LockedTenant(t *testing.T) {

Expect(status).Equals(http.StatusPaymentRequired)
}

func TestRootDomainFallback_NoTenantOnRootPath(t *testing.T) {
RegisterT(t)

fallbackServed := false
nextServed := false

server := mock.NewServer()
server.Use(middlewares.RootDomainFallback(func(c *web.Context) error {
fallbackServed = true
return c.Ok(web.Map{})
}))

status, _ := server.
WithURL("http://test.fider.io/").
Execute(func(c *web.Context) error {
nextServed = true
return c.Ok(web.Map{})
})

Expect(status).Equals(http.StatusOK)
Expect(fallbackServed).IsTrue()
Expect(nextServed).IsFalse()
}

func TestRootDomainFallback_NoTenantOnOtherPath(t *testing.T) {
RegisterT(t)

fallbackServed := false
nextServed := false

server := mock.NewServer()
server.Use(middlewares.RootDomainFallback(func(c *web.Context) error {
fallbackServed = true
return c.Ok(web.Map{})
}))

status, _ := server.
WithURL("http://test.fider.io/posts/1").
Execute(func(c *web.Context) error {
nextServed = true
return c.Ok(web.Map{})
})

Expect(status).Equals(http.StatusOK)
Expect(fallbackServed).IsFalse()
Expect(nextServed).IsTrue()
}

func TestRootDomainFallback_TenantOnRootPath(t *testing.T) {
RegisterT(t)

fallbackServed := false
nextServed := false

server := mock.NewServer()
server.Use(middlewares.RootDomainFallback(func(c *web.Context) error {
fallbackServed = true
return c.Ok(web.Map{})
}))

status, _ := server.
WithURL("http://demo.test.fider.io/").
OnTenant(mock.DemoTenant).
Execute(func(c *web.Context) error {
nextServed = true
return c.Ok(web.Map{})
})

Expect(status).Equals(http.StatusOK)
Expect(fallbackServed).IsFalse()
Expect(nextServed).IsTrue()
}

func TestRootDomainFallback_UnknownSubdomain(t *testing.T) {
RegisterT(t)

fallbackServed := false
nextServed := false

server := mock.NewServer()
server.Use(middlewares.RootDomainFallback(func(c *web.Context) error {
fallbackServed = true
return c.Ok(web.Map{})
}))

// A subdomain that resolves to no tenant must keep 404ing rather than being handed the
// portal directory: it is not the root domain.
status, _ := server.
WithURL("http://nosuchtenant.test.fider.io/").
Execute(func(c *web.Context) error {
nextServed = true
return c.Ok(web.Map{})
})

Expect(status).Equals(http.StatusOK)
Expect(fallbackServed).IsFalse()
Expect(nextServed).IsTrue()
}

func TestRootDomainFallback_UnknownCustomDomain(t *testing.T) {
RegisterT(t)

fallbackServed := false
nextServed := false

server := mock.NewServer()
server.Use(middlewares.RootDomainFallback(func(c *web.Context) error {
fallbackServed = true
return c.Ok(web.Map{})
}))

// A custom domain pointed at this instance but matching no tenant is also not the root
// domain, so it must fall through.
status, _ := server.
WithURL("http://feedback.someoneelse.com/").
Execute(func(c *web.Context) error {
nextServed = true
return c.Ok(web.Map{})
})

Expect(status).Equals(http.StatusOK)
Expect(fallbackServed).IsFalse()
Expect(nextServed).IsTrue()
}
11 changes: 11 additions & 0 deletions app/models/dto/portal.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package dto

// PortalSummary is one entry in the public portal directory. URL and LogoURL are absolute and
// point at the portal's own host, because the directory is served from the root domain where
// tenant-scoped paths do not resolve.
type PortalSummary struct {
Name string `json:"name"`
URL string `json:"url"`
Host string `json:"host"`
LogoURL string `json:"logoURL,omitempty"`
}
8 changes: 8 additions & 0 deletions app/models/query/tenant.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ type GetTenantByDomain struct {
Result *entity.Tenant
}

// GetPublicTenants returns the tenants eligible for the public portal directory: active,
// non-private, and not inside a deletion grace window. Ordered by name.
type GetPublicTenants struct {

// Output
Result []*entity.Tenant
}

type GetPendingSignUpVerification struct {
// Output
Result *entity.EmailVerification
Expand Down
Loading