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
39 changes: 39 additions & 0 deletions internal/cmd/user/page.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package user

import (
"github.com/antiwork/gumroad-cli/internal/cmdutil"
"github.com/antiwork/gumroad-cli/internal/pageutil"
"github.com/spf13/cobra"
)

func newPageCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "page",
Short: "Manage your profile landing page",
Example: ` gumroad user page preview ./landing.html
gumroad user page publish ./landing.html
gumroad user page publish - < landing.html
gumroad user page clear --yes
gumroad user page url`,
}

cmd.AddCommand(newPagePreviewCmd())
cmd.AddCommand(newPagePublishCmd())
cmd.AddCommand(newPageClearCmd())
cmd.AddCommand(newPageURLCmd())
return cmd
}

func profilePageHTMLArgs(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
return cmdutil.UsageErrorf(cmd, "unexpected argument: %s", args[1])
}
return nil
}

func profilePageHTMLPath(args []string) string {
if len(args) > 0 {
return args[0]
}
return pageutil.DefaultHTMLPath
}
47 changes: 47 additions & 0 deletions internal/cmd/user/page_clear.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package user

import (
"net/http"

"github.com/antiwork/gumroad-cli/internal/cmdutil"
"github.com/antiwork/gumroad-cli/internal/pageutil"
"github.com/spf13/cobra"
)

func newPageClearCmd() *cobra.Command {
return &cobra.Command{
Use: "clear",
Short: "Clear your profile landing page",
Args: cmdutil.ExactArgs(0),
RunE: func(c *cobra.Command, args []string) error {
opts := cmdutil.OptionsFrom(c)
ok, err := cmdutil.ConfirmAction(opts, "Clear your profile landing page?")
if err != nil {
return err
}
if !ok {
return cmdutil.PrintCancelledAction(opts, "clear profile landing page", "")
}

target := pageutil.ProfileTarget()
err = cmdutil.RunRequestDecoded[pageutil.ProfileUpdateResponse](
opts,
"Clearing page...",
http.MethodPut,
target.Path,
pageutil.ClearParams(),
func(resp pageutil.ProfileUpdateResponse) error {
return pageutil.RenderSanitizationResult(opts, pageutil.RenderResult{
Action: "Cleared page",
BeforeHTML: pageutil.ProfilePreviousHTML(resp),
AfterHTML: resp.CustomHTML,
LandingURL: resp.ProfileURL,
Report: resp.SanitizationReport,
ClearMessage: "Page cleared.",
})
},
)
return pageutil.TranslateRateLimitError(err, pageutil.ProfileClearRateLimitMessage)
},
}
}
77 changes: 77 additions & 0 deletions internal/cmd/user/page_clear_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package user

import (
"net/http"
"net/url"
"strings"
"testing"

"github.com/antiwork/gumroad-cli/internal/testutil"
)

func TestUserPageClearConfirmsAndSendsEmptyCustomHTML(t *testing.T) {
var gotMethod, gotPath string
var gotForm url.Values
var hasCustomHTML bool
testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
_ = r.ParseForm()
gotForm = r.PostForm
_, hasCustomHTML = r.PostForm["custom_html"]
previous := "<h1>Old</h1>"
testutil.JSON(t, w, map[string]any{
"custom_html": "",
"previous_custom_html": previous,
"profile_url": "https://jane.gumroad.com",
"sanitization_report": emptyReport(),
})
})

cmd := testutil.Command(newPageClearCmd(), testutil.Yes(true), testutil.Quiet(false), testutil.NoColor(true))
cmd.SetArgs([]string{})
out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) })

if gotMethod != http.MethodPut {
t.Errorf("got method %q, want PUT", gotMethod)
}
if gotPath != "/user/custom_html" {
t.Errorf("got path %q, want /user/custom_html", gotPath)
}
if !hasCustomHTML {
t.Fatalf("custom_html should be sent to clear")
}
if got := gotForm.Get("custom_html"); got != "" {
t.Fatalf("got custom_html=%q, want empty", got)
}
if !strings.Contains(out, "Page cleared.") {
t.Fatalf("output missing clear message: %q", out)
}
}

func TestUserPageClearNoInputRequiresYesBeforeAPI(t *testing.T) {
testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("clear without confirmation should not call API")
})

cmd := testutil.Command(newPageClearCmd(), testutil.NoInput(true), testutil.Quiet(false), testutil.NoColor(true))
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "Use --yes to skip confirmation") {
t.Fatalf("expected confirmation error, got %v", err)
}
}

func TestUserPageClearRateLimitMessage(t *testing.T) {
testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
testutil.RawJSON(t, w, `{"success":false,"message":"Rate limited"}`)
})

cmd := testutil.Command(newPageClearCmd(), testutil.Yes(true))
cmd.SetArgs([]string{})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "Wait a moment before trying again") {
t.Fatalf("expected clear-specific rate limit message, got %v", err)
}
}
43 changes: 43 additions & 0 deletions internal/cmd/user/page_preview.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package user

import (
"net/http"

"github.com/antiwork/gumroad-cli/internal/cmdutil"
"github.com/antiwork/gumroad-cli/internal/pageutil"
"github.com/spf13/cobra"
)

