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
1 change: 1 addition & 0 deletions apps/api/cmd/clickclack/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func serve(args []string) error {
CookieNames: cookieNames,
FrontendURL: cfg.PublicURL,
PublicAPIURL: cfg.PublicAPIURL,
HomeLink: httpapi.HomeLinkConfig{URL: cfg.HomeURL, Label: cfg.HomeLabel},
EmbedFrameAncestors: cfg.EmbedFrameAncestors,
GitHubOAuth: httpapi.GitHubOAuthConfig{
ClientID: cfg.GitHubClientID,
Expand Down
47 changes: 47 additions & 0 deletions apps/api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"os"
"strconv"
"strings"
"unicode/utf8"

"github.com/openclaw/clickclack/apps/api/internal/authpolicy"
)
Expand All @@ -21,6 +22,8 @@ type Config struct {
MetricsEnabled bool `json:"metrics_enabled"`
PublicURL string `json:"public_url"`
PublicAPIURL string `json:"public_api_url"`
HomeURL string `json:"home_url"`
HomeLabel string `json:"home_label"`
EmbedFrameAncestors []string `json:"embed_frame_ancestors"`
CookieNamespace string `json:"cookie_namespace"`
DevBootstrap bool `json:"dev_bootstrap"`
Expand Down Expand Up @@ -89,6 +92,12 @@ func Load(path string) (Config, error) {
if env := os.Getenv("CLICKCLACK_PUBLIC_API_URL"); env != "" {
cfg.PublicAPIURL = env
}
if env := os.Getenv("CLICKCLACK_HOME_URL"); env != "" {
cfg.HomeURL = env
}
if env := os.Getenv("CLICKCLACK_HOME_LABEL"); env != "" {
cfg.HomeLabel = env
}
if env := os.Getenv("CLICKCLACK_EMBED_FRAME_ANCESTORS"); env != "" {
cfg.EmbedFrameAncestors = ParseEmbedFrameAncestors(env)
}
Expand Down Expand Up @@ -205,6 +214,12 @@ func (c *Config) ValidateServe() error {
if _, err := authpolicy.NewCookieNames(namespace, publicURL, publicAPIURL); err != nil {
return fmt.Errorf("cookie policy: %w", err)
}
homeURL, homeLabel, err := NormalizeHomeLink(c.HomeURL, c.HomeLabel)
if err != nil {
return err
}
c.HomeURL = homeURL
c.HomeLabel = homeLabel
c.PublicAPIURL = publicAPIURL
c.EmbedFrameAncestors = embedFrameAncestors
c.CookieNamespace = namespace
Expand Down Expand Up @@ -273,3 +288,35 @@ func validatePublicURLPair(publicURL, publicAPIURL string) error {
}
return nil
}

// MaxHomeLabelLength bounds the label rendered on the workspace rail's home
// button; it is a short badge, not a title.
const MaxHomeLabelLength = 32

// NormalizeHomeLink validates the optional deployment-specific home link that
// replaces the ClickClack landing page behind the workspace rail's home
// button. Empty values keep the built-in default. A URL must be either an
// absolute http(s) URL or an absolute path on this deployment, so a
// misconfiguration can never turn the button into a javascript: or
// protocol-relative link.
func NormalizeHomeLink(rawURL, rawLabel string) (string, string, error) {
homeURL := strings.TrimSpace(rawURL)
if homeURL != "" {
switch {
case strings.HasPrefix(homeURL, "//"):
return "", "", errors.New("CLICKCLACK_HOME_URL must be an absolute http(s) URL or a path starting with /")
case strings.HasPrefix(homeURL, "/"):
// Absolute path on this deployment.
default:
parsed, err := url.Parse(homeURL)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
return "", "", errors.New("CLICKCLACK_HOME_URL must be an absolute http(s) URL or a path starting with /")
}
}
}
homeLabel := strings.TrimSpace(rawLabel)
if utf8.RuneCountInString(homeLabel) > MaxHomeLabelLength {
return "", "", fmt.Errorf("CLICKCLACK_HOME_LABEL must be at most %d characters", MaxHomeLabelLength)
}
return homeURL, homeLabel, nil
}
55 changes: 55 additions & 0 deletions apps/api/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -225,3 +226,57 @@ func TestLoadAccessConfigFromEnvironmentAndJSON(t *testing.T) {
t.Fatalf("Access JSON config was not loaded: %#v", cfg)
}
}

func TestNormalizeHomeLink(t *testing.T) {
cases := []struct {
name string
url string
label string
wantURL string
wantLabel string
wantErr bool
}{
{name: "empty keeps defaults", wantURL: "", wantLabel: ""},
{name: "absolute https", url: " https://mfs.example.com/ ", label: " MFS ", wantURL: "https://mfs.example.com/", wantLabel: "MFS"},
{name: "absolute path", url: "/app", label: "Home", wantURL: "/app", wantLabel: "Home"},
{name: "label only", label: "MFS", wantLabel: "MFS"},
{name: "javascript scheme", url: "javascript:alert(1)", wantErr: true},
{name: "protocol relative", url: "//evil.example.com", wantErr: true},
{name: "relative path", url: "app", wantErr: true},
{name: "label too long", url: "/app", label: strings.Repeat("x", MaxHomeLabelLength+1), wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
gotURL, gotLabel, err := NormalizeHomeLink(tc.url, tc.label)
if tc.wantErr {
if err == nil {
t.Fatalf("expected error, got url=%q label=%q", gotURL, gotLabel)
}
return
}
if err != nil {
t.Fatal(err)
}
if gotURL != tc.wantURL || gotLabel != tc.wantLabel {
t.Fatalf("got url=%q label=%q, want url=%q label=%q", gotURL, gotLabel, tc.wantURL, tc.wantLabel)
}
})
}
}

