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 AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
### Before speaking

Never guess. Ground every claim in actual files, docs, or command output. If you're unsure about a dependency's size, run `go get` and check. If you're unsure about an API, read the source or docs. If you're unsure about behavior, write a test. State what you verified and how — don't speculate.

### While developing

#### Tests, tests, tests!
Expand Down
32 changes: 29 additions & 3 deletions e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ func (c *cliOpts) run(t *testing.T, args []string, opts map[string]string) (stdo
var cmdArgs []string
var extraEnv []string

// DB path goes before the subcommand (persistent flag).
// Persistent flags / env vars.
if c.mode == "flags" {
cmdArgs = append(cmdArgs, "--db", c.dbPath)
cmdArgs = append(cmdArgs, "--db", c.dbPath, "--unsafe-client")
} else {
extraEnv = append(extraEnv, "BLOGWATCHER_DB="+c.dbPath)
extraEnv = append(extraEnv, "BLOGWATCHER_DB="+c.dbPath, "BLOGWATCHER_UNSAFE_CLIENT=true")
}

// Positional args first (includes the subcommand name).
Expand Down Expand Up @@ -355,6 +355,10 @@ func TestE2E(t *testing.T) {
out = c.ok(t, []string{"articles"}, map[string]string{"blog": "go-blog"})
checkOutput(t, "12_articles_filter_blog", out, baseURL)

// ── Articles filtered by category ──
out = c.ok(t, []string{"articles"}, map[string]string{"category": "Engineering"})
checkOutput(t, "12b_articles_filter_category", out, baseURL)

// ── Read / unread cycle ──
articlesOut := c.ok(t, []string{"articles"}, nil)
id := extractFirstID(t, articlesOut)
Expand Down Expand Up @@ -402,6 +406,28 @@ func TestE2E(t *testing.T) {
}
}

func TestSSRFProtection(t *testing.T) {
baseURL := startTestServer(t)
dbPath := filepath.Join(t.TempDir(), "test.db")

// Add a blog pointing to the loopback test server WITHOUT --unsafe-client.
// The add command doesn't fetch, so it should succeed.
cmd := exec.CommandContext(context.Background(), binaryPath,
"--db", dbPath, "add", "test-blog", baseURL+"/go/",
"--feed-url", baseURL+"/go/feed.atom")
cmd.Env = append(os.Environ(), "NO_COLOR=1")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "add should succeed: %s", string(out))

// Scan WITHOUT --unsafe-client — the safe client should block loopback and fail.
cmd = exec.CommandContext(context.Background(), binaryPath,
"--db", dbPath, "scan")
cmd.Env = append(os.Environ(), "NO_COLOR=1")
out, err = cmd.CombinedOutput()
require.Error(t, err, "scan should fail when SSRF protection blocks loopback")
require.Contains(t, string(out), "is not authorized", "expected SSRF error message")
}