func newPagePreviewCmd() *cobra.Command {
return &cobra.Command{
Use: "preview [path]",
Short: "Preview server sanitization for your profile landing page",
Args: profilePageHTMLArgs,
RunE: func(c *cobra.Command, args []string) error {
opts := cmdutil.OptionsFrom(c)
input, err := pageutil.ReadHTML(opts.In(), profilePageHTMLPath(args))
if err != nil {
return cmdutil.UsageErrorf(c, "%s", err)
}

target := pageutil.ProfileTarget()
err = cmdutil.RunRequestDecoded[pageutil.PreviewResponse](
opts,
"Previewing page...",
http.MethodPost,
target.PreviewPath,
pageutil.HTMLParams(input.HTML),
func(resp pageutil.PreviewResponse) error {
return pageutil.RenderSanitizationResult(opts, pageutil.RenderResult{
Action: "Previewed page",
Source: input.Source,
BeforeHTML: input.HTML,
AfterHTML: resp.CustomHTML,
Report: resp.SanitizationReport,
})
},
)
return pageutil.TranslateRateLimitError(err, pageutil.ProfilePreviewRateLimitMessage)
},
}
}
149 changes: 149 additions & 0 deletions internal/cmd/user/page_preview_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package user

import (
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"

"github.com/antiwork/gumroad-cli/internal/testutil"
)

func TestUserPagePreviewPostsHTMLToPreviewEndpoint(t *testing.T) {
htmlPath := writeProfilePageHTML(t, "<script src=\"https://evil.test/x.js\"></script><h1>Hi</h1>")

var gotMethod, gotPath string
var gotForm url.Values
testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
gotMethod = r.Method
gotPath = r.URL.Path
_ = r.ParseForm()
gotForm = r.PostForm
testutil.JSON(t, w, map[string]any{
"custom_html": "<h1>Hi</h1>",
"sanitization_report": map[string]any{
"removed_tags": []map[string]any{{
"tag": "script",
"attrs": map[string]string{"src": "https://evil.test/x.js"},
"reason": "script src host not allowed",
}},
"removed_attributes": []any{},
"total_removed": 1,
"truncated": false,
},
})
})

cmd := testutil.Command(newPagePreviewCmd(), testutil.Quiet(false), testutil.NoColor(true))
cmd.SetArgs([]string{htmlPath})
out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) })

if gotMethod != http.MethodPost {
t.Errorf("got method %q, want POST", gotMethod)
}
if gotPath != "/user/preview_custom_html" {
t.Errorf("got path %q, want /user/preview_custom_html", gotPath)
}
if got := gotForm.Get("custom_html"); got != "<script src=\"https://evil.test/x.js\"></script><h1>Hi</h1>" {
t.Errorf("got custom_html=%q", got)
}
if !strings.Contains(out, "Previewed page") || !strings.Contains(out, "Sanitization removed 1 item") {
t.Fatalf("output missing preview summary: %q", out)
}
if !strings.Contains(out, "script src host not allowed") {
t.Fatalf("output missing report reason: %q", out)
}
}

func TestUserPagePreviewDefaultsToLandingHTML(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "landing.html"), []byte("<h1>Hi</h1>"), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
t.Chdir(dir)

var gotForm url.Values
testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
gotForm = r.PostForm
testutil.JSON(t, w, map[string]any{
"custom_html": "<h1>Hi</h1>",
"sanitization_report": emptyReport(),
})
})

cmd := testutil.Command(newPagePreviewCmd(), testutil.Quiet(false), testutil.NoColor(true))
cmd.SetArgs([]string{})
testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) })

if got := gotForm.Get("custom_html"); got != "<h1>Hi</h1>" {
t.Errorf("got custom_html=%q from default ./landing.html", got)
}
}

func TestUserPagePreviewDryRunDoesNotCallAPI(t *testing.T) {
htmlPath := writeProfilePageHTML(t, "<h1>Hi</h1>")

var calls atomic.Int32
testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
t.Errorf("preview --dry-run should not call API")
})

cmd := testutil.Command(newPagePreviewCmd(), testutil.DryRun(true), testutil.Quiet(false), testutil.NoColor(true))
cmd.SetArgs([]string{htmlPath})
out := testutil.CaptureStdout(func() { testutil.MustExecute(t, cmd) })

if calls.Load() != 0 {
t.Fatalf("API was called %d times", calls.Load())
}
if !strings.Contains(out, "Dry run: POST /user/preview_custom_html") {
t.Fatalf("dry-run output missing preview request: %q", out)
}
}

func TestUserPagePreviewRateLimitMessage(t *testing.T) {
htmlPath := writeProfilePageHTML(t, "<h1>Hi</h1>")

testutil.Setup(t, func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
testutil.RawJSON(t, w, `{"success":false,"message":"Rate limited"}`)
})

cmd := testutil.Command(newPagePreviewCmd())
cmd.SetArgs([]string{htmlPath})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "60 previews/min") {
t.Fatalf("expected preview-specific rate limit message, got %v", err)
}
}

func TestUserPagePreviewRejectsExtraArg(t *testing.T) {
cmd := testutil.Command(newPagePreviewCmd())
cmd.SetArgs([]string{"./landing.html", "unexpected"})
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "unexpected argument") {
t.Fatalf("expected usage error for extra arg, got %v", err)
}
}

func writeProfilePageHTML(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "landing.html")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write fixture: %v", err)
}
return path
}

func emptyReport() map[string]any {
return map[string]any{
"removed_tags": []any{},
"removed_attributes": []any{},
"total_removed": 0,
"truncated": false,
}
}
Loading
Loading