func TestValidateServeRejectsBadHomeLink(t *testing.T) {
cfg := Defaults()
cfg.HomeURL = "ftp://files.example.com"
if err := cfg.ValidateServe(); err == nil || !strings.Contains(err.Error(), "CLICKCLACK_HOME_URL") {
t.Fatalf("expected home URL error, got %v", err)
}
cfg = Defaults()
cfg.HomeURL = " https://mfs.example.com "
cfg.HomeLabel = " MFS "
if err := cfg.ValidateServe(); err != nil {
t.Fatal(err)
}
if cfg.HomeURL != "https://mfs.example.com" || cfg.HomeLabel != "MFS" {
t.Fatalf("expected trimmed home link, got url=%q label=%q", cfg.HomeURL, cfg.HomeLabel)
}
}
44 changes: 44 additions & 0 deletions apps/api/internal/httpapi/home_link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package httpapi

import (
"net/http"
"strings"
)

// HomeLinkConfig points the workspace rail's home button somewhere other than
// the ClickClack landing page, for deployments that live inside a larger
// product. Empty fields keep the built-in destination and label.
type HomeLinkConfig struct {
URL string
Label string
}

const (
defaultHomeLinkURL = "/"
defaultHomeLinkLabel = "cc"
)

type homeLinkPayload struct {
URL string `json:"url"`
Label string `json:"label"`
}

func (s *Server) homeLinkPayload() homeLinkPayload {
payload := homeLinkPayload{
URL: strings.TrimSpace(s.homeLinkConfig.URL),
Label: strings.TrimSpace(s.homeLinkConfig.Label),
}
if payload.URL == "" {
payload.URL = defaultHomeLinkURL
}
if payload.Label == "" {
payload.Label = defaultHomeLinkLabel
}
return payload
}