func extractFirstID(t *testing.T, output string) string {
t.Helper()
re := regexp.MustCompile(`\[(\d+)\]`)
Expand Down
3 changes: 3 additions & 0 deletions e2e/expected/11_articles_unread.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ Unread articles (11):
Blog: github-blog
URL: https://github.blog/news-insights/github-copilot-the-agent-awakens/
Published: 2026-04-01
Categories: AI, Copilot

[ID] [new] How we built the new GitHub Issues
Blog: github-blog
URL: https://github.blog/engineering/how-we-built-the-new-github-issues/
Published: 2026-03-28
Categories: Engineering

[ID] [new] More powerful Go execution traces
Blog: go-blog
Expand All @@ -36,6 +38,7 @@ Unread articles (11):
Blog: github-blog
URL: https://github.blog/engineering/the-uphill-climb-of-making-diff-lines-performant/
Published: 2026-04-03
Categories: Engineering, Performance

[ID] [new] Type Construction and Cycle Detection
Blog: go-blog
Expand Down
13 changes: 13 additions & 0 deletions e2e/expected/12b_articles_filter_category.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Unread articles (2):

[ID] [new] How we built the new GitHub Issues
Blog: github-blog
URL: https://github.blog/engineering/how-we-built-the-new-github-issues/
Published: 2026-03-28
Categories: Engineering

[ID] [new] The uphill climb of making diff lines performant
Blog: github-blog
URL: https://github.blog/engineering/the-uphill-climb-of-making-diff-lines-performant/
Published: 2026-04-03
Categories: Engineering, Performance
3 changes: 3 additions & 0 deletions e2e/expected/20_articles_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ All articles (11):
Blog: github-blog
URL: https://github.blog/news-insights/github-copilot-the-agent-awakens/
Published: 2026-04-01
Categories: AI, Copilot

[ID] [read] How we built the new GitHub Issues
Blog: github-blog
URL: https://github.blog/engineering/how-we-built-the-new-github-issues/
Published: 2026-03-28
Categories: Engineering

[ID] [read] More powerful Go execution traces
Blog: go-blog
Expand All @@ -36,6 +38,7 @@ All articles (11):
Blog: github-blog
URL: https://github.blog/engineering/the-uphill-climb-of-making-diff-lines-performant/
Published: 2026-04-03
Categories: Engineering, Performance

[ID] [read] Type Construction and Cycle Detection
Blog: go-blog
Expand Down
5 changes: 5 additions & 0 deletions e2e/testdata/github_blog.rss
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,25 @@
<link>https://github.blog/engineering/the-uphill-climb-of-making-diff-lines-performant/</link>
<dc:creator><![CDATA[Luke Ghenco]]></dc:creator>
<pubDate>Fri, 03 Apr 2026 16:00:00 +0000</pubDate>
<category>Engineering</category>
<category>Performance</category>
<description><![CDATA[The path to better performance is often found in simplicity.]]></description>
</item>
<item>
<title>GitHub Copilot: the agent awakens</title>
<link>https://github.blog/news-insights/github-copilot-the-agent-awakens/</link>
<dc:creator><![CDATA[Thomas Dohmke]]></dc:creator>
<pubDate>Wed, 01 Apr 2026 12:00:00 +0000</pubDate>
<category>AI</category>
<category>Copilot</category>
<description><![CDATA[GitHub Copilot is evolving from code completion to full agent.]]></description>
</item>
<item>
<title>How we built the new GitHub Issues</title>
<link>https://github.blog/engineering/how-we-built-the-new-github-issues/</link>
<dc:creator><![CDATA[Zach Posten]]></dc:creator>
<pubDate>Mon, 28 Mar 2026 10:00:00 +0000</pubDate>
<category>Engineering</category>
<description><![CDATA[A deep dive into the new GitHub Issues experience.]]></description>
</item>
</channel>
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/JulienTant/blogwatcher-cli
go 1.26.1

require (
github.com/DataDog/go-secure-sdk v0.0.7
github.com/Masterminds/squirrel v1.5.4
github.com/PuerkitoBio/goquery v1.12.0
github.com/fatih/color v1.19.0
Expand All @@ -11,6 +12,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/sync v0.20.0
modernc.org/sqlite v1.48.1
)

Expand Down Expand Up @@ -44,7 +46,6 @@ require (
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/tools v0.43.0 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
Expand Down
12 changes: 8 additions & 4 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
github.com/DataDog/go-secure-sdk v0.0.7 h1:FJYJPXXZmxOC0RHWzhsZ3PKtUaF+IgC6fDc/PbEoH6c=
github.com/DataDog/go-secure-sdk v0.0.7/go.mod h1:/fIdMM7LMp7KGouxN9Cnv3UP0NdfP+XZ5R8qyU3D8qk=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
Expand All @@ -21,6 +23,8 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down Expand Up @@ -66,8 +70,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
Expand Down Expand Up @@ -169,8 +173,8 @@ golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
Expand Down
32 changes: 24 additions & 8 deletions internal/cli/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,34 @@ package cli
import (
"bufio"
"fmt"
"net/http"
"os"
"strconv"
"strings"
"time"

"github.com/DataDog/go-secure-sdk/net/httpclient"
"github.com/fatih/color"
"github.com/spf13/cobra"
"github.com/spf13/viper"

"github.com/JulienTant/blogwatcher-cli/internal/controller"
"github.com/JulienTant/blogwatcher-cli/internal/model"
"github.com/JulienTant/blogwatcher-cli/internal/rss"
"github.com/JulienTant/blogwatcher-cli/internal/scanner"
"github.com/JulienTant/blogwatcher-cli/internal/scraper"
"github.com/JulienTant/blogwatcher-cli/internal/storage"
)

const httpTimeout = 30 * time.Second

func newHTTPClient() *http.Client {
if viper.GetBool("unsafe-client") {
return httpclient.UnSafe(httpclient.WithTimeout(httpTimeout))
}
return httpclient.Safe(httpclient.WithTimeout(httpTimeout))
}

func newAddCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "add <name> <url>",
Expand Down Expand Up @@ -148,8 +161,11 @@ func newScanCommand() *cobra.Command {
}
}()

client := newHTTPClient()
sc := scanner.NewScanner(rss.NewFetcher(client), scraper.NewScraper(client))

if len(args) == 1 {
result, err := scanner.ScanBlogByName(cmd.Context(), db, args[0])
result, err := sc.ScanBlogByName(cmd.Context(), db, args[0])
if err != nil {
return err
}
Expand All @@ -173,7 +189,7 @@ func newScanCommand() *cobra.Command {
if !silent {
cprintf([]color.Attribute{color.FgCyan}, "Scanning %d blog(s)...\n\n", len(blogs))
}
results, err := scanner.ScanAllBlogs(cmd.Context(), db, workers)
results, err := sc.ScanAllBlogs(cmd.Context(), db, workers)
if err != nil {
return err
}
Expand Down Expand Up @@ -221,7 +237,7 @@ func newArticlesCommand() *cobra.Command {
fmt.Fprintf(os.Stderr, "close db: %v\n", err)
}
}()
articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"))
articles, blogNames, err := controller.GetArticles(cmd.Context(), db, showAll, viper.GetString("blog"), viper.GetString("category"))
if err != nil {
printError(err)
return markError(err)
Expand Down Expand Up @@ -249,6 +265,7 @@ func newArticlesCommand() *cobra.Command {

cmd.Flags().BoolP("all", "a", false, "Show all articles (including read)")
cmd.Flags().StringP("blog", "b", "", "Filter by blog name")
cmd.Flags().StringP("category", "c", "", "Filter by category")
return cmd
}

Expand Down Expand Up @@ -304,7 +321,7 @@ func newReadAllCommand() *cobra.Command {
}
}()

articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName)
articles, _, err := controller.GetArticles(cmd.Context(), db, false, blogName, "")
if err != nil {
printError(err)
return markError(err)
Expand Down Expand Up @@ -385,10 +402,6 @@ func printScanResult(result scanner.ScanResult) {
statusColor = []color.Attribute{color.FgGreen}
}
cprintf([]color.Attribute{color.FgWhite, color.Bold}, " %s\n", result.BlogName)
if result.Error != "" {
cprintfErr(color.FgRed, " Error: %s\n", result.Error)
return
}
if result.Source == "none" {
cprintln([]color.Attribute{color.FgYellow}, " No feed or scraper configured")
return
Expand All @@ -413,6 +426,9 @@ func printArticle(article model.Article, blogName string) {
if article.PublishedDate != nil {
fmt.Printf(" Published: %s\n", article.PublishedDate.Format("2006-01-02"))
}
if len(article.Categories) > 0 {
fmt.Printf(" Categories: %s\n", strings.Join(article.Categories, ", "))
}
fmt.Println()
}

Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ func NewRootCommand() *cobra.Command {
rootCmd.SetVersionTemplate("{{.Version}}\n")

rootCmd.PersistentFlags().String("db", "", "Path to the SQLite database file (default: ~/.blogwatcher-cli/blogwatcher-cli.db)")
rootCmd.PersistentFlags().Bool("unsafe-client", false, "Disable SSRF protection (allow requests to private/loopback IPs)")

rootCmd.AddCommand(newAddCommand())
rootCmd.AddCommand(newRemoveCommand())
Expand Down
11 changes: 8 additions & 3 deletions internal/controller/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ func RemoveBlog(ctx context.Context, db *storage.Database, name string) error {
return err
}

func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string) ([]model.Article, map[int64]string, error) {
func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogName string, category string) ([]model.Article, map[int64]string, error) {
var blogID *int64
if blogName != "" {
blog, err := db.GetBlogByName(ctx, blogName)
Expand All @@ -79,7 +79,12 @@ func GetArticles(ctx context.Context, db *storage.Database, showAll bool, blogNa
blogID = &blog.ID
}

articles, err := db.ListArticles(ctx, !showAll, blogID)
var categoryPtr *string
if category != "" {
categoryPtr = &category
}

articles, err := db.ListArticles(ctx, !showAll, blogID, categoryPtr)
if err != nil {
return nil, nil, err
}
Expand Down Expand Up @@ -125,7 +130,7 @@ func MarkAllArticlesRead(ctx context.Context, db *storage.Database, blogName str
blogID = &blog.ID
}

articles, err := db.ListArticles(ctx, true, blogID)
articles, err := db.ListArticles(ctx, true, blogID, nil)
if err != nil {
return nil, err
}
Expand Down
29 changes: 27 additions & 2 deletions internal/controller/controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,40 @@ func TestGetArticlesFilters(t *testing.T) {
_, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Title", URL: "https://example.com/1"})
require.NoError(t, err, "add article")

articles, blogNames, err := GetArticles(ctx, db, false, "")
articles, blogNames, err := GetArticles(ctx, db, false, "", "")
require.NoError(t, err, "get articles")
require.Len(t, articles, 1)
require.Equal(t, blog.Name, blogNames[blog.ID])

_, _, err = GetArticles(ctx, db, false, "Missing")
_, _, err = GetArticles(ctx, db, false, "Missing", "")
require.Error(t, err, "expected blog not found error")
}

func TestGetArticlesFilterByCategory(t *testing.T) {
ctx := context.Background()
db := openTestDB(t)
defer func() { require.NoError(t, db.Close()) }()

blog, err := AddBlog(ctx, db, "Test", "https://example.com", "", "")
require.NoError(t, err, "add blog")

_, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Go Post", URL: "https://example.com/1", Categories: []string{"Go", "Programming"}})
require.NoError(t, err, "add article")
_, err = db.AddArticle(ctx, model.Article{BlogID: blog.ID, Title: "Rust Post", URL: "https://example.com/2", Categories: []string{"Rust"}})
require.NoError(t, err, "add article")

// Filter by Go
articles, _, err := GetArticles(ctx, db, false, "", "Go")
require.NoError(t, err, "get articles by category")
require.Len(t, articles, 1)
require.Equal(t, "Go Post", articles[0].Title)

// No filter returns all
all, _, err := GetArticles(ctx, db, false, "", "")
require.NoError(t, err, "get all articles")
require.Len(t, all, 2)
}

func openTestDB(t *testing.T) *storage.Database {
t.Helper()
path := filepath.Join(t.TempDir(), "blogwatcher-cli.db")
Expand Down
Loading