Skip to content

Commit 1fa7e76

Browse files
authored
Merge pull request #522 from goblogplatform/fix/plugin-pages-use-content-template
Plugin pages render through generic content template
2 parents 5021910 + c81c85c commit 1fa7e76

9 files changed

Lines changed: 187 additions & 140 deletions

File tree

plugins/scholar/scholar.go

Lines changed: 70 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@ import (
77
"errors"
88
"fmt"
99
"goblog/blog"
10+
"html"
1011
"log"
12+
"net/url"
1113
"sort"
14+
"strconv"
15+
"strings"
1216
"sync"
1317
"time"
1418

@@ -115,12 +119,13 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s
115119
return "", nil
116120
}
117121

122+
data := gin.H{"has_plugin_content": true}
123+
118124
settings := ctx.Settings
119125
scholarID := settings["scholar_id"]
120126
if scholarID == "" {
121-
return "page_research.html", gin.H{
122-
"errors": "Google Scholar ID not configured. Set it in the Scholar plugin settings.",
123-
}
127+
data["plugin_content"] = `<div class="alert alert-warning" role="alert">Google Scholar ID not configured. Set it in the Scholar plugin settings.</div>`
128+
return "page_content.html", data
124129
}
125130

126131
limitStr := settings["article_limit"]
@@ -132,18 +137,71 @@ func (p *ScholarPlugin) RenderPage(ctx *gplugin.HookContext, pageType string) (s
132137
p.ensureScholar(settings)
133138

134139
articles, err := p.sch.QueryProfileWithMemoryCache(scholarID, limit)
135-
data := gin.H{}
136-
if err == nil {
137-
sortArticlesByDateDesc(articles)
138-
p.sch.SaveCache(settings["profile_cache"], settings["article_cache"])
139-
data["articles"] = articles
140-
} else {
140+
if err != nil {
141141
log.Printf("Scholar query failed: %v", err)
142-
data["articles"] = make([]*scholarlib.Article, 0)
143-
data["errors"] = err.Error()
142+
data["plugin_content"] = `<div class="alert alert-danger" role="alert">` + html.EscapeString(err.Error()) + `</div>`
143+
return "page_content.html", data
144+
}
145+
146+
sortArticlesByDateDesc(articles)
147+
p.sch.SaveCache(settings["profile_cache"], settings["article_cache"])
148+
149+
data["plugin_content"] = renderArticlesHTML(articles)
150+
return "page_content.html", data
151+
}
152+
153+
// safeHref returns the URL only if it uses http or https scheme, otherwise empty.
154+
func safeHref(rawURL string) string {
155+
u, err := url.Parse(rawURL)
156+
if err != nil {
157+
return ""
158+
}
159+
if u.Scheme != "http" && u.Scheme != "https" {
160+
return ""
161+
}
162+
return html.EscapeString(rawURL)
163+
}
164+
165+
// renderArticlesHTML generates the HTML for the articles list.
166+
func renderArticlesHTML(articles []*scholarlib.Article) string {
167+
if len(articles) == 0 {
168+
return `<p>No publications found.</p>`
144169
}
145170

146-
return "page_research.html", data
171+
var b strings.Builder
172+
for _, a := range articles {
173+
b.WriteString(`<div style="margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #eee;">`)
174+
175+
// Title — link only if URL is safe
176+
href := safeHref(a.ScholarURL)
177+
if href != "" {
178+
b.WriteString(`<div><a href="` + href + `">` + html.EscapeString(a.Title) + `</a></div>`)
179+
} else {
180+
b.WriteString(`<div>` + html.EscapeString(a.Title) + `</div>`)
181+
}
182+
183+
if a.Authors != "" {
184+
b.WriteString(`<div style="color: #666; font-size: 13px;">` + html.EscapeString(a.Authors) + `</div>`)
185+
}
186+
187+
// Meta line: year · journal · citations
188+
var meta []string
189+
if a.Year > 0 {
190+
meta = append(meta, strconv.Itoa(a.Year))
191+
}
192+
if a.Journal != "" {
193+
meta = append(meta, html.EscapeString(a.Journal))
194+
}
195+
if a.NumCitations > 0 {
196+
meta = append(meta, strconv.Itoa(a.NumCitations)+" citations")
197+
}
198+
if len(meta) > 0 {
199+
b.WriteString(`<div style="color: #888; font-size: 13px;">` + strings.Join(meta, " &middot; ") + `</div>`)
200+
}
201+
202+
b.WriteString(`</div>`)
203+
}
204+
return b.String()
147205
}
148206

149207
func (p *ScholarPlugin) ScheduledJobs() []gplugin.ScheduledJob {

plugins/scholar/scholar_test.go

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package scholar
22

33
import (
4-
scholarlib "github.com/compscidr/scholar"
4+
"strings"
55
"testing"
6+
7+
scholarlib "github.com/compscidr/scholar"
68
)
79

810
func TestSortArticlesByDateDesc(t *testing.T) {
@@ -23,3 +25,84 @@ func TestSortArticlesByDateDesc(t *testing.T) {
2325
}
2426
}
2527
}
28+
29+
func TestRenderArticlesHTML_Empty(t *testing.T) {
30+
result := renderArticlesHTML(nil)
31+
if !strings.Contains(result, "No publications found") {
32+
t.Errorf("expected 'No publications found' for empty list, got %q", result)
33+
}
34+
}
35+
36+
func TestRenderArticlesHTML_WithArticles(t *testing.T) {
37+
articles := []*scholarlib.Article{
38+
{
39+
Title: "Test Paper",
40+
Authors: "Alice, Bob",
41+
ScholarURL: "https://scholar.google.com/test",
42+
Year: 2024,
43+
Journal: "Test Journal",
44+
NumCitations: 42,
45+
},
46+
}
47+
result := renderArticlesHTML(articles)
48+
49+
if !strings.Contains(result, "Test Paper") {
50+
t.Error("expected title in output")
51+
}
52+
if !strings.Contains(result, "Alice, Bob") {
53+
t.Error("expected authors in output")
54+
}
55+
if !strings.Contains(result, "2024") {
56+
t.Error("expected year in output")
57+
}
58+
if !strings.Contains(result, "Test Journal") {
59+
t.Error("expected journal in output")
60+
}
61+
if !strings.Contains(result, "42 citations") {
62+
t.Error("expected citation count in output")
63+
}
64+
if !strings.Contains(result, `href="https://scholar.google.com/test"`) {
65+
t.Error("expected scholar URL in href")
66+
}
67+
}
68+
69+
func TestRenderArticlesHTML_XSSEscaping(t *testing.T) {
70+
articles := []*scholarlib.Article{
71+
{
72+
Title: `<script>alert("xss")</script>`,
73+
Authors: `Bob "the hacker"`,
74+
ScholarURL: "https://scholar.google.com/safe",
75+
},
76+
}
77+
result := renderArticlesHTML(articles)
78+
79+
if strings.Contains(result, "<script>") {
80+
t.Error("title should be HTML-escaped")
81+
}
82+
if strings.Contains(result, `"the hacker"`) {
83+
t.Error("authors should be HTML-escaped")
84+
}
85+
}
86+
87+
func TestSafeHref(t *testing.T) {
88+
tests := []struct {
89+
input string
90+
safe bool
91+
}{
92+
{"https://scholar.google.com/test", true},
93+
{"http://example.com", true},
94+
{"javascript:alert(1)", false},
95+
{"data:text/html,<h1>hi</h1>", false},
96+
{"ftp://files.example.com", false},
97+
{"", false},
98+
}
99+
for _, tt := range tests {
100+
result := safeHref(tt.input)
101+
if tt.safe && result == "" {
102+
t.Errorf("expected %q to be safe, got empty", tt.input)
103+
}
104+
if !tt.safe && result != "" {
105+
t.Errorf("expected %q to be blocked, got %q", tt.input, result)
106+
}
107+
}
108+
}

themes/default/templates/page_content.html

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,18 @@ <h1>{{ .page.Title }}</h1>
2121
{{ if .is_admin }}
2222
<p class="text-left"><a href="/admin/pages/{{ .page.ID }}">Edit this page</a></p>
2323
{{ end }}
24+
{{ if .has_plugin_content }}
25+
<div id="page-content">{{ .plugin_content | rawHTML }}</div>
26+
{{ else }}
2427
<div id="page-content"></div>
28+
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous"></script>
29+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js" integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ==" crossorigin="anonymous"></script>
30+
<script>
31+
var converter = new showdown.Converter({tables: true});
32+
var content = {{ .page.Content }};
33+
document.getElementById('page-content').innerHTML = DOMPurify.sanitize(converter.makeHtml(content));
34+
</script>
35+
{{ end }}
2536
</div>
2637

27-
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous"></script>
28-
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js" integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ==" crossorigin="anonymous"></script>
29-
<script>
30-
var converter = new showdown.Converter({tables: true});
31-
var content = {{ .page.Content }};
32-
document.getElementById('page-content').innerHTML = DOMPurify.sanitize(converter.makeHtml(content));
33-
</script>
34-
3538
{{ template "footer.html" .}}

themes/default/templates/page_research.html

Lines changed: 0 additions & 30 deletions
This file was deleted.

themes/default/templates/research.html

Lines changed: 0 additions & 25 deletions
This file was deleted.

themes/forest/templates/page_content.html

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ <h1>{{ .page.Title }}</h1>
55
{{ if .is_admin }}
66
<p class="text-left"><a href="/admin/pages/{{ .page.ID }}">Edit this page</a></p>
77
{{ end }}
8+
{{ if .has_plugin_content }}
9+
<div id="page-content">{{ .plugin_content | rawHTML }}</div>
10+
{{ else }}
811
<div id="page-content"></div>
12+
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous"></script>
13+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js" integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ==" crossorigin="anonymous"></script>
14+
<script>
15+
var converter = new showdown.Converter({tables: true});
16+
var content = {{ .page.Content }};
17+
document.getElementById('page-content').innerHTML = DOMPurify.sanitize(converter.makeHtml(content));
18+
</script>
19+
{{ end }}
920
</div></div>
1021

11-
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous"></script>
12-
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js" integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ==" crossorigin="anonymous"></script>
13-
<script>
14-
var converter = new showdown.Converter({tables: true});
15-
var content = {{ .page.Content }};
16-
document.getElementById('page-content').innerHTML = DOMPurify.sanitize(converter.makeHtml(content));
17-
</script>
18-
1922
{{ template "footer.html" .}}

themes/forest/templates/page_research.html

Lines changed: 0 additions & 24 deletions
This file was deleted.

themes/minimal/templates/page_content.html

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@ <h1>{{ .page.Title }}</h1>
55
{{ if .is_admin }}
66
<p class="text-left"><a href="/admin/pages/{{ .page.ID }}">Edit this page</a></p>
77
{{ end }}
8+
{{ if .has_plugin_content }}
9+
<div id="page-content">{{ .plugin_content | rawHTML }}</div>
10+
{{ else }}
811
<div id="page-content"></div>
12+
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous"></script>
13+
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js" integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ==" crossorigin="anonymous"></script>
14+
<script>
15+
var converter = new showdown.Converter({tables: true});
16+
var content = {{ .page.Content }};
17+
document.getElementById('page-content').innerHTML = DOMPurify.sanitize(converter.makeHtml(content));
18+
</script>
19+
{{ end }}
920
</div>
1021

11-
<script src="https://cdnjs.cloudflare.com/ajax/libs/showdown/2.1.0/showdown.min.js" integrity="sha512-LhccdVNGe2QMEfI3x4DVV3ckMRe36TfydKss6mJpdHjNFiV07dFpS2xzeZedptKZrwxfICJpez09iNioiSZ3hA==" crossorigin="anonymous"></script>
12-
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.7/purify.min.js" integrity="sha512-78KH17QLT5e55GJqP76vutp1D2iAoy06WcYBXB6iBCsmO6wWzx0Qdg8EDpm8mKXv68BcvHOyeeP4wxAL0twJGQ==" crossorigin="anonymous"></script>
13-
<script>
14-
var converter = new showdown.Converter({tables: true});
15-
var content = {{ .page.Content }};
16-
document.getElementById('page-content').innerHTML = DOMPurify.sanitize(converter.makeHtml(content));
17-
</script>
18-
1922
{{ template "footer.html" .}}

themes/minimal/templates/page_research.html

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)