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
7 changes: 7 additions & 0 deletions docs/content/configuration/parameters.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ output:
directory: 'static/images/%Y-%m-%d_%H%M%S'
filename: '[:id].png'
url: '/images/%Y-%m-%d_%H%M%S'
trusted_hosts:
- 'github.com'
- 'user-images.githubusercontent.com'
- 'private-user-images.githubusercontent.com'
```

## Configuration Items
Expand Down Expand Up @@ -52,11 +56,14 @@ Output settings.
- `filename`: Image filename
- `url`: Image URL referenced from Markdown
- `targets`: URL prefixes to detect and replace in issue bodies
- `trusted_hosts`: Exact HTTPS hosts that may receive the GitHub token when images are downloaded

If `targets` is omitted, the built-in GitHub attachment URL rules are used.
If `targets: []` is specified, no image URLs are detected or replaced.
Wildcard host patterns such as `https://*.githubusercontent.com` are also supported.

`trusted_hosts` is an independent security boundary: adding a URL to `targets` does **not** grant it access to the token. Hosts match exactly (including an explicit port), and tokens are sent only over HTTPS. On redirects, the token is removed unless the redirect destination is also listed. If omitted, the default GitHub attachment hosts (`github.com`, `user-images.githubusercontent.com`, and `private-user-images.githubusercontent.com`) are trusted for backward compatibility. Set `trusted_hosts: []` to download every image without a token.

`[:id]` will be replaced with the image ID. The image ID is unique within each issue and assigned sequentially.

## Placeholders
Expand Down
4 changes: 4 additions & 0 deletions gic.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ output:
targets:
- "https://github.com/user-attachments/"
- "https://*.githubusercontent.com"
trusted_hosts:
- "github.com"
- "user-images.githubusercontent.com"
- "private-user-images.githubusercontent.com"

# For page bundle
# output:
Expand Down
104 changes: 100 additions & 4 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,92 @@ import (
"github.com/spf13/viper"
)

func TestOutputImagesConfig_TrustedImageHosts(t *testing.T) {
images := NewOutputImagesConfig()
expected := []string{
"github.com",
"user-images.githubusercontent.com",
"private-user-images.githubusercontent.com",
}
got := images.TrustedImageHosts()
if len(got) != len(expected) {
t.Fatalf("trusted hosts = %#v, want %#v", got, expected)
}
for i := range expected {
if got[i] != expected[i] {
t.Fatalf("trusted hosts = %#v, want %#v", got, expected)
}
}
}

func TestOutputImagesConfig_TrustedImageHosts_PreservesExplicitEmptyValue(t *testing.T) {
images := &OutputImagesConfig{TrustedHosts: []string{}}
if got := images.TrustedImageHosts(); len(got) != 0 {
t.Fatalf("trusted hosts = %#v, want empty", got)
}
}

func TestConfig_TrustedImageHosts_WithPartialConfigUsesDefaults(t *testing.T) {
conf := Config{}
if got := conf.TrustedImageHosts(); len(got) != len(defaultTrustedImageHosts) {
t.Fatalf("trusted hosts = %#v, want defaults", got)
}
}

func TestConfigValidate_RejectsInvalidTrustedImageHosts(t *testing.T) {
conf := NewConfig()
conf.Output.Images.TrustedHosts = []string{"https://github.com"}
if err := conf.validate(); err == nil {
t.Fatal("expected trusted host validation to fail")
}
}

func TestReload_MigratesOmittedTrustedImageHostsToDefaults(t *testing.T) {
tempDir := t.TempDir()
originalWd, err := os.Getwd()
if err != nil {
t.Fatalf("getwd: %v", err)
}
t.Cleanup(func() {
if err := os.Chdir(originalWd); err != nil {
t.Fatalf("restore wd: %v", err)
}
config = Config{}
viper.Reset()
})
if err := os.Chdir(tempDir); err != nil {
t.Fatalf("chdir: %v", err)
}
config = Config{}
viper.Reset()

contents := "output:\n images:\n directory: static/images\n filename: '[:id].png'\n url: /images\n"
if err := os.WriteFile(GetConfigPath(), []byte(contents), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}

reloaded, err := Reload()
if err != nil {
t.Fatalf("reload: %v", err)
}
if reloaded.Output.Images.TrustedHosts == nil {
t.Fatal("trusted hosts were not migrated to defaults")
}
if got := reloaded.TrustedImageHosts(); len(got) != len(defaultTrustedImageHosts) {
t.Fatalf("trusted hosts = %#v, want defaults", got)
}
if err := Write(reloaded); err != nil {
t.Fatalf("write migrated config: %v", err)
}
data, err := os.ReadFile(GetConfigPath())
if err != nil {
t.Fatalf("read migrated config: %v", err)
}
if !strings.Contains(string(data), "trusted_hosts:") || !strings.Contains(string(data), "- github.com") {
t.Fatalf("expected migrated trusted hosts in config, got:\n%s", string(data))
}
}