// homeLink is public: the signed-in shell reads it once at startup and it
// carries no user or workspace data.
func (s *Server) homeLink(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.homeLinkPayload())
}
4 changes: 4 additions & 0 deletions apps/api/internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ type Server struct {
openclawID OpenClawIDConfig
access *accessVerifier
frontendURL string
homeLinkConfig HomeLinkConfig
publicAPIURL string
embedFrameAncestors []string
cookies authpolicy.CookieNames
Expand Down Expand Up @@ -86,6 +87,7 @@ type Options struct {
Access AccessConfig
FrontendURL string
PublicAPIURL string
HomeLink HomeLinkConfig
EmbedFrameAncestors []string
CookieNames authpolicy.CookieNames
DisableDevAuth bool
Expand Down Expand Up @@ -123,6 +125,7 @@ func New(st store.Store, hub *realtime.Hub, options Options) *Server {
openclawID: options.OpenClawID.withDefaults(),
access: newAccessVerifier(options.Access),
frontendURL: strings.TrimSpace(options.FrontendURL),
homeLinkConfig: options.HomeLink,
publicAPIURL: strings.TrimRight(strings.TrimSpace(options.PublicAPIURL), "/"),
embedFrameAncestors: append([]string(nil), options.EmbedFrameAncestors...),
cookies: cookieNames,
Expand Down Expand Up @@ -166,6 +169,7 @@ func (s *Server) Handler() http.Handler {
r.Get("/auth/github/callback", s.githubCallback)
r.Get("/auth/openclaw/start", s.openclawIDStart)
r.Get("/auth/openclaw/callback", s.openclawIDCallback)
r.Get("/home-link", s.homeLink)
r.Get("/me", s.me)
r.Patch("/me", s.updateMe)
r.Get("/me/bots", s.listMyBots)
Expand Down
37 changes: 37 additions & 0 deletions apps/api/internal/httpapi/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3979,3 +3979,40 @@ func expectHTTPExactSeqs(t *testing.T, messages []store.Message, want ...int64)
}
}
}

func TestHomeLinkEndpoint(t *testing.T) {
for _, tc := range []struct {
name string
options Options
wantURL string
wantLabel string
}{
{name: "defaults to the landing page", wantURL: "/", wantLabel: "cc"},
{name: "configured product link", options: Options{HomeLink: HomeLinkConfig{URL: "https://mfs.example.com/", Label: "MFS"}}, wantURL: "https://mfs.example.com/", wantLabel: "MFS"},
{name: "partial config keeps the other default", options: Options{HomeLink: HomeLinkConfig{URL: " /app "}}, wantURL: "/app", wantLabel: "cc"},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(New(nil, nil, tc.options).Handler())
t.Cleanup(server.Close)
// Public: no cookie, no bearer token.
response, err := http.Get(server.URL + "/api/home-link")
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", response.StatusCode)
}
var payload struct {
URL string `json:"url"`
Label string `json:"label"`
}
if err := json.NewDecoder(response.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
if payload.URL != tc.wantURL || payload.Label != tc.wantLabel {
t.Fatalf("got %+v, want url=%q label=%q", payload, tc.wantURL, tc.wantLabel)
}
})
}
}
4 changes: 2 additions & 2 deletions apps/api/internal/webassets/dist/200.html
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@
<link href="/_app/immutable/chunks/uCPLfb5l.js" rel="modulepreload">
<link href="/_app/immutable/chunks/DK3Fl9T5.js" rel="modulepreload">
<link href="/_app/immutable/chunks/Dt-HX3Vu.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.C2SVyiZs.js" rel="modulepreload">
<link href="/_app/immutable/entry/app.CygmzIFi.js" rel="modulepreload">
<link href="/_app/immutable/chunks/HclGiUj8.js" rel="modulepreload">
<link href="/_app/immutable/chunks/xihTtKlq.js" rel="modulepreload">
<link href="/_app/immutable/nodes/0.DmQX232Z.js" rel="modulepreload">
Expand All @@ -125,7 +125,7 @@

Promise.all([
import("/_app/immutable/entry/start.CRNCrUAM.js"),
import("/_app/immutable/entry/app.C2SVyiZs.js")
import("/_app/immutable/entry/app.CygmzIFi.js")
]).then(([kit, app]) => {
kit.start(app, element);
});
Expand Down

Large diffs are not rendered by default.

This file was deleted.

Loading