diff --git a/plugins/scholar/scholar.go b/plugins/scholar/scholar.go index f88a5a1..e23f823 100644 --- a/plugins/scholar/scholar.go +++ b/plugins/scholar/scholar.go @@ -7,8 +7,12 @@ import ( "errors" "fmt" "goblog/blog" + "html" "log" + "net/url" "sort" + "strconv" + "strings" "sync" "time" @@ -115,12 +119,13 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s return "", nil } + data := gin.H{"has_plugin_content": true} + settings := ctx.Settings scholarID := settings["scholar_id"] if scholarID == "" { - return "page_research.html", gin.H{ - "errors": "Google Scholar ID not configured. Set it in the Scholar plugin settings.", - } + data["plugin_content"] = `` + return "page_content.html", data } limitStr := settings["article_limit"] @@ -132,18 +137,71 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s p.ensureScholar(settings) articles, err := p.sch.QueryProfileWithMemoryCache(scholarID, limit) - data := gin.H{} - if err == nil { - sortArticlesByDateDesc(articles) - p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) - data["articles"] = articles - } else { + if err != nil { log.Printf("Scholar query failed: %v", err) - data["articles"] = make([]*scholarlib.Article, 0) - data["errors"] = err.Error() + data["plugin_content"] = `` + return "page_content.html", data + } + + sortArticlesByDateDesc(articles) + p.sch.SaveCache(settings["profile_cache"], settings["article_cache"]) + + data["plugin_content"] = renderArticlesHTML(articles) + return "page_content.html", data +} + +// safeHref returns the URL only if it uses http or https scheme, otherwise empty. +func safeHref(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + if u.Scheme != "http" && u.Scheme != "https" { + return "" + } + return html.EscapeString(rawURL) +} + +// renderArticlesHTML generates the HTML for the articles list. +func renderArticlesHTML(articles []*scholarlib.Article) string { + if len(articles) == 0 { + return `

No publications found.