func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) {
tempDir := t.TempDir()

Expand All @@ -34,10 +120,11 @@ func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) {
Output: &OutputConfig{
Articles: NewOutputArticlesConfig(),
Images: &OutputImagesConfig{
Directory: "static/images",
Filename: "[:id].png",
BaseURL: Ptr("/images"),
Targets: []string{},
Directory: "static/images",
Filename: "[:id].png",
BaseURL: Ptr("/images"),
Targets: []string{},
TrustedHosts: []string{},
},
},
}
Expand All @@ -53,6 +140,9 @@ func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) {
if !strings.Contains(string(data), "targets: []") {
t.Fatalf("expected explicit empty targets in config, got:\n%s", string(data))
}
if !strings.Contains(string(data), "trusted_hosts: []") {
t.Fatalf("expected explicit empty trusted hosts in config, got:\n%s", string(data))
}

reloaded, err := Reload()
if err != nil {
Expand All @@ -67,4 +157,10 @@ func TestWriteAndReload_PreservesExplicitEmptyImageTargets(t *testing.T) {
if len(reloaded.Output.Images.TargetURLs()) != 0 {
t.Fatalf("target urls = %#v", reloaded.Output.Images.TargetURLs())
}
if reloaded.Output.Images.TrustedHosts == nil {
t.Fatal("trusted hosts became nil after reload")
}
if len(reloaded.Output.Images.TrustedImageHosts()) != 0 {
t.Fatalf("trusted hosts = %#v", reloaded.Output.Images.TrustedImageHosts())
}
}
39 changes: 32 additions & 7 deletions pkg/config/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ type OutputArticlesConfig struct {
}

type OutputImagesConfig struct {
Directory string `yaml:"directory" mapstructure:"directory"`
Filename string `yaml:"filename" mapstructure:"filename"`
BaseURL *string `yaml:"url" mapstructure:"url"`
Targets []string `yaml:"targets" mapstructure:"targets"`
Directory string `yaml:"directory" mapstructure:"directory"`
Filename string `yaml:"filename" mapstructure:"filename"`
BaseURL *string `yaml:"url" mapstructure:"url"`
Targets []string `yaml:"targets" mapstructure:"targets"`
TrustedHosts []string `yaml:"trusted_hosts" mapstructure:"trusted_hosts"`
Comment thread
rokuosanai marked this conversation as resolved.
}

var defaultImageTargets = []string{
Expand All @@ -38,6 +39,12 @@ var defaultImageTargets = []string{
"https://private-user-images.githubusercontent.com/",
}

var defaultTrustedImageHosts = []string{
"github.com",
"user-images.githubusercontent.com",
"private-user-images.githubusercontent.com",
}

type HugoConfig struct {
Content *HugoContentConfig `yaml:"content,omitempty" mapstructure:"content"`
Images *HugoImagesConfig `yaml:"images,omitempty" mapstructure:"images"`
Expand Down Expand Up @@ -109,9 +116,10 @@ func NewOutputArticlesConfig() *OutputArticlesConfig {
func NewOutputImagesConfig() *OutputImagesConfig {
url := "/images/%Y-%m-%d_%H%M%S"
return &OutputImagesConfig{
Directory: "static/images/%Y-%m-%d_%H%M%S",
Filename: "[:id].png",
BaseURL: &url,
Directory: "static/images/%Y-%m-%d_%H%M%S",
Filename: "[:id].png",
BaseURL: &url,
TrustedHosts: append([]string(nil), defaultTrustedImageHosts...),
}
}

Expand All @@ -129,6 +137,20 @@ func (c *OutputImagesConfig) TargetURLs() []string {
return c.Targets
}

func (c *OutputImagesConfig) TrustedImageHosts() []string {
if c == nil || c.TrustedHosts == nil {
return defaultTrustedImageHosts
}
return c.TrustedHosts
}

func (c *Config) TrustedImageHosts() []string {
if c == nil || c.Output == nil {
return defaultTrustedImageHosts
}
return c.Output.Images.TrustedImageHosts()
}

func (c *Config) normalize() {
if c.GitHub == nil {
c.GitHub = NewGitHubConfig()
Expand All @@ -148,6 +170,9 @@ func (c *Config) normalize() {
if c.Output.Images == nil {
c.Output.Images = &OutputImagesConfig{}
}
if c.Output.Images.TrustedHosts == nil {
c.Output.Images.TrustedHosts = append([]string(nil), defaultTrustedImageHosts...)
}

if c.Hugo == nil {
return
Expand Down
17 changes: 17 additions & 0 deletions pkg/config/validation.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package config
import (
"fmt"
"log/slog"
"net/url"
"strings"
)

func (c *Config) validate() error {
Expand All @@ -12,6 +14,7 @@ func (c *Config) validate() error {
}{
// Constraints
{"Failed to validate deprecated options", c.WarnDeprecatedOptions},
{"Failed to validate output.images.trusted_hosts", c.ValidTrustedImageHosts},
}

// Check
Expand All @@ -25,6 +28,20 @@ func (c *Config) validate() error {
return nil
}

func (c *Config) ValidTrustedImageHosts() bool {
if c.Output == nil || c.Output.Images == nil {
return true
}

for _, host := range c.Output.Images.TrustedImageHosts() {
parsed, err := url.Parse("https://" + host)
if err != nil || host == "" || parsed.Host != host || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" || strings.Contains(host, "@") {
return false
}
}
return true
}

func (c *Config) WarnDeprecatedOptions() bool {
if c.Hugo == nil {
return true
Expand Down
2 changes: 1 addition & 1 deletion pkg/core/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ func NewArticleGeneratorWithLogger(conf config.Config, token string, logger *slo
return nil, err
}

imageRepo := NewHTTPImageRepositoryWithLogger(token, logger)
imageRepo := NewHTTPImageRepositoryWithTrustedHostsAndLogger(token, conf.TrustedImageHosts(), logger)
articleRepo := NewFileSystemArticleRepositoryWithLogger(imageRepo, logger)

// Initialize services.
Expand Down
6 changes: 6 additions & 0 deletions pkg/core/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ func TestNewArticleGenerator(t *testing.T) {
})
}

func TestNewArticleGeneratorWithPartialConfig(t *testing.T) {
gen, err := NewArticleGenerator(config.Config{}, "test-token")
assert.NoError(t, err)
assert.NotNil(t, gen)
}

func TestArticleGenerator_ConvertIssueToArticle(t *testing.T) {
conf := *config.NewConfig()
conf.Output.Images.BaseURL = Ptr("/images")
Expand Down
Loading
Loading