diff --git a/blogroll.go b/blogroll.go
new file mode 100644
index 0000000..766b521
--- /dev/null
+++ b/blogroll.go
@@ -0,0 +1,115 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "net/http"
+ "time"
+
+ "github.com/kaorimatz/go-opml"
+ "github.com/thoas/go-funk"
+ "golang.org/x/sync/singleflight"
+)
+
+var blogrollCacheGroup singleflight.Group
+
+func serveBlogroll(w http.ResponseWriter, r *http.Request) {
+ blog := r.Context().Value(blogContextKey).(string)
+ c := appConfig.Blogs[blog].Blogroll
+ if !c.Enabled {
+ serve404(w, r)
+ return
+ }
+ outlines, err, _ := blogrollCacheGroup.Do(blog, func() (interface{}, error) {
+ return getBlogrollOutlines(c)
+ })
+ if err != nil {
+ serveError(w, r, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if appConfig.Cache != nil && appConfig.Cache.Enable {
+ setInternalCacheExpirationHeader(w, r, int(appConfig.Cache.Expiration))
+ }
+ render(w, r, templateBlogroll, &renderData{
+ BlogString: blog,
+ Data: map[string]interface{}{
+ "Title": c.Title,
+ "Description": c.Description,
+ "Outlines": outlines,
+ "Download": c.Path + ".opml",
+ },
+ })
+}
+
+func serveBlogrollExport(w http.ResponseWriter, r *http.Request) {
+ blog := r.Context().Value(blogContextKey).(string)
+ c := appConfig.Blogs[blog].Blogroll
+ if !c.Enabled {
+ serve404(w, r)
+ return
+ }
+ outlines, err, _ := blogrollCacheGroup.Do(blog, func() (interface{}, error) {
+ return getBlogrollOutlines(c)
+ })
+ if err != nil {
+ serveError(w, r, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if appConfig.Cache != nil && appConfig.Cache.Enable {
+ setInternalCacheExpirationHeader(w, r, int(appConfig.Cache.Expiration))
+ }
+ w.Header().Set(contentType, contentTypeXMLUTF8)
+ mw := minifier.Writer(contentTypeXML, w)
+ defer func() {
+ _ = mw.Close()
+ }()
+ _ = opml.Render(mw, &opml.OPML{
+ Version: "2.0",
+ DateCreated: time.Now().UTC(),
+ Outlines: outlines.([]*opml.Outline),
+ })
+}
+
+func getBlogrollOutlines(config *configBlogroll) ([]*opml.Outline, error) {
+ if config.cachedOutlines != nil && time.Since(config.lastCache).Minutes() < 60 {
+ // return cache if younger than 60 min
+ return config.cachedOutlines, nil
+ }
+ req, err := http.NewRequest(http.MethodGet, config.Opml, nil)
+ if err != nil {
+ return nil, err
+ }
+ if config.AuthHeader != "" && config.AuthValue != "" {
+ req.Header.Set(config.AuthHeader, config.AuthValue)
+ }
+ res, err := appHttpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ _, _ = io.Copy(io.Discard, res.Body)
+ res.Body.Close()
+ }()
+ if code := res.StatusCode; code < 200 || 300 <= code {
+ return nil, fmt.Errorf("opml request not successfull, status code: %d", code)
+ }
+ o, err := opml.Parse(res.Body)
+ if err != nil {
+ return nil, err
+ }
+ outlines := o.Outlines
+ if len(config.Categories) > 0 {
+ filtered := []*opml.Outline{}
+ for _, category := range config.Categories {
+ if outline, ok := funk.Find(outlines, func(outline *opml.Outline) bool {
+ return outline.Title == category || outline.Text == category
+ }).(*opml.Outline); ok && outline != nil {
+ filtered = append(filtered, outline)
+ }
+ }
+ outlines = filtered
+ }
+ config.cachedOutlines = outlines
+ config.lastCache = time.Now()
+ return outlines, nil
+}
diff --git a/cache.go b/cache.go
index f42b378..797066b 100644
--- a/cache.go
+++ b/cache.go
@@ -21,7 +21,7 @@ import (
)
const (
- cacheInternalExpirationHeader = "GoBlog-Expire"
+ cacheInternalExpirationHeader = "Goblog-Expire"
)
var (
@@ -212,6 +212,9 @@ func purgeCache() {
cacheR.Clear()
}
-func setInternalCacheExpirationHeader(w http.ResponseWriter, expiration int) {
+func setInternalCacheExpirationHeader(w http.ResponseWriter, r *http.Request, expiration int) {
+ if loggedIn, ok := r.Context().Value(loggedInKey).(bool); ok && loggedIn {
+ return
+ }
w.Header().Set(cacheInternalExpirationHeader, strconv.Itoa(expiration))
}
diff --git a/config.go b/config.go
index 26b3654..44c3f2f 100644
--- a/config.go
+++ b/config.go
@@ -4,7 +4,9 @@ import (
"errors"
"net/url"
"strings"
+ "time"
+ "github.com/kaorimatz/go-opml"
"github.com/spf13/viper"
)
@@ -63,6 +65,7 @@ type configBlog struct {
Photos *photos `mapstructure:"photos"`
Search *search `mapstructure:"search"`
BlogStats *blogStats `mapstructure:"blogStats"`
+ Blogroll *configBlogroll `mapstructure:"blogroll"`
CustomPages []*customPage `mapstructure:"custompages"`
Telegram *configTelegram `mapstructure:"telegram"`
PostAsHome bool `mapstructure:"postAsHome"`
@@ -115,6 +118,19 @@ type blogStats struct {
Description string `mapstructure:"description"`
}
+type configBlogroll struct {
+ Enabled bool `mapstructure:"enabled"`
+ Path string `mapstructure:"path"`
+ Opml string `mapstructure:"opml"`
+ AuthHeader string `mapstructure:"authHeader"`
+ AuthValue string `mapstructure:"authValue"`
+ Categories []string `mapstructure:"categories"`
+ Title string `mapstructure:"title"`
+ Description string `mapstructure:"description"`
+ cachedOutlines []*opml.Outline
+ lastCache time.Time
+}
+
type customPage struct {
Path string `mapstructure:"path"`
Template string `mapstructure:"template"`
@@ -288,6 +304,12 @@ func initConfig() error {
b.Comments = &comments{Enabled: false}
}
}
+ // Check config for each blog
+ for _, blog := range appConfig.Blogs {
+ if br := blog.Blogroll; br != nil && br.Enabled && br.Opml == "" {
+ br.Enabled = false
+ }
+ }
return nil
}
diff --git a/customPages.go b/customPages.go
index bc41ccb..64beb42 100644
--- a/customPages.go
+++ b/customPages.go
@@ -8,9 +8,9 @@ func serveCustomPage(w http.ResponseWriter, r *http.Request) {
page := r.Context().Value(customPageContextKey).(*customPage)
if appConfig.Cache != nil && appConfig.Cache.Enable && page.Cache {
if page.CacheExpiration != 0 {
- setInternalCacheExpirationHeader(w, page.CacheExpiration)
+ setInternalCacheExpirationHeader(w, r, page.CacheExpiration)
} else {
- setInternalCacheExpirationHeader(w, int(appConfig.Cache.Expiration))
+ setInternalCacheExpirationHeader(w, r, int(appConfig.Cache.Expiration))
}
}
render(w, r, page.Template, &renderData{
diff --git a/example-config.yml b/example-config.yml
index 6b9e47b..dff0363 100644
--- a/example-config.yml
+++ b/example-config.yml
@@ -175,6 +175,17 @@ blogs:
path: /statistics # Path
title: Statistics # Title
description: "Here are some statistics with the number of posts per year:" # Description
+ # Blogroll
+ blogroll:
+ enabled: true # Enable
+ path: /blogroll # Path
+ title: Blogroll # Title
+ description: "I follow these blog:" # Description
+ opml: https://example.com/blogroll.opml # Required, URL to the OPML file
+ authHeader: X-Auth # Optional, header to use for OPML authentication
+ authValue: abc # Authentication value for OPML
+ categories: # Optional, allow only these categories
+ - Blogs
# Custom pages
custompages:
- path: /blogroll # Path
diff --git a/go.mod b/go.mod
index 5f1db62..411dced 100644
--- a/go.mod
+++ b/go.mod
@@ -25,6 +25,7 @@ require (
github.com/gorilla/handlers v1.5.1
github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/joncrlsn/dque v0.0.0-20200702023911-3e80e3146ce5
+ github.com/kaorimatz/go-opml v0.0.0-20210201121027-bc8e2852d7f9
github.com/kr/text v0.2.0 // indirect
github.com/kyokomi/emoji/v2 v2.2.8
github.com/lestrrat-go/file-rotatelogs v2.4.0+incompatible
diff --git a/go.sum b/go.sum
index 335b02b..e194aa0 100644
--- a/go.sum
+++ b/go.sum
@@ -178,6 +178,8 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
+github.com/kaorimatz/go-opml v0.0.0-20210201121027-bc8e2852d7f9 h1:+9REu9CK9D1AQ6C/PXXwGRcoKdT04cuHR5JgGD4DKqc=
+github.com/kaorimatz/go-opml v0.0.0-20210201121027-bc8e2852d7f9/go.mod h1:OvY5ZBrAC9kOvM2PZs9Lw0BH+5K7tjrT6T7SFhn27OA=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/cpuid/v2 v2.0.6 h1:dQ5ueTiftKxp0gyjKSx5+8BtPWkyQbd95m8Gys/RarI=
@@ -413,6 +415,7 @@ golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20210508051633-16afe75a6701 h1:lQVgcB3+FoAXOb20Dp6zTzAIrpj1k/yOOBN7s+Zv1rA=
diff --git a/http.go b/http.go
index 139096d..5ebb74d 100644
--- a/http.go
+++ b/http.go
@@ -26,6 +26,7 @@ const (
charsetUtf8Suffix = "; charset=utf-8"
contentTypeHTML = "text/html"
+ contentTypeXML = "text/xml"
contentTypeJSON = "application/json"
contentTypeWWWForm = "application/x-www-form-urlencoded"
contentTypeMultipartForm = "multipart/form-data"
@@ -35,6 +36,7 @@ const (
contentTypeJSONFeed = "application/feed+json"
contentTypeHTMLUTF8 = contentTypeHTML + charsetUtf8Suffix
+ contentTypeXMLUTF8 = contentTypeXML + charsetUtf8Suffix
contentTypeJSONUTF8 = contentTypeJSON + charsetUtf8Suffix
contentTypeASUTF8 = contentTypeAS + charsetUtf8Suffix
@@ -542,6 +544,17 @@ func buildDynamicRouter() (*chi.Mux, error) {
commentsPath := blogPath + "/comment"
r.With(sbm, commentsMiddlewares[blog]).Mount(commentsPath, commentsRouter)
}
+
+ // Blogroll
+ if brConfig := blogConfig.Blogroll; brConfig != nil && brConfig.Enabled {
+ brPath := blogPath + brConfig.Path
+ r.Group(func(r chi.Router) {
+ r.Use(privateModeHandler...)
+ r.Use(cacheMiddleware, sbm)
+ r.Get(brPath, serveBlogroll)
+ r.Get(brPath+".opml", serveBlogrollExport)
+ })
+ }
}
// Sitemap
diff --git a/minify.go b/minify.go
index a42eb28..e5d1ccc 100644
--- a/minify.go
+++ b/minify.go
@@ -17,7 +17,7 @@ func initMinify() {
minifier = minify.New()
minifier.AddFunc(contentTypeHTML, mHtml.Minify)
minifier.AddFunc("text/css", mCss.Minify)
- minifier.AddFunc("text/xml", mXml.Minify)
+ minifier.AddFunc(contentTypeXML, mXml.Minify)
minifier.AddFunc("application/javascript", mJs.Minify)
minifier.AddFunc(contentTypeRSS, mXml.Minify)
minifier.AddFunc(contentTypeATOM, mXml.Minify)
diff --git a/render.go b/render.go
index 65a3778..84409aa 100644
--- a/render.go
+++ b/render.go
@@ -8,6 +8,7 @@ import (
"html/template"
"log"
"net/http"
+ "net/url"
"os"
"path"
"path/filepath"
@@ -39,6 +40,7 @@ const (
templateCommentsAdmin = "commentsadmin"
templateNotificationsAdmin = "notificationsadmin"
templateWebmentionAdmin = "webmentionadmin"
+ templateBlogroll = "blogroll"
)
var templates map[string]*template.Template = map[string]*template.Template{}
@@ -164,6 +166,9 @@ func initRendering() error {
})
return mentions
},
+ "urlToString": func(u url.URL) string {
+ return u.String()
+ },
}
baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt))
diff --git a/sitemap.go b/sitemap.go
index 2f44933..41552fc 100644
--- a/sitemap.go
+++ b/sitemap.go
@@ -114,6 +114,12 @@ func serveSitemap(w http.ResponseWriter, r *http.Request) {
Loc: appConfig.Server.PublicAddress + bc.getRelativePath(bc.BlogStats.Path),
})
}
+ // Blogroll
+ if bc.Blogroll != nil && bc.Blogroll.Enabled {
+ sm.Add(&sitemap.URL{
+ Loc: appConfig.Server.PublicAddress + bc.getRelativePath(bc.Blogroll.Path),
+ })
+ }
// Custom pages
for _, cp := range bc.CustomPages {
sm.Add(&sitemap.URL{
diff --git a/templates/blogroll.gohtml b/templates/blogroll.gohtml
new file mode 100644
index 0000000..6da7a89
--- /dev/null
+++ b/templates/blogroll.gohtml
@@ -0,0 +1,24 @@
+{{ define "title" }}
+
{{ .Data.Title }} - {{ .Blog.Title }}
+{{ end }}
+
+{{ define "main" }}
+
+ {{ with .Data.Title }}{{ . }}
{{ end }}
+ {{ with .Data.Description }}{{ md . }}{{ end }}
+ {{ string .Blog.Lang "download" }}
+ {{ $lang := .Blog.Lang }}
+ {{ range .Data.Outlines }}
+ {{ with .Title }}{{ . }}{{ else }}{{ with .Text }}{{ . }}{{ end }}{{ end }}
+
+ {{ end }}
+
+{{ end }}
+
+{{ define "blogroll" }}
+ {{ template "base" . }}
+{{ end }}
\ No newline at end of file
diff --git a/templates/custom/blogroll.gohtml b/templates/custom/blogroll.gohtml
deleted file mode 100644
index 28f0980..0000000
--- a/templates/custom/blogroll.gohtml
+++ /dev/null
@@ -1,23 +0,0 @@
-{{ define "title" }}
- {{ .Data.Title }} - {{ .Blog.Title }}
-{{ end }}
-
-{{ define "main" }}
-
- {{ .Data.Title }}
- {{ md .Data.Description }}
- {{ $opmlJson := index ( jsonFile "data/opml.json" ) "outline" }}
- {{ range $opmlJson }}
- {{ md (printf "%s %s" "##" ._text) }}
-
- {{ end }}
-
-{{ end }}
-
-{{ define "blogroll" }}
- {{ template "base" . }}
-{{ end }}
\ No newline at end of file
diff --git a/templates/strings/de.yaml b/templates/strings/de.yaml
index bb4a967..bd2cd8e 100644
--- a/templates/strings/de.yaml
+++ b/templates/strings/de.yaml
@@ -6,6 +6,7 @@ confirmdelete: "Löschen bestätigen"
create: "Erstellen"
delete: "Löschen"
docomment: "Kommentieren"
+download: "Herunterladen"
drafts: "Entwürfe"
editor: "Editor"
interactions: "Interaktionen & Kommentare"
diff --git a/templates/strings/default.yaml b/templates/strings/default.yaml
index 675e8dc..8edc2be 100644
--- a/templates/strings/default.yaml
+++ b/templates/strings/default.yaml
@@ -11,8 +11,10 @@ confirmdelete: "Confirm deletion"
create: "Create"
delete: "Delete"
docomment: "Comment"
+download: "Download"
drafts: "Drafts"
editor: "Editor"
+feed: "Feed"
indieauth: "IndieAuth"
interactions: "Interactions & Comments"
interactionslabel: "Have you published a response to this? Paste the URL here."