` } - return "page_research.html", data + var b strings.Builder + for _, a := range articles { + b.WriteString(`
`) + + // Title — link only if URL is safe + href := safeHref(a.ScholarURL) + if href != "" { + b.WriteString(`
` + html.EscapeString(a.Title) + `
`) + } else { + b.WriteString(`
` + html.EscapeString(a.Title) + `
`) + } + + if a.Authors != "" { + b.WriteString(`
` + html.EscapeString(a.Authors) + `
`) + } + + // Meta line: year · journal · citations + var meta []string + if a.Year > 0 { + meta = append(meta, strconv.Itoa(a.Year)) + } + if a.Journal != "" { + meta = append(meta, html.EscapeString(a.Journal)) + } + if a.NumCitations > 0 { + meta = append(meta, strconv.Itoa(a.NumCitations)+" citations") + } + if len(meta) > 0 { + b.WriteString(`
` + strings.Join(meta, " · ") + `
`) + } + + b.WriteString(`
`) + } + return b.String() } func (p *ScholarPlugin) ScheduledJobs() []gplugin.ScheduledJob { diff --git a/plugins/scholar/scholar_test.go b/plugins/scholar/scholar_test.go index f3ca677..1b816bd 100644 --- a/plugins/scholar/scholar_test.go +++ b/plugins/scholar/scholar_test.go @@ -1,8 +1,10 @@ package scholar import ( - scholarlib "github.com/compscidr/scholar" + "strings" "testing" + + scholarlib "github.com/compscidr/scholar" ) func TestSortArticlesByDateDesc(t *testing.T) { @@ -23,3 +25,84 @@ func TestSortArticlesByDateDesc(t *testing.T) { } } } + +func TestRenderArticlesHTML_Empty(t *testing.T) { + result := renderArticlesHTML(nil) + if !strings.Contains(result, "No publications found") { + t.Errorf("expected 'No publications found' for empty list, got %q", result) + } +} + +func TestRenderArticlesHTML_WithArticles(t *testing.T) { + articles := []*scholarlib.Article{ + { + Title: "Test Paper", + Authors: "Alice, Bob", + ScholarURL: "https://scholar.google.com/test", + Year: 2024, + Journal: "Test Journal", + NumCitations: 42, + }, + } + result := renderArticlesHTML(articles) + + if !strings.Contains(result, "Test Paper") { + t.Error("expected title in output") + } + if !strings.Contains(result, "Alice, Bob") { + t.Error("expected authors in output") + } + if !strings.Contains(result, "2024") { + t.Error("expected year in output") + } + if !strings.Contains(result, "Test Journal") { + t.Error("expected journal in output") + } + if !strings.Contains(result, "42 citations") { + t.Error("expected citation count in output") + } + if !strings.Contains(result, `href="https://scholar.google.com/test"`) { + t.Error("expected scholar URL in href") + } +} + +func TestRenderArticlesHTML_XSSEscaping(t *testing.T) { + articles := []*scholarlib.Article{ + { + Title: ``, + Authors: `Bob "the hacker"`, + ScholarURL: "https://scholar.google.com/safe", + }, + } + result := renderArticlesHTML(articles) + + if strings.Contains(result, " + + + {{ end }} - - - - {{ template "footer.html" .}} diff --git a/themes/default/templates/page_research.html b/themes/default/templates/page_research.html deleted file mode 100644 index f998526..0000000 --- a/themes/default/templates/page_research.html +++ /dev/null @@ -1,30 +0,0 @@ -{{ template "header.html" .}} - - {{ if .page.HasHero }} -
-   -
- {{ end }} - -
-

{{ .page.Title }}

- {{ if .is_admin }} -

Edit this page

- {{ end }} - {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} diff --git a/themes/default/templates/research.html b/themes/default/templates/research.html deleted file mode 100644 index 0292b55..0000000 --- a/themes/default/templates/research.html +++ /dev/null @@ -1,25 +0,0 @@ -{{ template "header.html" .}} - -
-   -
- -
-

Research and Publications

- {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} \ No newline at end of file diff --git a/themes/forest/templates/page_content.html b/themes/forest/templates/page_content.html index 453e7f5..67e10b8 100644 --- a/themes/forest/templates/page_content.html +++ b/themes/forest/templates/page_content.html @@ -5,15 +5,18 @@

{{ .page.Title }}

{{ if .is_admin }}

Edit this page

{{ end }} + {{ if .has_plugin_content }} +
{{ .plugin_content | rawHTML }}
+ {{ else }}
+ + + + {{ end }} - - - - {{ template "footer.html" .}} diff --git a/themes/forest/templates/page_research.html b/themes/forest/templates/page_research.html deleted file mode 100644 index ca191f1..0000000 --- a/themes/forest/templates/page_research.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ template "header.html" .}} - -
-

{{ .page.Title }}

- {{ if .is_admin }} -

Edit this page

- {{ end }} - {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}} diff --git a/themes/minimal/templates/page_content.html b/themes/minimal/templates/page_content.html index 33ca2e3..459946d 100644 --- a/themes/minimal/templates/page_content.html +++ b/themes/minimal/templates/page_content.html @@ -5,15 +5,18 @@

{{ .page.Title }}

{{ if .is_admin }}

Edit this page

{{ end }} + {{ if .has_plugin_content }} +
{{ .plugin_content | rawHTML }}
+ {{ else }}
+ + + + {{ end }} - - - - {{ template "footer.html" .}} diff --git a/themes/minimal/templates/page_research.html b/themes/minimal/templates/page_research.html deleted file mode 100644 index 90e874d..0000000 --- a/themes/minimal/templates/page_research.html +++ /dev/null @@ -1,24 +0,0 @@ -{{ template "header.html" .}} - -
-

{{ .page.Title }}

- {{ if .is_admin }} -

Edit this page

- {{ end }} - {{ if .errors }} - - {{ end }} - {{range .articles}} -
-
- {{.Title}}
- {{.Authors}}
-
-
{{.Year}}
-
- {{end}} -
- -{{ template "footer.html" .}}