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."