mirror of https://github.com/jlelse/GoBlog
Bug fixes, refactoring and other improvements
This commit is contained in:
parent
20df90bf4e
commit
67f2b1fbdb
|
@ -4,7 +4,7 @@ RUN apk add --no-cache git gcc musl-dev
|
|||
RUN apk add --no-cache --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main sqlite-dev
|
||||
ADD *.go go.mod go.sum /app/
|
||||
ENV GOFLAGS="-tags=linux,libsqlite3,sqlite_fts5"
|
||||
RUN go test -cover
|
||||
RUN go test -cover ./...
|
||||
RUN go build -ldflags '-w -s' -o GoBlog
|
||||
|
||||
FROM alpine:3.13
|
||||
|
|
|
@ -15,6 +15,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-fed/httpsig"
|
||||
"github.com/spf13/cast"
|
||||
|
@ -90,7 +91,7 @@ func (a *goBlog) apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
|
|||
"links": []map[string]string{
|
||||
{
|
||||
"rel": "self",
|
||||
"type": contentTypeAS,
|
||||
"type": contenttype.AS,
|
||||
"href": a.apIri(blog),
|
||||
},
|
||||
{
|
||||
|
@ -100,8 +101,8 @@ func (a *goBlog) apHandleWebfinger(w http.ResponseWriter, r *http.Request) {
|
|||
},
|
||||
},
|
||||
})
|
||||
w.Header().Set(contentType, "application/jrd+json"+charsetUtf8Suffix)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
w.Header().Set(contentType, "application/jrd+json"+contenttype.CharsetUtf8Suffix)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
}
|
||||
|
||||
func (a *goBlog) apHandleInbox(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -244,7 +245,7 @@ func apVerifySignature(r *http.Request) (*asPerson, string, int, error) {
|
|||
}
|
||||
|
||||
func handleWellKnownHostMeta(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set(contentType, "application/xrd+xml"+charsetUtf8Suffix)
|
||||
w.Header().Set(contentType, "application/xrd+xml"+contenttype.CharsetUtf8Suffix)
|
||||
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" type="application/xrd+xml" template="https://` + r.Host + `/.well-known/webfinger?resource={uri}"/></XRD>`))
|
||||
}
|
||||
|
||||
|
@ -253,7 +254,7 @@ func apGetRemoteActor(iri string) (*asPerson, int, error) {
|
|||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.Header.Set("Accept", contentTypeAS)
|
||||
req.Header.Set("Accept", contenttype.AS)
|
||||
req.Header.Set(userAgent, appUserAgent)
|
||||
resp, err := appHttpClient.Do(req)
|
||||
if err != nil {
|
||||
|
@ -308,7 +309,7 @@ func (db *database) apRemoveInbox(inbox string) error {
|
|||
func (a *goBlog) apPost(p *post) {
|
||||
n := a.toASNote(p)
|
||||
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
|
||||
"@context": asContext,
|
||||
"@context": []string{asContext},
|
||||
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
|
||||
"id": a.fullPostURL(p),
|
||||
"published": n.Published,
|
||||
|
@ -324,7 +325,7 @@ func (a *goBlog) apPost(p *post) {
|
|||
|
||||
func (a *goBlog) apUpdate(p *post) {
|
||||
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
|
||||
"@context": asContext,
|
||||
"@context": []string{asContext},
|
||||
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
|
||||
"id": a.fullPostURL(p),
|
||||
"published": time.Now().Format("2006-01-02T15:04:05-07:00"),
|
||||
|
@ -335,7 +336,7 @@ func (a *goBlog) apUpdate(p *post) {
|
|||
|
||||
func (a *goBlog) apAnnounce(p *post) {
|
||||
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
|
||||
"@context": asContext,
|
||||
"@context": []string{asContext},
|
||||
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
|
||||
"id": a.fullPostURL(p) + "#announce",
|
||||
"published": a.toASNote(p).Published,
|
||||
|
@ -346,7 +347,7 @@ func (a *goBlog) apAnnounce(p *post) {
|
|||
|
||||
func (a *goBlog) apDelete(p *post) {
|
||||
a.apSendToAllFollowers(p.Blog, map[string]interface{}{
|
||||
"@context": asContext,
|
||||
"@context": []string{asContext},
|
||||
"actor": a.apIri(a.cfg.Blogs[p.Blog]),
|
||||
"id": a.fullPostURL(p) + "#delete",
|
||||
"type": "Delete",
|
||||
|
@ -383,7 +384,7 @@ func (a *goBlog) apAccept(blogName string, blog *configBlog, follow map[string]i
|
|||
// remove @context from the inner activity
|
||||
delete(follow, "@context")
|
||||
accept := map[string]interface{}{
|
||||
"@context": asContext,
|
||||
"@context": []string{asContext},
|
||||
"to": follow["actor"],
|
||||
"actor": a.apIri(blog),
|
||||
"object": follow,
|
||||
|
|
|
@ -11,6 +11,8 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
type apRequest struct {
|
||||
|
@ -101,8 +103,8 @@ func (a *goBlog) apSendSigned(blogIri, to string, activity []byte) error {
|
|||
r.Header.Set("Accept-Charset", "utf-8")
|
||||
r.Header.Set("Date", time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05")+" GMT")
|
||||
r.Header.Set(userAgent, appUserAgent)
|
||||
r.Header.Set("Accept", contentTypeASUTF8)
|
||||
r.Header.Set(contentType, contentTypeASUTF8)
|
||||
r.Header.Set("Accept", contenttype.ASUTF8)
|
||||
r.Header.Set(contentType, contenttype.ASUTF8)
|
||||
r.Header.Set("Host", iri.Host)
|
||||
// Sign request
|
||||
a.apPostSignMutex.Lock()
|
||||
|
|
|
@ -8,25 +8,26 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/elnormous/contenttype"
|
||||
ct "github.com/elnormous/contenttype"
|
||||
)
|
||||
|
||||
var asContext = []string{"https://www.w3.org/ns/activitystreams"}
|
||||
|
||||
var asCheckMediaTypes = []contenttype.MediaType{
|
||||
contenttype.NewMediaType(contentTypeHTML),
|
||||
contenttype.NewMediaType(contentTypeAS),
|
||||
contenttype.NewMediaType("application/ld+json"),
|
||||
}
|
||||
const asContext = "https://www.w3.org/ns/activitystreams"
|
||||
|
||||
const asRequestKey requestContextKey = "asRequest"
|
||||
|
||||
var asCheckMediaTypes = []ct.MediaType{
|
||||
ct.NewMediaType(contenttype.HTML),
|
||||
ct.NewMediaType(contenttype.AS),
|
||||
ct.NewMediaType(contenttype.LDJSON),
|
||||
}
|
||||
|
||||
func (a *goBlog) checkActivityStreamsRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
||||
if ap := a.cfg.ActivityPub; ap != nil && ap.Enabled {
|
||||
// Check if accepted media type is not HTML
|
||||
if mt, _, err := contenttype.GetAcceptableMediaType(r, asCheckMediaTypes); err == nil && mt.String() != asCheckMediaTypes[0].String() {
|
||||
if mt, _, err := ct.GetAcceptableMediaType(r, asCheckMediaTypes); err == nil && mt.String() != asCheckMediaTypes[0].String() {
|
||||
next.ServeHTTP(rw, r.WithContext(context.WithValue(r.Context(), asRequestKey, true)))
|
||||
return
|
||||
}
|
||||
|
@ -89,29 +90,29 @@ type asEndpoints struct {
|
|||
|
||||
func (a *goBlog) serveActivityStreamsPost(p *post, w http.ResponseWriter) {
|
||||
b, _ := json.Marshal(a.toASNote(p))
|
||||
w.Header().Set(contentType, contentTypeASUTF8)
|
||||
_, _ = writeMinified(w, contentTypeAS, b)
|
||||
w.Header().Set(contentType, contenttype.ASUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.AS, b)
|
||||
}
|
||||
|
||||
func (a *goBlog) toASNote(p *post) *asNote {
|
||||
// Create a Note object
|
||||
as := &asNote{
|
||||
Context: asContext,
|
||||
Context: []string{asContext},
|
||||
To: []string{"https://www.w3.org/ns/activitystreams#Public"},
|
||||
MediaType: contentTypeHTML,
|
||||
MediaType: contenttype.HTML,
|
||||
ID: a.fullPostURL(p),
|
||||
URL: a.fullPostURL(p),
|
||||
AttributedTo: a.apIri(a.cfg.Blogs[p.Blog]),
|
||||
}
|
||||
// Name and Type
|
||||
if title := p.title(); title != "" {
|
||||
if title := p.Title(); title != "" {
|
||||
as.Name = title
|
||||
as.Type = "Article"
|
||||
} else {
|
||||
as.Type = "Note"
|
||||
}
|
||||
// Content
|
||||
as.Content = string(a.absoluteHTML(p))
|
||||
as.Content = string(a.absolutePostHTML(p))
|
||||
// Attachments
|
||||
if images := p.Parameters[a.cfg.Micropub.PhotoParam]; len(images) > 0 {
|
||||
for _, image := range images {
|
||||
|
@ -158,7 +159,7 @@ func (a *goBlog) serveActivityStreams(blog string, w http.ResponseWriter, r *htt
|
|||
return
|
||||
}
|
||||
asBlog := &asPerson{
|
||||
Context: asContext,
|
||||
Context: []string{asContext},
|
||||
Type: "Person",
|
||||
ID: a.apIri(b),
|
||||
URL: a.apIri(b),
|
||||
|
@ -184,6 +185,6 @@ func (a *goBlog) serveActivityStreams(blog string, w http.ResponseWriter, r *htt
|
|||
}
|
||||
}
|
||||
jb, _ := json.Marshal(asBlog)
|
||||
w.Header().Set(contentType, contentTypeASUTF8)
|
||||
_, _ = writeMinified(w, contentTypeAS, jb)
|
||||
w.Header().Set(contentType, contenttype.ASUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.AS, jb)
|
||||
}
|
||||
|
|
9
app.go
9
app.go
|
@ -6,6 +6,7 @@ import (
|
|||
"net/http"
|
||||
"sync"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/minify"
|
||||
shutdowner "git.jlel.se/jlelse/go-shutdowner"
|
||||
ts "git.jlel.se/jlelse/template-strings"
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
@ -27,6 +28,8 @@ type goBlog struct {
|
|||
assetFiles map[string]*assetFile
|
||||
// Blogroll
|
||||
blogrollCacheGroup singleflight.Group
|
||||
// Blogstats
|
||||
blogStatsCacheGroup singleflight.Group
|
||||
// Cache
|
||||
cache *cache
|
||||
// Config
|
||||
|
@ -37,6 +40,9 @@ type goBlog struct {
|
|||
pPostHooks []postHookFunc
|
||||
pUpdateHooks []postHookFunc
|
||||
pDeleteHooks []postHookFunc
|
||||
hourlyHooks []hourlyHookFunc
|
||||
// HTTP
|
||||
cspDomains string
|
||||
// HTTP Routers
|
||||
d *dynamicHandler
|
||||
privateMode bool
|
||||
|
@ -53,6 +59,7 @@ type goBlog struct {
|
|||
setBlogMiddlewares map[string]func(http.Handler) http.Handler
|
||||
sectionMiddlewares map[string]func(http.Handler) http.Handler
|
||||
taxonomyMiddlewares map[string]func(http.Handler) http.Handler
|
||||
taxValueMiddlewares map[string]func(http.Handler) http.Handler
|
||||
photosMiddlewares map[string]func(http.Handler) http.Handler
|
||||
searchMiddlewares map[string]func(http.Handler) http.Handler
|
||||
customPagesMiddlewares map[string]func(http.Handler) http.Handler
|
||||
|
@ -61,6 +68,8 @@ type goBlog struct {
|
|||
logf *rotatelogs.RotateLogs
|
||||
// Markdown
|
||||
md, absoluteMd goldmark.Markdown
|
||||
// Minify
|
||||
min minify.Minifier
|
||||
// Regex Redirects
|
||||
regexRedirects []*regexRedirect
|
||||
// Rendering
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
|
@ -102,7 +103,7 @@ func (a *goBlog) checkLogin(w http.ResponseWriter, r *http.Request) bool {
|
|||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
if r.Header.Get(contentType) != contentTypeWWWForm {
|
||||
if r.Header.Get(contentType) != contenttype.WWWForm {
|
||||
return false
|
||||
}
|
||||
if r.FormValue("loginaction") != "login" {
|
||||
|
|
|
@ -10,6 +10,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/kaorimatz/go-opml"
|
||||
servertiming "github.com/mitchellh/go-server-timing"
|
||||
"github.com/thoas/go-funk"
|
||||
|
@ -55,14 +56,14 @@ func (a *goBlog) serveBlogrollExport(w http.ResponseWriter, r *http.Request) {
|
|||
if a.cfg.Cache != nil && a.cfg.Cache.Enable {
|
||||
setInternalCacheExpirationHeader(w, r, int(a.cfg.Cache.Expiration))
|
||||
}
|
||||
w.Header().Set(contentType, contentTypeXMLUTF8)
|
||||
w.Header().Set(contentType, contenttype.XMLUTF8)
|
||||
var opmlBytes bytes.Buffer
|
||||
_ = opml.Render(&opmlBytes, &opml.OPML{
|
||||
Version: "2.0",
|
||||
DateCreated: time.Now().UTC(),
|
||||
Outlines: outlines.([]*opml.Outline),
|
||||
})
|
||||
_, _ = writeMinified(w, contentTypeXML, opmlBytes.Bytes())
|
||||
_, _ = a.min.Write(w, contenttype.XML, opmlBytes.Bytes())
|
||||
}
|
||||
|
||||
func (a *goBlog) getBlogrollOutlines(blog string) ([]*opml.Outline, error) {
|
||||
|
|
|
@ -5,8 +5,6 @@ import (
|
|||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
func (a *goBlog) initBlogStats() {
|
||||
|
@ -31,11 +29,9 @@ func (a *goBlog) serveBlogStats(w http.ResponseWriter, r *http.Request) {
|
|||
})
|
||||
}
|
||||
|
||||
var blogStatsCacheGroup singleflight.Group
|
||||
|
||||
func (a *goBlog) serveBlogStatsTable(w http.ResponseWriter, r *http.Request) {
|
||||
blog := r.Context().Value(blogContextKey).(string)
|
||||
data, err, _ := blogStatsCacheGroup.Do(blog, func() (interface{}, error) {
|
||||
data, err, _ := a.blogStatsCacheGroup.Do(blog, func() (interface{}, error) {
|
||||
return a.db.getBlogStats(blog)
|
||||
})
|
||||
if err != nil {
|
||||
|
|
|
@ -7,6 +7,7 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/dchest/captcha"
|
||||
)
|
||||
|
||||
|
@ -53,7 +54,7 @@ func (a *goBlog) checkCaptcha(w http.ResponseWriter, r *http.Request) bool {
|
|||
if r.Method != http.MethodPost {
|
||||
return false
|
||||
}
|
||||
if r.Header.Get(contentType) != contentTypeWWWForm {
|
||||
if r.Header.Get(contentType) != contenttype.WWWForm {
|
||||
return false
|
||||
}
|
||||
if r.FormValue("captchaaction") != "captcha" {
|
||||
|
|
2
check.go
2
check.go
|
@ -90,7 +90,7 @@ func (a *goBlog) getExternalLinks(posts []*post, linkChan chan<- stringPair) err
|
|||
wg.Add(1)
|
||||
go func(p *post) {
|
||||
defer wg.Done()
|
||||
links, _ := allLinksFromHTMLString(string(a.absoluteHTML(p)), a.fullPostURL(p))
|
||||
links, _ := allLinksFromHTMLString(string(a.absolutePostHTML(p)), a.fullPostURL(p))
|
||||
for _, link := range links {
|
||||
linkChan <- stringPair{a.fullPostURL(p), link}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ import (
|
|||
"errors"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
sqlite "github.com/mattn/go-sqlite3"
|
||||
"github.com/schollz/sqlite3dump"
|
||||
|
@ -19,6 +20,7 @@ type database struct {
|
|||
stmts map[string]*sql.Stmt
|
||||
g singleflight.Group
|
||||
pc singleflight.Group
|
||||
pcm sync.Mutex
|
||||
}
|
||||
|
||||
func (a *goBlog) initDatabase() (err error) {
|
||||
|
@ -38,7 +40,7 @@ func (a *goBlog) initDatabase() (err error) {
|
|||
}
|
||||
})
|
||||
if a.cfg.Db.DumpFile != "" {
|
||||
hourlyHooks = append(hourlyHooks, func() {
|
||||
a.hourlyHooks = append(a.hourlyHooks, func() {
|
||||
db.dump(a.cfg.Db.DumpFile)
|
||||
})
|
||||
db.dump(a.cfg.Db.DumpFile)
|
||||
|
|
|
@ -7,6 +7,8 @@ import (
|
|||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
const editorPath = "/editor"
|
||||
|
@ -71,7 +73,7 @@ func (a *goBlog) serveEditorPost(w http.ResponseWriter, r *http.Request) {
|
|||
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
req.Header.Set(contentType, contentTypeJSON)
|
||||
req.Header.Set(contentType, contenttype.JSON)
|
||||
a.editorMicropubPost(w, req, false)
|
||||
case "upload":
|
||||
a.editorMicropubPost(w, r, true)
|
||||
|
|
|
@ -4,7 +4,8 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/elnormous/contenttype"
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
ct "github.com/elnormous/contenttype"
|
||||
)
|
||||
|
||||
type errorData struct {
|
||||
|
@ -20,12 +21,12 @@ func (a *goBlog) serveNotAllowed(w http.ResponseWriter, r *http.Request) {
|
|||
a.serveError(w, r, "", http.StatusMethodNotAllowed)
|
||||
}
|
||||
|
||||
var errorCheckMediaTypes = []contenttype.MediaType{
|
||||
contenttype.NewMediaType(contentTypeHTML),
|
||||
var errorCheckMediaTypes = []ct.MediaType{
|
||||
ct.NewMediaType(contenttype.HTML),
|
||||
}
|
||||
|
||||
func (a *goBlog) serveError(w http.ResponseWriter, r *http.Request, message string, status int) {
|
||||
if mt, _, err := contenttype.GetAcceptableMediaType(r, errorCheckMediaTypes); err != nil || mt.String() != errorCheckMediaTypes[0].String() {
|
||||
if mt, _, err := ct.GetAcceptableMediaType(r, errorCheckMediaTypes); err != nil || mt.String() != errorCheckMediaTypes[0].String() {
|
||||
// Request doesn't accept HTML
|
||||
http.Error(w, message, status)
|
||||
return
|
||||
|
|
17
feeds.go
17
feeds.go
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/araddon/dateparse"
|
||||
"github.com/gorilla/feeds"
|
||||
)
|
||||
|
@ -55,11 +56,11 @@ func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r
|
|||
}
|
||||
}
|
||||
feed.Add(&feeds.Item{
|
||||
Title: p.title(),
|
||||
Title: p.Title(),
|
||||
Link: &feeds.Link{Href: a.fullPostURL(p)},
|
||||
Description: a.summary(p),
|
||||
Description: a.postSummary(p),
|
||||
Id: p.Path,
|
||||
Content: string(a.absoluteHTML(p)),
|
||||
Content: string(a.absolutePostHTML(p)),
|
||||
Created: created,
|
||||
Updated: updated,
|
||||
Enclosure: enc,
|
||||
|
@ -69,13 +70,13 @@ func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r
|
|||
var feedString, feedMediaType string
|
||||
switch f {
|
||||
case rssFeed:
|
||||
feedMediaType = contentTypeRSS
|
||||
feedMediaType = contenttype.RSS
|
||||
feedString, err = feed.ToRss()
|
||||
case atomFeed:
|
||||
feedMediaType = contentTypeATOM
|
||||
feedMediaType = contenttype.ATOM
|
||||
feedString, err = feed.ToAtom()
|
||||
case jsonFeed:
|
||||
feedMediaType = contentTypeJSONFeed
|
||||
feedMediaType = contenttype.JSONFeed
|
||||
feedString, err = feed.ToJSON()
|
||||
default:
|
||||
return
|
||||
|
@ -85,6 +86,6 @@ func (a *goBlog) generateFeed(blog string, f feedType, w http.ResponseWriter, r
|
|||
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set(contentType, feedMediaType+charsetUtf8Suffix)
|
||||
_, _ = writeMinified(w, feedMediaType, []byte(feedString))
|
||||
w.Header().Set(contentType, feedMediaType+contenttype.CharsetUtf8Suffix)
|
||||
_, _ = a.min.Write(w, feedMediaType, []byte(feedString))
|
||||
}
|
||||
|
|
|
@ -7,12 +7,16 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
gogeouri "git.jlel.se/jlelse/go-geouri"
|
||||
geojson "github.com/paulmach/go.geojson"
|
||||
"github.com/thoas/go-funk"
|
||||
)
|
||||
|
||||
func (db *database) geoTitle(lat, lon float64, lang string) string {
|
||||
ba, err := db.photonReverse(lat, lon, lang)
|
||||
func (db *database) geoTitle(g *gogeouri.Geo, lang string) string {
|
||||
if name, ok := g.Parameters["name"]; ok && len(name) > 0 && name[0] != "" {
|
||||
return name[0]
|
||||
}
|
||||
ba, err := db.photonReverse(g.Latitude, g.Longitude, lang)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
@ -65,3 +69,7 @@ func (db *database) photonReverse(lat, lon float64, lang string) ([]byte, error)
|
|||
_ = db.cachePersistently(cacheKey, ba)
|
||||
return ba, nil
|
||||
}
|
||||
|
||||
func geoOSMLink(g *gogeouri.Geo) string {
|
||||
return fmt.Sprintf("https://www.openstreetmap.org/?mlat=%v&mlon=%v", g.Latitude, g.Longitude)
|
||||
}
|
10
hooks.go
10
hooks.go
|
@ -78,7 +78,7 @@ func (cfg *configHooks) executeTemplateCommand(hookType string, tmpl string, dat
|
|||
executeHookCommand(hookType, cfg.Shell, cmd)
|
||||
}
|
||||
|
||||
var hourlyHooks = []func(){}
|
||||
type hourlyHookFunc func()
|
||||
|
||||
func (a *goBlog) startHourlyHooks() {
|
||||
cfg := a.cfg.Hooks
|
||||
|
@ -88,14 +88,14 @@ func (a *goBlog) startHourlyHooks() {
|
|||
f := func() {
|
||||
executeHookCommand("hourly", cfg.Shell, c)
|
||||
}
|
||||
hourlyHooks = append(hourlyHooks, f)
|
||||
a.hourlyHooks = append(a.hourlyHooks, f)
|
||||
}
|
||||
// When there are hooks, start ticker
|
||||
if len(hourlyHooks) > 0 {
|
||||
if len(a.hourlyHooks) > 0 {
|
||||
// Wait for next full hour
|
||||
tr := time.AfterFunc(time.Until(time.Now().Truncate(time.Hour).Add(time.Hour)), func() {
|
||||
// Execute once
|
||||
for _, f := range hourlyHooks {
|
||||
for _, f := range a.hourlyHooks {
|
||||
go f()
|
||||
}
|
||||
// Start ticker and execute regularly
|
||||
|
@ -105,7 +105,7 @@ func (a *goBlog) startHourlyHooks() {
|
|||
log.Println("Stopped hourly hooks")
|
||||
})
|
||||
for range ticker.C {
|
||||
for _, f := range hourlyHooks {
|
||||
for _, f := range a.hourlyHooks {
|
||||
go f()
|
||||
}
|
||||
}
|
||||
|
|
46
http.go
46
http.go
|
@ -22,24 +22,6 @@ import (
|
|||
|
||||
const (
|
||||
contentType = "Content-Type"
|
||||
|
||||
charsetUtf8Suffix = "; charset=utf-8"
|
||||
|
||||
contentTypeHTML = "text/html"
|
||||
contentTypeXML = "text/xml"
|
||||
contentTypeJSON = "application/json"
|
||||
contentTypeWWWForm = "application/x-www-form-urlencoded"
|
||||
contentTypeMultipartForm = "multipart/form-data"
|
||||
contentTypeAS = "application/activity+json"
|
||||
contentTypeRSS = "application/rss+xml"
|
||||
contentTypeATOM = "application/atom+xml"
|
||||
contentTypeJSONFeed = "application/feed+json"
|
||||
|
||||
contentTypeHTMLUTF8 = contentTypeHTML + charsetUtf8Suffix
|
||||
contentTypeXMLUTF8 = contentTypeXML + charsetUtf8Suffix
|
||||
contentTypeJSONUTF8 = contentTypeJSON + charsetUtf8Suffix
|
||||
contentTypeASUTF8 = contentTypeAS + charsetUtf8Suffix
|
||||
|
||||
userAgent = "User-Agent"
|
||||
appUserAgent = "GoBlog"
|
||||
)
|
||||
|
@ -241,6 +223,7 @@ func (a *goBlog) buildStaticHandlersRouters() error {
|
|||
a.setBlogMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
a.sectionMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
a.taxonomyMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
a.taxValueMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
a.photosMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
a.searchMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
a.customPagesMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
|
@ -293,10 +276,6 @@ func (a *goBlog) buildStaticHandlersRouters() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
taxValueMiddlewares = map[string]func(http.Handler) http.Handler{}
|
||||
)
|
||||
|
||||
func (a *goBlog) buildDynamicRouter() (*chi.Mux, error) {
|
||||
r := chi.NewRouter()
|
||||
|
||||
|
@ -436,14 +415,14 @@ func (a *goBlog) buildDynamicRouter() (*chi.Mux, error) {
|
|||
for _, tv := range taxValues {
|
||||
r.Group(func(r chi.Router) {
|
||||
vPath := taxPath + "/" + urlize(tv)
|
||||
if _, ok := taxValueMiddlewares[vPath]; !ok {
|
||||
taxValueMiddlewares[vPath] = middleware.WithValue(indexConfigKey, &indexConfig{
|
||||
if _, ok := a.taxValueMiddlewares[vPath]; !ok {
|
||||
a.taxValueMiddlewares[vPath] = middleware.WithValue(indexConfigKey, &indexConfig{
|
||||
path: vPath,
|
||||
tax: taxonomy,
|
||||
taxValue: tv,
|
||||
})
|
||||
}
|
||||
r.Use(taxValueMiddlewares[vPath])
|
||||
r.Use(a.taxValueMiddlewares[vPath])
|
||||
r.Get(vPath, a.serveIndex)
|
||||
r.Get(vPath+feedPath, a.serveIndex)
|
||||
r.Get(vPath+paginationPath, a.serveIndex)
|
||||
|
@ -512,10 +491,9 @@ func (a *goBlog) buildDynamicRouter() (*chi.Mux, error) {
|
|||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.privateModeHandler...)
|
||||
r.Use(sbm)
|
||||
blogBasePath := blogConfig.getRelativePath("")
|
||||
r.With(a.checkActivityStreamsRequest, a.cache.cacheMiddleware).Get(blogBasePath, a.serveHome)
|
||||
r.With(a.cache.cacheMiddleware).Get(blogBasePath+feedPath, a.serveHome)
|
||||
r.With(a.cache.cacheMiddleware).Get(blogBasePath+paginationPath, a.serveHome)
|
||||
r.With(a.checkActivityStreamsRequest, a.cache.cacheMiddleware).Get(blogConfig.getRelativePath(""), a.serveHome)
|
||||
r.With(a.cache.cacheMiddleware).Get(blogConfig.getRelativePath("")+feedPath, a.serveHome)
|
||||
r.With(a.cache.cacheMiddleware).Get(blogConfig.getRelativePath(paginationPath), a.serveHome)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -579,17 +557,15 @@ func (a *goBlog) buildDynamicRouter() (*chi.Mux, error) {
|
|||
const blogContextKey requestContextKey = "blog"
|
||||
const pathContextKey requestContextKey = "httpPath"
|
||||
|
||||
var cspDomains = ""
|
||||
|
||||
func (a *goBlog) refreshCSPDomains() {
|
||||
cspDomains = ""
|
||||
a.cspDomains = ""
|
||||
if mp := a.cfg.Micropub.MediaStorage; mp != nil && mp.MediaURL != "" {
|
||||
if u, err := url.Parse(mp.MediaURL); err == nil {
|
||||
cspDomains += " " + u.Hostname()
|
||||
a.cspDomains += " " + u.Hostname()
|
||||
}
|
||||
}
|
||||
if len(a.cfg.Server.CSPDomains) > 0 {
|
||||
cspDomains += " " + strings.Join(a.cfg.Server.CSPDomains, " ")
|
||||
a.cspDomains += " " + strings.Join(a.cfg.Server.CSPDomains, " ")
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -601,7 +577,7 @@ func (a *goBlog) securityHeaders(next http.Handler) http.Handler {
|
|||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||||
w.Header().Set("X-Xss-Protection", "1; mode=block")
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'"+cspDomains)
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'self'"+a.cspDomains)
|
||||
if a.cfg.Server.Tor && a.torAddress != "" {
|
||||
w.Header().Set("Onion-Location", fmt.Sprintf("http://%v%v", a.torAddress, r.RequestURI))
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
|
@ -137,8 +138,8 @@ func (a *goBlog) indieAuthVerification(w http.ResponseWriter, r *http.Request) {
|
|||
b, _ := json.Marshal(tokenResponse{
|
||||
Me: a.cfg.Server.PublicAddress,
|
||||
})
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
}
|
||||
|
||||
func (a *goBlog) indieAuthToken(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -155,8 +156,8 @@ func (a *goBlog) indieAuthToken(w http.ResponseWriter, r *http.Request) {
|
|||
ClientID: data.ClientID,
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
return
|
||||
} else if r.Method == http.MethodPost {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
|
@ -208,8 +209,8 @@ func (a *goBlog) indieAuthToken(w http.ResponseWriter, r *http.Request) {
|
|||
Me: a.cfg.Server.PublicAddress,
|
||||
}
|
||||
b, _ := json.Marshal(res)
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
return
|
||||
}
|
||||
a.serveError(w, r, "", http.StatusBadRequest)
|
||||
|
|
5
main.go
5
main.go
|
@ -10,13 +10,12 @@ import (
|
|||
"github.com/pquerna/otp/totp"
|
||||
)
|
||||
|
||||
var cpuprofile = flag.String("cpuprofile", "", "write cpu profile to `file`")
|
||||
var memprofile = flag.String("memprofile", "", "write memory profile to `file`")
|
||||
|
||||
func main() {
|
||||
var err error
|
||||
|
||||
// Init CPU and memory profiling
|
||||
cpuprofile := flag.String("cpuprofile", "", "write cpu profile to `file`")
|
||||
memprofile := flag.String("memprofile", "", "write memory profile to `file`")
|
||||
flag.Parse()
|
||||
if *cpuprofile != "" {
|
||||
f, err := os.Create(*cpuprofile)
|
||||
|
|
14
markdown.go
14
markdown.go
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"strings"
|
||||
|
||||
marktag "git.jlel.se/jlelse/goldmark-mark"
|
||||
|
@ -54,6 +55,19 @@ func (a *goBlog) renderMarkdown(source string, absoluteLinks bool) (rendered []b
|
|||
return buffer.Bytes(), err
|
||||
}
|
||||
|
||||
func (a *goBlog) renderMarkdownAsHTML(source string, absoluteLinks bool) (rendered template.HTML, err error) {
|
||||
b, err := a.renderMarkdown(source, absoluteLinks)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return template.HTML(b), nil
|
||||
}
|
||||
|
||||
func (a *goBlog) safeRenderMarkdownAsHTML(source string) template.HTML {
|
||||
h, _ := a.renderMarkdownAsHTML(source, false)
|
||||
return h
|
||||
}
|
||||
|
||||
func (a *goBlog) renderText(s string) string {
|
||||
h, err := a.renderMarkdown(s, false)
|
||||
if err != nil {
|
||||
|
|
|
@ -4,6 +4,8 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_markdown(t *testing.T) {
|
||||
|
@ -65,6 +67,11 @@ func Test_markdown(t *testing.T) {
|
|||
if renderedText != "This is text" {
|
||||
t.Errorf("Wrong result, got \"%v\"", renderedText)
|
||||
}
|
||||
|
||||
// Template func
|
||||
|
||||
renderedText = string(app.safeRenderMarkdownAsHTML("[Relative](/relative)"))
|
||||
assert.Contains(t, renderedText, `href="/relative"`)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
const defaultCompressionWidth = 2000
|
||||
|
@ -34,7 +36,7 @@ func (a *goBlog) tinify(url string, config *configMicropubMedia) (location strin
|
|||
return "", err
|
||||
}
|
||||
req.SetBasicAuth("api", config.TinifyKey)
|
||||
req.Header.Set(contentType, contentTypeJSON)
|
||||
req.Header.Set(contentType, contenttype.JSON)
|
||||
resp, err := appHttpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
@ -61,7 +63,7 @@ func (a *goBlog) tinify(url string, config *configMicropubMedia) (location strin
|
|||
return "", err
|
||||
}
|
||||
downloadReq.SetBasicAuth("api", config.TinifyKey)
|
||||
downloadReq.Header.Set(contentType, contentTypeJSON)
|
||||
downloadReq.Header.Set(contentType, contenttype.JSON)
|
||||
downloadResp, err := appHttpClient.Do(downloadReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
19
micropub.go
19
micropub.go
|
@ -12,6 +12,7 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/spf13/cast"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
@ -25,12 +26,12 @@ type micropubConfig struct {
|
|||
func (a *goBlog) serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Query().Get("q") {
|
||||
case "config":
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
b, _ := json.Marshal(µpubConfig{
|
||||
MediaEndpoint: a.getFullAddress(micropubPath + micropubMediaSubPath),
|
||||
})
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
case "source":
|
||||
var mf interface{}
|
||||
if urlString := r.URL.Query().Get("url"); urlString != "" {
|
||||
|
@ -62,10 +63,10 @@ func (a *goBlog) serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
mf = list
|
||||
}
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
b, _ := json.Marshal(mf)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
case "category":
|
||||
allCategories := []string{}
|
||||
for blog := range a.cfg.Blogs {
|
||||
|
@ -76,12 +77,12 @@ func (a *goBlog) serveMicropubQuery(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
allCategories = append(allCategories, values...)
|
||||
}
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
b, _ := json.Marshal(map[string]interface{}{
|
||||
"categories": allCategories,
|
||||
})
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
default:
|
||||
a.serve404(w, r)
|
||||
}
|
||||
|
@ -120,9 +121,9 @@ func (a *goBlog) toMfItem(p *post) *microformatItem {
|
|||
func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) {
|
||||
defer r.Body.Close()
|
||||
var p *post
|
||||
if ct := r.Header.Get(contentType); strings.Contains(ct, contentTypeWWWForm) || strings.Contains(ct, contentTypeMultipartForm) {
|
||||
if ct := r.Header.Get(contentType); strings.Contains(ct, contenttype.WWWForm) || strings.Contains(ct, contenttype.MultipartForm) {
|
||||
var err error
|
||||
if strings.Contains(ct, contentTypeMultipartForm) {
|
||||
if strings.Contains(ct, contenttype.MultipartForm) {
|
||||
err = r.ParseMultipartForm(0)
|
||||
} else {
|
||||
err = r.ParseForm()
|
||||
|
@ -149,7 +150,7 @@ func (a *goBlog) serveMicropubPost(w http.ResponseWriter, r *http.Request) {
|
|||
a.serveError(w, r, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
} else if strings.Contains(ct, contentTypeJSON) {
|
||||
} else if strings.Contains(ct, contenttype.JSON) {
|
||||
parsedMfItem := µformatItem{}
|
||||
err := json.NewDecoder(r.Body).Decode(parsedMfItem)
|
||||
if err != nil {
|
||||
|
|
|
@ -11,6 +11,8 @@ import (
|
|||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
const micropubMediaSubPath = "/media"
|
||||
|
@ -20,7 +22,7 @@ func (a *goBlog) serveMicropubMedia(w http.ResponseWriter, r *http.Request) {
|
|||
a.serveError(w, r, "media scope missing", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if ct := r.Header.Get(contentType); !strings.Contains(ct, contentTypeMultipartForm) {
|
||||
if ct := r.Header.Get(contentType); !strings.Contains(ct, contenttype.MultipartForm) {
|
||||
a.serveError(w, r, "wrong content-type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
|
39
minify.go
39
minify.go
|
@ -1,39 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"github.com/tdewolff/minify/v2"
|
||||
mCss "github.com/tdewolff/minify/v2/css"
|
||||
mHtml "github.com/tdewolff/minify/v2/html"
|
||||
mJs "github.com/tdewolff/minify/v2/js"
|
||||
mJson "github.com/tdewolff/minify/v2/json"
|
||||
mXml "github.com/tdewolff/minify/v2/xml"
|
||||
)
|
||||
|
||||
var (
|
||||
initMinify sync.Once
|
||||
minifier *minify.M
|
||||
)
|
||||
|
||||
func getMinifier() *minify.M {
|
||||
initMinify.Do(func() {
|
||||
minifier = minify.New()
|
||||
minifier.AddFunc(contentTypeHTML, mHtml.Minify)
|
||||
minifier.AddFunc("text/css", mCss.Minify)
|
||||
minifier.AddFunc(contentTypeXML, mXml.Minify)
|
||||
minifier.AddFunc("application/javascript", mJs.Minify)
|
||||
minifier.AddFunc(contentTypeRSS, mXml.Minify)
|
||||
minifier.AddFunc(contentTypeATOM, mXml.Minify)
|
||||
minifier.AddFunc(contentTypeJSONFeed, mJson.Minify)
|
||||
minifier.AddFunc(contentTypeAS, mJson.Minify)
|
||||
})
|
||||
return minifier
|
||||
}
|
||||
|
||||
func writeMinified(w io.Writer, mediatype string, b []byte) (int, error) {
|
||||
mw := getMinifier().Writer(mediatype, w)
|
||||
defer func() { _ = mw.Close() }()
|
||||
return mw.Write(b)
|
||||
}
|
10
nodeinfo.go
10
nodeinfo.go
|
@ -3,6 +3,8 @@ package main
|
|||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
func (a *goBlog) serveNodeInfoDiscover(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -14,8 +16,8 @@ func (a *goBlog) serveNodeInfoDiscover(w http.ResponseWriter, r *http.Request) {
|
|||
},
|
||||
},
|
||||
})
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
}
|
||||
|
||||
func (a *goBlog) serveNodeInfo(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -41,6 +43,6 @@ func (a *goBlog) serveNodeInfo(w http.ResponseWriter, r *http.Request) {
|
|||
},
|
||||
"metadata": map[string]interface{}{},
|
||||
})
|
||||
w.Header().Set(contentType, contentTypeJSONUTF8)
|
||||
_, _ = writeMinified(w, contentTypeJSON, b)
|
||||
w.Header().Set(contentType, contenttype.JSONUTF8)
|
||||
_, _ = a.min.Write(w, contenttype.JSON, b)
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
func (a *goBlog) serveOpenSearch(w http.ResponseWriter, r *http.Request) {
|
||||
|
@ -17,7 +19,7 @@ func (a *goBlog) serveOpenSearch(w http.ResponseWriter, r *http.Request) {
|
|||
"</OpenSearchDescription>",
|
||||
title, title, sURL, sURL)
|
||||
w.Header().Set(contentType, "application/opensearchdescription+xml")
|
||||
_, _ = writeMinified(w, contentTypeXML, []byte(xml))
|
||||
_, _ = a.min.Write(w, contenttype.XML, []byte(xml))
|
||||
}
|
||||
|
||||
func openSearchUrl(b *configBlog) string {
|
||||
|
|
6
paths.go
6
paths.go
|
@ -43,3 +43,9 @@ func (cfg *configServer) getFullAddress(path string) string {
|
|||
}
|
||||
return pa + path
|
||||
}
|
||||
|
||||
// Rendering funcs
|
||||
|
||||
func (blog *configBlog) RelativePath(path string) string {
|
||||
return blog.getRelativePath(path)
|
||||
}
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
package contenttype
|
||||
|
||||
// This package contains constants for a few content types used in GoBlog
|
||||
|
||||
const (
|
||||
CharsetUtf8Suffix = "; charset=utf-8"
|
||||
|
||||
AS = "application/activity+json"
|
||||
ATOM = "application/atom+xml"
|
||||
CSS = "text/css"
|
||||
HTML = "text/html"
|
||||
JS = "application/javascript"
|
||||
JSON = "application/json"
|
||||
JSONFeed = "application/feed+json"
|
||||
LDJSON = "application/ld+json"
|
||||
MultipartForm = "multipart/form-data"
|
||||
RSS = "application/rss+xml"
|
||||
WWWForm = "application/x-www-form-urlencoded"
|
||||
XML = "text/xml"
|
||||
|
||||
ASUTF8 = AS + CharsetUtf8Suffix
|
||||
HTMLUTF8 = HTML + CharsetUtf8Suffix
|
||||
JSONUTF8 = JSON + CharsetUtf8Suffix
|
||||
XMLUTF8 = XML + CharsetUtf8Suffix
|
||||
)
|
|
@ -0,0 +1,45 @@
|
|||
package minify
|
||||
|
||||
import (
|
||||
"io"
|
||||
"sync"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/tdewolff/minify/v2"
|
||||
mCss "github.com/tdewolff/minify/v2/css"
|
||||
mHtml "github.com/tdewolff/minify/v2/html"
|
||||
mJs "github.com/tdewolff/minify/v2/js"
|
||||
mJson "github.com/tdewolff/minify/v2/json"
|
||||
mXml "github.com/tdewolff/minify/v2/xml"
|
||||
)
|
||||
|
||||
type Minifier struct {
|
||||
i sync.Once
|
||||
m *minify.M
|
||||
}
|
||||
|
||||
func (m *Minifier) init() {
|
||||
m.i.Do(func() {
|
||||
m.m = minify.New()
|
||||
m.m.AddFunc(contenttype.HTML, mHtml.Minify)
|
||||
m.m.AddFunc(contenttype.CSS, mCss.Minify)
|
||||
m.m.AddFunc(contenttype.XML, mXml.Minify)
|
||||
m.m.AddFunc(contenttype.JS, mJs.Minify)
|
||||
m.m.AddFunc(contenttype.RSS, mXml.Minify)
|
||||
m.m.AddFunc(contenttype.ATOM, mXml.Minify)
|
||||
m.m.AddFunc(contenttype.JSONFeed, mJson.Minify)
|
||||
m.m.AddFunc(contenttype.AS, mJson.Minify)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Minifier) Get() *minify.M {
|
||||
m.init()
|
||||
return m.m
|
||||
}
|
||||
|
||||
func (m *Minifier) Write(w io.Writer, mediatype string, b []byte) (int, error) {
|
||||
m.init()
|
||||
mw := m.m.Writer(mediatype, w)
|
||||
defer func() { _ = mw.Close() }()
|
||||
return mw.Write(b)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
package minify
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_minify(t *testing.T) {
|
||||
var min Minifier
|
||||
assert.NotNil(t, min.Get())
|
||||
}
|
|
@ -6,7 +6,6 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
|
@ -122,8 +121,6 @@ type postCreationOptions struct {
|
|||
oldStatus postStatus
|
||||
}
|
||||
|
||||
var postCreationMutex sync.Mutex
|
||||
|
||||
func (a *goBlog) createOrReplacePost(p *post, o *postCreationOptions) error {
|
||||
// Check post
|
||||
if err := a.checkPost(p); err != nil {
|
||||
|
@ -148,8 +145,8 @@ func (a *goBlog) createOrReplacePost(p *post, o *postCreationOptions) error {
|
|||
// Save check post to database
|
||||
func (db *database) savePost(p *post, o *postCreationOptions) error {
|
||||
// Prevent bad things
|
||||
postCreationMutex.Lock()
|
||||
defer postCreationMutex.Unlock()
|
||||
db.pcm.Lock()
|
||||
defer db.pcm.Unlock()
|
||||
// Check if path is already in use
|
||||
if o.new || (p.Path != o.oldPath) {
|
||||
// Post is new or post path was changed
|
||||
|
|
|
@ -53,7 +53,7 @@ func Test_postsDb(t *testing.T) {
|
|||
is.Equal("en", p.Blog)
|
||||
is.Equal("test", p.Section)
|
||||
is.Equal(statusDraft, p.Status)
|
||||
is.Equal("Title", p.title())
|
||||
is.Equal("Title", p.Title())
|
||||
|
||||
// Check number of post paths
|
||||
pp, err := app.db.allPostPaths(statusDraft)
|
||||
|
|
|
@ -4,8 +4,11 @@ import (
|
|||
"html/template"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gogeouri "git.jlel.se/jlelse/go-geouri"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/araddon/dateparse"
|
||||
)
|
||||
|
||||
func (a *goBlog) fullPostURL(p *post) string {
|
||||
|
@ -23,6 +26,14 @@ func (a *goBlog) shortPostURL(p *post) string {
|
|||
return a.getFullAddress(s)
|
||||
}
|
||||
|
||||
func postParameter(p *post, parameter string) []string {
|
||||
return p.Parameters[parameter]
|
||||
}
|
||||
|
||||
func postHasParameter(p *post, parameter string) bool {
|
||||
return len(p.Parameters[parameter]) > 0
|
||||
}
|
||||
|
||||
func (p *post) firstParameter(parameter string) (result string) {
|
||||
if pp := p.Parameters[parameter]; len(pp) > 0 {
|
||||
result = pp[0]
|
||||
|
@ -30,11 +41,11 @@ func (p *post) firstParameter(parameter string) (result string) {
|
|||
return
|
||||
}
|
||||
|
||||
func (p *post) title() string {
|
||||
return p.firstParameter("title")
|
||||
func firstPostParameter(p *post, parameter string) string {
|
||||
return p.firstParameter(parameter)
|
||||
}
|
||||
|
||||
func (a *goBlog) html(p *post) template.HTML {
|
||||
func (a *goBlog) postHtml(p *post) template.HTML {
|
||||
if p.rendered != "" {
|
||||
return p.rendered
|
||||
}
|
||||
|
@ -47,7 +58,7 @@ func (a *goBlog) html(p *post) template.HTML {
|
|||
return p.rendered
|
||||
}
|
||||
|
||||
func (a *goBlog) absoluteHTML(p *post) template.HTML {
|
||||
func (a *goBlog) absolutePostHTML(p *post) template.HTML {
|
||||
if p.absoluteRendered != "" {
|
||||
return p.absoluteRendered
|
||||
}
|
||||
|
@ -62,12 +73,12 @@ func (a *goBlog) absoluteHTML(p *post) template.HTML {
|
|||
|
||||
const summaryDivider = "<!--more-->"
|
||||
|
||||
func (a *goBlog) summary(p *post) (summary string) {
|
||||
func (a *goBlog) postSummary(p *post) (summary string) {
|
||||
summary = p.firstParameter("summary")
|
||||
if summary != "" {
|
||||
return
|
||||
}
|
||||
html := string(a.html(p))
|
||||
html := string(a.postHtml(p))
|
||||
if splitted := strings.Split(html, summaryDivider); len(splitted) > 1 {
|
||||
doc, _ := goquery.NewDocumentFromReader(strings.NewReader(splitted[0]))
|
||||
summary = doc.Text()
|
||||
|
@ -78,7 +89,7 @@ func (a *goBlog) summary(p *post) (summary string) {
|
|||
return
|
||||
}
|
||||
|
||||
func (a *goBlog) translations(p *post) []*post {
|
||||
func (a *goBlog) postTranslations(p *post) []*post {
|
||||
translationkey := p.firstParameter("translationkey")
|
||||
if translationkey == "" {
|
||||
return nil
|
||||
|
@ -105,3 +116,30 @@ func (a *goBlog) translations(p *post) []*post {
|
|||
func (p *post) isPublishedSectionPost() bool {
|
||||
return p.Published != "" && p.Section != "" && p.Status == statusPublished
|
||||
}
|
||||
|
||||
// Public because of rendering
|
||||
|
||||
func (p *post) Title() string {
|
||||
return p.firstParameter("title")
|
||||
}
|
||||
|
||||
func (p *post) GeoURI() *gogeouri.Geo {
|
||||
loc := p.firstParameter("location")
|
||||
if loc == "" {
|
||||
return nil
|
||||
}
|
||||
g, _ := gogeouri.Parse(loc)
|
||||
return g
|
||||
}
|
||||
|
||||
func (p *post) Old() bool {
|
||||
pub := p.Published
|
||||
if pub == "" {
|
||||
return false
|
||||
}
|
||||
pubDate, err := dateparse.ParseLocal(pub)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return pubDate.AddDate(1, 0, 0).Before(time.Now())
|
||||
}
|
||||
|
|
174
render.go
174
render.go
|
@ -2,21 +2,16 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
gogeouri "git.jlel.se/jlelse/go-geouri"
|
||||
"github.com/araddon/dateparse"
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
servertiming "github.com/mitchellh/go-server-timing"
|
||||
)
|
||||
|
||||
|
@ -48,142 +43,32 @@ const (
|
|||
func (a *goBlog) initRendering() error {
|
||||
a.templates = map[string]*template.Template{}
|
||||
templateFunctions := template.FuncMap{
|
||||
"menu": func(blog *configBlog, id string) *menu {
|
||||
return blog.Menus[id]
|
||||
},
|
||||
"user": func() *configUser {
|
||||
return a.cfg.User
|
||||
},
|
||||
"md": func(content string) template.HTML {
|
||||
htmlContent, err := a.renderMarkdown(content, false)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
return ""
|
||||
}
|
||||
return template.HTML(htmlContent)
|
||||
},
|
||||
"html": func(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
},
|
||||
"md": a.safeRenderMarkdownAsHTML,
|
||||
"html": wrapStringAsHTML,
|
||||
// Post specific
|
||||
"p": func(p *post, parameter string) string {
|
||||
return p.firstParameter(parameter)
|
||||
},
|
||||
"ps": func(p *post, parameter string) []string {
|
||||
return p.Parameters[parameter]
|
||||
},
|
||||
"hasp": func(p *post, parameter string) bool {
|
||||
return len(p.Parameters[parameter]) > 0
|
||||
},
|
||||
"title": func(p *post) string {
|
||||
return p.title()
|
||||
},
|
||||
"content": func(p *post) template.HTML {
|
||||
return a.html(p)
|
||||
},
|
||||
"summary": func(p *post) string {
|
||||
return a.summary(p)
|
||||
},
|
||||
"translations": func(p *post) []*post {
|
||||
return a.translations(p)
|
||||
},
|
||||
"shorturl": func(p *post) string {
|
||||
return a.shortPostURL(p)
|
||||
},
|
||||
"p": firstPostParameter,
|
||||
"ps": postParameter,
|
||||
"hasp": postHasParameter,
|
||||
"content": a.postHtml,
|
||||
"summary": a.postSummary,
|
||||
"translations": a.postTranslations,
|
||||
"shorturl": a.shortPostURL,
|
||||
// Others
|
||||
"dateformat": dateFormat,
|
||||
"isodate": func(date string) string {
|
||||
return dateFormat(date, "2006-01-02")
|
||||
},
|
||||
"unixtodate": func(unix int64) string {
|
||||
return time.Unix(unix, 0).Local().String()
|
||||
},
|
||||
"now": func() string {
|
||||
return time.Now().Local().String()
|
||||
},
|
||||
"dateadd": func(date string, years, months, days int) string {
|
||||
d, err := dateparse.ParseLocal(date)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return d.AddDate(years, months, days).Local().String()
|
||||
},
|
||||
"datebefore": func(date string, before string) bool {
|
||||
d, err := dateparse.ParseLocal(date)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
b, err := dateparse.ParseLocal(before)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return d.Before(b)
|
||||
},
|
||||
"isodate": isoDateFormat,
|
||||
"unixtodate": unixToLocalDateString,
|
||||
"now": localNowString,
|
||||
"asset": a.assetFileName,
|
||||
"assetsri": a.assetSRI,
|
||||
"string": a.ts.GetTemplateStringVariantFunc(),
|
||||
"include": func(templateName string, data ...interface{}) (template.HTML, error) {
|
||||
if len(data) == 0 || len(data) > 2 {
|
||||
return "", errors.New("wrong argument count")
|
||||
}
|
||||
if rd, ok := data[0].(*renderData); ok {
|
||||
if len(data) == 2 {
|
||||
nrd := *rd
|
||||
nrd.Data = data[1]
|
||||
rd = &nrd
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := a.templates[templateName].ExecuteTemplate(&buf, templateName, rd)
|
||||
return template.HTML(buf.String()), err
|
||||
}
|
||||
return "", errors.New("wrong arguments")
|
||||
},
|
||||
"include": a.includeRenderedTemplate,
|
||||
"urlize": urlize,
|
||||
"sort": sortedStrings,
|
||||
"absolute": func(path string) string {
|
||||
return a.getFullAddress(path)
|
||||
},
|
||||
"blogrelative": func(blog *configBlog, path string) string {
|
||||
return blog.getRelativePath(path)
|
||||
},
|
||||
"jsonFile": func(filename string) *map[string]interface{} {
|
||||
parsed := &map[string]interface{}{}
|
||||
content, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
err = json.Unmarshal(content, parsed)
|
||||
if err != nil {
|
||||
fmt.Println(err.Error())
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
},
|
||||
"mentions": func(absolute string) []*mention {
|
||||
mentions, _ := a.db.getWebmentions(&webmentionsRequestConfig{
|
||||
target: absolute,
|
||||
status: webmentionStatusApproved,
|
||||
asc: true,
|
||||
})
|
||||
return mentions
|
||||
},
|
||||
"urlToString": func(u url.URL) string {
|
||||
return u.String()
|
||||
},
|
||||
"geouri": func(u string) *gogeouri.Geo {
|
||||
g, _ := gogeouri.Parse(u)
|
||||
return g
|
||||
},
|
||||
"geourip": func(g *gogeouri.Geo, parameter string) (s string) {
|
||||
if gp := g.Parameters[parameter]; len(gp) > 0 {
|
||||
return gp[0]
|
||||
}
|
||||
return
|
||||
},
|
||||
"absolute": a.getFullAddress,
|
||||
"mentions": a.db.getWebmentionsByAddress,
|
||||
"geotitle": a.db.geoTitle,
|
||||
"geolink": geoOSMLink,
|
||||
"opensearch": openSearchUrl,
|
||||
}
|
||||
|
||||
baseTemplate, err := template.New("base").Funcs(templateFunctions).ParseFiles(path.Join(templatesDir, templateBase+templatesExt))
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -212,6 +97,7 @@ type renderData struct {
|
|||
Canonical string
|
||||
TorAddress string
|
||||
Blog *configBlog
|
||||
User *configUser
|
||||
Data interface{}
|
||||
LoggedIn bool
|
||||
CommentsEnabled bool
|
||||
|
@ -223,6 +109,9 @@ func (a *goBlog) render(w http.ResponseWriter, r *http.Request, template string,
|
|||
// Server timing
|
||||
t := servertiming.FromContext(r.Context()).NewMetric("r").Start()
|
||||
// Check render data
|
||||
if data.User == nil {
|
||||
data.User = a.cfg.User
|
||||
}
|
||||
if data.Blog == nil {
|
||||
if len(data.BlogString) == 0 {
|
||||
data.BlogString = a.cfg.DefaultBlog
|
||||
|
@ -256,7 +145,7 @@ func (a *goBlog) render(w http.ResponseWriter, r *http.Request, template string,
|
|||
data.TorUsed = true
|
||||
}
|
||||
// Set content type
|
||||
w.Header().Set(contentType, contentTypeHTMLUTF8)
|
||||
w.Header().Set(contentType, contenttype.HTMLUTF8)
|
||||
// Minify and write response
|
||||
var tw bytes.Buffer
|
||||
err := a.templates[template].ExecuteTemplate(&tw, template, data)
|
||||
|
@ -264,7 +153,7 @@ func (a *goBlog) render(w http.ResponseWriter, r *http.Request, template string,
|
|||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_, err = writeMinified(w, contentTypeHTML, tw.Bytes())
|
||||
_, err = a.min.Write(w, contenttype.HTML, tw.Bytes())
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
|
@ -272,3 +161,20 @@ func (a *goBlog) render(w http.ResponseWriter, r *http.Request, template string,
|
|||
// Server timing
|
||||
t.Stop()
|
||||
}
|
||||
|
||||
func (a *goBlog) includeRenderedTemplate(templateName string, data ...interface{}) (template.HTML, error) {
|
||||
if l := len(data); l < 1 || l > 2 {
|
||||
return "", errors.New("wrong argument count")
|
||||
}
|
||||
if rd, ok := data[0].(*renderData); ok {
|
||||
if len(data) == 2 {
|
||||
nrd := *rd
|
||||
nrd.Data = data[1]
|
||||
rd = &nrd
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
err := a.templates[templateName].ExecuteTemplate(&buf, templateName, rd)
|
||||
return template.HTML(buf.String()), err
|
||||
}
|
||||
return "", errors.New("wrong arguments")
|
||||
}
|
||||
|
|
|
@ -27,7 +27,7 @@ func (a *goBlog) initSessions() {
|
|||
}
|
||||
}
|
||||
deleteExpiredSessions()
|
||||
hourlyHooks = append(hourlyHooks, deleteExpiredSessions)
|
||||
a.hourlyHooks = append(a.hourlyHooks, deleteExpiredSessions)
|
||||
a.loginSessions = &dbSessionStore{
|
||||
codecs: securecookie.CodecsFromPairs(a.jwtKey()),
|
||||
options: &sessions.Options{
|
||||
|
|
|
@ -16,7 +16,7 @@ const telegramBaseURL = "https://api.telegram.org/bot"
|
|||
func (a *goBlog) initTelegram() {
|
||||
a.pPostHooks = append(a.pPostHooks, func(p *post) {
|
||||
if tg := a.cfg.Blogs[p.Blog].Telegram; tg.enabled() && p.isPublishedSectionPost() {
|
||||
if html := tg.generateHTML(p.title(), a.fullPostURL(p), a.shortPostURL(p)); html != "" {
|
||||
if html := tg.generateHTML(p.Title(), a.fullPostURL(p), a.shortPostURL(p)); html != "" {
|
||||
if err := tg.send(html, "HTML"); err != nil {
|
||||
log.Printf("Failed to send post to Telegram: %v", err)
|
||||
}
|
||||
|
|
|
@ -2,23 +2,21 @@ package main
|
|||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"crypto/sha512"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
const assetsFolder = "templates/assets"
|
||||
|
||||
type assetFile struct {
|
||||
contentType string
|
||||
sri string
|
||||
body []byte
|
||||
}
|
||||
|
||||
|
@ -50,7 +48,7 @@ func (a *goBlog) compileAsset(name string) (string, error) {
|
|||
}
|
||||
ext := path.Ext(name)
|
||||
compiledExt := ext
|
||||
m := getMinifier()
|
||||
m := a.min.Get()
|
||||
switch ext {
|
||||
case ".js":
|
||||
content, err = m.Bytes("application/javascript", content)
|
||||
|
@ -67,18 +65,14 @@ func (a *goBlog) compileAsset(name string) (string, error) {
|
|||
}
|
||||
// Hashes
|
||||
sha1Hash := sha1.New()
|
||||
sha512Hash := sha512.New()
|
||||
if _, err := io.MultiWriter(sha1Hash, sha512Hash).Write(content); err != nil {
|
||||
if _, err := sha1Hash.Write(content); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// File name
|
||||
compiledFileName := fmt.Sprintf("%x", sha1Hash.Sum(nil)) + compiledExt
|
||||
// SRI
|
||||
sriHash := fmt.Sprintf("sha512-%s", base64.StdEncoding.EncodeToString(sha512Hash.Sum(nil)))
|
||||
// Create struct
|
||||
a.assetFiles[compiledFileName] = &assetFile{
|
||||
contentType: mime.TypeByExtension(compiledExt),
|
||||
sri: sriHash,
|
||||
body: content,
|
||||
}
|
||||
return compiledFileName, err
|
||||
|
@ -89,10 +83,6 @@ func (a *goBlog) assetFileName(fileName string) string {
|
|||
return "/" + a.assetFileNames[fileName]
|
||||
}
|
||||
|
||||
func (a *goBlog) assetSRI(fileName string) string {
|
||||
return a.assetFiles[a.assetFileNames[fileName]].sri
|
||||
}
|
||||
|
||||
func (a *goBlog) allAssetPaths() []string {
|
||||
var paths []string
|
||||
for _, name := range a.assetFileNames {
|
||||
|
@ -109,6 +99,6 @@ func (a *goBlog) serveAsset(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
w.Header().Set("Cache-Control", "public,max-age=31536000,immutable")
|
||||
w.Header().Set(contentType, af.contentType+charsetUtf8Suffix)
|
||||
w.Header().Set(contentType, af.contentType+contenttype.CharsetUtf8Suffix)
|
||||
_, _ = w.Write(af.body)
|
||||
}
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{{ define "author" }}
|
||||
{{ with user }}
|
||||
{{ with .User }}
|
||||
<div class="p-author h-card hide">
|
||||
{{ with .Picture }}<data class="u-photo" value="{{ . }}"></data>{{ end }}
|
||||
{{ if .Name }}
|
||||
|
|
|
@ -15,11 +15,7 @@
|
|||
<link rel="micropub" href="/micropub" />
|
||||
<link rel="authorization_endpoint" href="/indieauth" />
|
||||
<link rel="token_endpoint" href="/indieauth/token" />
|
||||
{{ with user }}
|
||||
{{ range .Identities }}
|
||||
<link rel="me" href="{{ . }}" />
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ with .User }}{{ range .Identities }}<link rel="me" href="{{ . }}" />{{ end }}{{ end }}
|
||||
{{ $os := opensearch .Blog }}
|
||||
{{ if $os }}
|
||||
<link rel="search" type="application/opensearchdescription+xml" href="{{ $os }}" title="{{ .Blog.Title }}" />
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
<main>
|
||||
{{ with .Data.Title }}<h1>{{ . }}</h1>{{ end }}
|
||||
{{ with .Data.Description }}{{ md . }}{{ end }}
|
||||
<p><a href="{{ blogrelative .Blog .Data.Download }}" class="button" download>{{ string .Blog.Lang "download" }}</a></p>
|
||||
<p><a href="{{ .Blog.RelativePath .Data.Download }}" class="button" download>{{ string .Blog.Lang "download" }}</a></p>
|
||||
{{ $lang := .Blog.Lang }}
|
||||
{{ range .Data.Outlines }}
|
||||
{{ $title := .Title }}
|
||||
|
@ -16,7 +16,7 @@
|
|||
{{ range .Outlines }}
|
||||
{{ $ct := .Title }}
|
||||
{{ if not $ct }}{{ $ct = .Text }}{{ end }}
|
||||
<li><a href="{{ urlToString .HTMLURL }}" target="_blank">{{ $ct }}</a> (<a href="{{ urlToString .XMLURL }}" target="_blank">{{ string $lang "feed" }}</a>)</li>
|
||||
<li><a href="{{ .HTMLURL }}" target="_blank">{{ $ct }}</a> (<a href="{{ .XMLURL }}" target="_blank">{{ string $lang "feed" }}</a>)</li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
{{ end }}
|
||||
|
|
|
@ -55,7 +55,7 @@ tags:
|
|||
<input type="hidden" name="editoraction" value="loadupdate">
|
||||
<select name="url" class="fw">
|
||||
{{ range $i, $draft := .Data.Drafts }}
|
||||
<option value="{{ absolute $draft.Path }}">{{ with (title $draft) }}{{ . }}{{ else }}{{ $draft.Path }}{{ end }}</option>
|
||||
<option value="{{ absolute $draft.Path }}">{{ with ($draft.Title) }}{{ . }}{{ else }}{{ $draft.Path }}{{ end }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
<input class="fw" type="submit" value="{{ string .Blog.Lang "update" }}">
|
||||
|
|
|
@ -1,13 +1,13 @@
|
|||
{{ define "footer" }}
|
||||
<footer>
|
||||
{{ with menu .Blog "footer" }}
|
||||
{{ with index .Blog.Menus "footer" }}
|
||||
<p>
|
||||
{{ range $i, $item := .Items }}
|
||||
{{ if ne $i 0 }} • {{ end }}<a href="{{ $item.Link }}">{{ $item.Title }}</a>
|
||||
{{ end }}
|
||||
</p>
|
||||
{{ end }}
|
||||
<p translate="no">© {{ dateformat now "2006" }} {{ with user.Name }}{{ . }}{{ else }}{{ .Blog.Title }}{{ end }}</p>
|
||||
<p translate="no">© {{ dateformat now "2006" }} {{ with .User.Name }}{{ . }}{{ else }}{{ .Blog.Title }}{{ end }}</p>
|
||||
{{ if .TorUsed }}
|
||||
<p>🔐 {{ string .Blog.Lang "connectedviator" }}</p>
|
||||
{{ else }}
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
{{ define "header" }}
|
||||
<header>
|
||||
<h1><a href="{{ blogrelative .Blog "/" }}" rel="home" title="{{ .Blog.Title }}" translate="no">{{ .Blog.Title }}</a></h1>
|
||||
<h1><a href="{{ .Blog.RelativePath "/" }}" rel="home" title="{{ .Blog.Title }}" translate="no">{{ .Blog.Title }}</a></h1>
|
||||
{{ with .Blog.Description }}<p><i>{{ . }}</i></p>{{ end }}
|
||||
<nav>
|
||||
{{ with menu .Blog "main" }}
|
||||
{{ with index .Blog.Menus "main" }}
|
||||
{{ range $i, $item := .Items }}{{ if ne $i 0 }} • {{ end }}<a href="{{ $item.Link }}">{{ $item.Title }}</a>{{ end }}
|
||||
{{ end }}
|
||||
</nav>
|
||||
{{ if .LoggedIn }}
|
||||
<nav>
|
||||
<a href="{{ blogrelative .Blog "/editor" }}">{{ string .Blog.Lang "editor" }}</a>
|
||||
<a href="{{ .Blog.RelativePath "/editor" }}">{{ string .Blog.Lang "editor" }}</a>
|
||||
• <a href="/notifications">{{ string .Blog.Lang "notifications" }}</a>
|
||||
{{ if .WebmentionReceivingEnabled }}• <a href="/webmention">{{ string .Blog.Lang "webmentions" }}</a>{{ end }}
|
||||
{{ if .CommentsEnabled }}• <a href="{{ blogrelative .Blog "/comment" }}">{{ string .Blog.Lang "comments" }}</a>{{ end }}
|
||||
{{ if .CommentsEnabled }}• <a href="{{ .Blog.RelativePath "/comment" }}">{{ string .Blog.Lang "comments" }}</a>{{ end }}
|
||||
• <a href="/logout">{{ string .Blog.Lang "logout" }}</a>
|
||||
</nav>
|
||||
{{ end }}
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
<input type="hidden" name="target" value="{{ .Canonical }}">
|
||||
<input class="fw" type="submit" value="{{ string .Blog.Lang "send" }}">
|
||||
</form>
|
||||
<form class="fw-form p" method="post" action="{{ blogrelative .Blog "/comment" }}">
|
||||
<form class="fw-form p" method="post" action="{{ .Blog.RelativePath "/comment" }}">
|
||||
<input type="hidden" name="target" value="{{ .Canonical }}">
|
||||
<input type="text" name="name" placeholder="{{ string .Blog.Lang "nameopt" }}">
|
||||
<input type="url" name="website" placeholder="{{ string .Blog.Lang "websiteopt" }}">
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
{{ define "oldcontentwarning" }}
|
||||
{{ if .Data.Published }}
|
||||
{{ if (datebefore (dateadd .Data.Published 1 0 0) now) }}
|
||||
{{ if .Data.Old }}
|
||||
<strong class="p border-top border-bottom">{{ string .Blog.Lang "oldcontent" }}</strong>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ end }}
|
|
@ -1,5 +1,5 @@
|
|||
{{ define "title" }}
|
||||
<title>{{ with title .Data }}{{ . }} - {{end}}{{ .Blog.Title }}</title>
|
||||
<title>{{ with .Data.Title }}{{ . }} - {{end}}{{ .Blog.Title }}</title>
|
||||
{{ include "postheadmeta" . }}
|
||||
{{ with shorturl .Data }}<link rel="shortlink" href="{{ . }}">{{ end }}
|
||||
{{ end }}
|
||||
|
@ -8,7 +8,7 @@
|
|||
<main class=h-entry>
|
||||
<article>
|
||||
<data class="u-url hide" value="{{ absolute .Data.Path }}"></data>
|
||||
{{ with title .Data }}<h1 class=p-name>{{ . }}</h1>{{ end }}
|
||||
{{ with .Data.Title }}<h1 class=p-name>{{ . }}</h1>{{ end }}
|
||||
{{ include "postmeta" . }}
|
||||
{{ include "postactions" . }}
|
||||
{{ if .Data.Content }}
|
||||
|
@ -29,12 +29,12 @@
|
|||
</main>
|
||||
{{ if .LoggedIn }}
|
||||
<div class="p">
|
||||
<form class="in" method="post" action="{{ blogrelative .Blog "/editor" }}#update">
|
||||
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}#update">
|
||||
<input type="hidden" name="editoraction" value="loadupdate">
|
||||
<input type="hidden" name="url" value="{{ .Canonical }}">
|
||||
<input type="submit" value="{{ string .Blog.Lang "update" }}">
|
||||
</form>
|
||||
<form class="in" method="post" action="{{ blogrelative .Blog "/editor" }}#delete">
|
||||
<form class="in" method="post" action="{{ .Blog.RelativePath "/editor" }}#delete">
|
||||
<input type="hidden" name="editoraction" value="loaddelete">
|
||||
<input type="hidden" name="url" value="{{ .Canonical }}">
|
||||
<input type="submit" value="{{ string .Blog.Lang "delete" }}">
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{ define "postactions" }}
|
||||
<div class="p flex" id="post-actions">
|
||||
<a href="https://www.addtoany.com/share#url={{ absolute .Data.Path }}{{ with title .Data }}&title={{ . }}{{ end }}" target="_blank" rel="nofollow noopener noreferrer" class="button">{{ string .Blog.Lang "share" }}</a>
|
||||
<a href="https://www.addtoany.com/share#url={{ absolute .Data.Path }}{{ with .Data.Title }}&title={{ . }}{{ end }}" target="_blank" rel="nofollow noopener noreferrer" class="button">{{ string .Blog.Lang "share" }}</a>
|
||||
<a id="translateBtn" href="https://translate.google.com/translate?u={{ absolute .Data.Path }}" target="_blank" rel="nofollow noopener noreferrer" class="button">{{ string .Blog.Lang "translate" }}</a>
|
||||
<script defer src="{{ asset "js/translate.js" }}"></script>
|
||||
<button id="speakBtn" class="hide" data-speak="{{ string .Blog.Lang "speak" }}" data-stopspeak="{{ string .Blog.Lang "stopspeak" }}"></button>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
<meta property="og:url" content="{{ . }}">
|
||||
<meta property="twitter:url" content="{{ . }}">
|
||||
{{ end }}
|
||||
{{ with title .Data }}
|
||||
{{ with .Data.Title }}
|
||||
<meta property="og:title" content="{{ . }}">
|
||||
<meta property="twitter:title" content="{{ . }}">
|
||||
{{ end }}
|
||||
|
|
|
@ -2,19 +2,17 @@
|
|||
<div class="p">
|
||||
{{ include "summaryandpostmeta" . }}
|
||||
{{ $bloglang := .Blog.Lang }}
|
||||
{{ $loc := ( p .Data "location" ) }}
|
||||
{{ if $loc }}
|
||||
{{ with geouri $loc }}
|
||||
<div>📍 <a class="p-location h-geo" href="https://www.openstreetmap.org/?mlat={{ .Latitude }}&mlon={{ .Longitude }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||
<span class="p-name">{{ with ( geourip . "name" ) }}{{ . }}{{ else }}{{ with ( geotitle .Latitude .Longitude $bloglang ) }}{{ . }}{{ else }}{{ .Latitude }}, {{ .Longitude }}{{ end }}{{ end }}</span>
|
||||
<data class="p-longitude" value="{{ .Longitude }}" />
|
||||
<data class="p-latitude" value="{{ .Latitude }}" />
|
||||
{{ $geo := .Data.GeoURI }}
|
||||
{{ if $geo }}
|
||||
<div>📍 <a class="p-location h-geo" href="{{ geolink $geo }}" target="_blank" rel="nofollow noopener noreferrer">
|
||||
<span class="p-name">{{ geotitle $geo .Blog.Lang }}</span>
|
||||
<data class="p-longitude" value="{{ $geo.Longitude }}" />
|
||||
<data class="p-latitude" value="{{ $geo.Latitude }}" />
|
||||
</a></div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
{{ $translations := (translations .Data) }}
|
||||
{{ if gt (len $translations) 0 }}
|
||||
<div>{{ string .Blog.Lang "translations" }}: {{ $delimiter := "" }}{{ range $i, $t := $translations }}{{ $delimiter }}<a href="{{ $t.Path }}" translate="no">{{ title $t }}</a>{{ $delimiter = ", " }}{{ end }}</div>
|
||||
<div>{{ string .Blog.Lang "translations" }}: {{ $delimiter := "" }}{{ range $i, $t := $translations }}{{ $delimiter }}<a href="{{ $t.Path }}" translate="no">{{ $t.Title }}</a>{{ $delimiter = ", " }}{{ end }}</div>
|
||||
{{ end }}
|
||||
{{ $short := shorturl .Data }}
|
||||
{{ if $short }}<div>{{ string .Blog.Lang "shorturl" }} <a href="{{ $short }}" rel="shortlink">{{ $short }}</a></div>{{ end }}
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
{{ if gt (len $tvs) 0 }}
|
||||
<p><b>{{ $tax.Title }}</b>:
|
||||
{{ range $j, $tv := $tvs }}
|
||||
<a class="p-category" rel="tag" href="{{ blogrelative $blog ( printf "/%s/%s" $tax.Name (urlize $tv) ) }}">{{ $tv }}</a>
|
||||
<a class="p-category" rel="tag" href="{{ $blog.RelativePath ( printf "/%s/%s" $tax.Name (urlize $tv) ) }}">{{ $tv }}</a>
|
||||
{{ end }}
|
||||
</p>
|
||||
{{ end }}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{{ define "summaryandpostmeta" }}
|
||||
{{ $section := (index .Blog.Sections .Data.Section) }}
|
||||
{{ if .Data.Published }}<div>{{ string .Blog.Lang "publishedon" }} <time class="dt-published" datetime="{{ dateformat .Data.Published "2006-01-02T15:04:05Z07:00"}}">{{ isodate .Data.Published }}</time>{{ if $section }} in <a href="{{ blogrelative .Blog $section.Name }}">{{ $section.Title }}</a>{{ end }}</div>{{ end }}
|
||||
{{ if .Data.Published }}<div>{{ string .Blog.Lang "publishedon" }} <time class="dt-published" datetime="{{ dateformat .Data.Published "2006-01-02T15:04:05Z07:00"}}">{{ isodate .Data.Published }}</time>{{ if $section }} in <a href="{{ .Blog.RelativePath $section.Name }}">{{ $section.Title }}</a>{{ end }}</div>{{ end }}
|
||||
{{ if .Data.Updated }}<div>{{ string .Blog.Lang "updatedon" }} <time class="dt-updated" datetime="{{ dateformat .Data.Updated "2006-01-02T15:04:05Z07:00"}}">{{ isodate .Data.Updated }}</time></div>{{ end }}
|
||||
{{ if p .Data "replylink" }}
|
||||
<div>{{ string .Blog.Lang "replyto" }}: <a class="u-in-reply-to" href="{{ p .Data "replylink" }}" target="_blank" rel="noopener">{{ with (p .Data "replytitle") }}{{ . }}{{ else }}{{ p .Data "replylink" }}{{ end }}</a></div>
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<h2>{{ $valueGroup.Identifier }}</h2>
|
||||
<p>
|
||||
{{ range $i, $value := $valueGroup.Strings }}
|
||||
{{ if ne $i 0 }} • {{ end }}<a href="{{ blogrelative $blog ( printf "/%s/%s" $taxonomy (urlize .) ) }}">{{ . }}</a>
|
||||
{{ if ne $i 0 }} • {{ end }}<a href="{{ $blog.RelativePath ( printf "/%s/%s" $taxonomy (urlize .) ) }}">{{ . }}</a>
|
||||
{{ end }}
|
||||
</p>
|
||||
{{ end }}
|
||||
|
|
2
tor.go
2
tor.go
|
@ -16,7 +16,7 @@ import (
|
|||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
var torUsedKey requestContextKey = "tor"
|
||||
const torUsedKey requestContextKey = "tor"
|
||||
|
||||
func (a *goBlog) startOnionService(h http.Handler) error {
|
||||
torDataPath, err := filepath.Abs("data/tor")
|
||||
|
|
18
utils.go
18
utils.go
|
@ -1,11 +1,13 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
|
@ -156,6 +158,18 @@ func dateFormat(date string, format string) string {
|
|||
return d.Local().Format(format)
|
||||
}
|
||||
|
||||
func isoDateFormat(date string) string {
|
||||
return dateFormat(date, "2006-01-02")
|
||||
}
|
||||
|
||||
func unixToLocalDateString(unix int64) string {
|
||||
return time.Unix(unix, 0).Local().String()
|
||||
}
|
||||
|
||||
func localNowString() string {
|
||||
return time.Now().Local().String()
|
||||
}
|
||||
|
||||
type stringPair struct {
|
||||
First, Second string
|
||||
}
|
||||
|
@ -173,3 +187,7 @@ func charCount(s string) (count int) {
|
|||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func wrapStringAsHTML(s string) template.HTML {
|
||||
return template.HTML(s)
|
||||
}
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
"net/http/httptest"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
)
|
||||
|
||||
type webmentionStatus string
|
||||
|
@ -63,7 +65,7 @@ func (a *goBlog) handleWebmention(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
|
||||
func extractMention(r *http.Request) (*mention, error) {
|
||||
if !strings.Contains(r.Header.Get(contentType), contentTypeWWWForm) {
|
||||
if !strings.Contains(r.Header.Get(contentType), contenttype.WWWForm) {
|
||||
return nil, errors.New("unsupported Content-Type")
|
||||
}
|
||||
err := r.ParseForm()
|
||||
|
@ -187,6 +189,15 @@ func (db *database) getWebmentions(config *webmentionsRequestConfig) ([]*mention
|
|||
return mentions, nil
|
||||
}
|
||||
|
||||
func (db *database) getWebmentionsByAddress(address string) []*mention {
|
||||
mentions, _ := db.getWebmentions(&webmentionsRequestConfig{
|
||||
target: address,
|
||||
status: webmentionStatusApproved,
|
||||
asc: true,
|
||||
})
|
||||
return mentions
|
||||
}
|
||||
|
||||
func (db *database) countWebmentions(config *webmentionsRequestConfig) (count int, err error) {
|
||||
query, params := buildWebmentionsQuery(config)
|
||||
query = "select count(*) from (" + query + ")"
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"net/url"
|
||||
"strings"
|
||||
|
||||
"git.jlel.se/jlelse/GoBlog/pkgs/contenttype"
|
||||
"github.com/PuerkitoBio/goquery"
|
||||
"github.com/thoas/go-funk"
|
||||
"github.com/tomnomnom/linkheader"
|
||||
|
@ -19,7 +20,7 @@ func (a *goBlog) sendWebmentions(p *post) error {
|
|||
return nil
|
||||
}
|
||||
links := []string{}
|
||||
contentLinks, err := allLinksFromHTML(strings.NewReader(string(a.html(p))), a.fullPostURL(p))
|
||||
contentLinks, err := allLinksFromHTML(strings.NewReader(string(a.postHtml(p))), a.fullPostURL(p))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -67,7 +68,7 @@ func sendWebmention(endpoint, source, target string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set(contentType, contentTypeWWWForm)
|
||||
req.Header.Set(contentType, contenttype.WWWForm)
|
||||
req.Header.Set(userAgent, appUserAgent)
|
||||
res, err := appHttpClient.Do(req)
|
||||
if err != nil {
|
||||
|
|
|
@ -84,6 +84,7 @@ func (a *goBlog) verifyMention(m *mention) error {
|
|||
}
|
||||
}
|
||||
err = m.verifyReader(resp.Body)
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
_, err := a.db.exec("delete from webmentions where source = @source and target = @target", sql.Named("source", m.Source), sql.Named("target", m.Target))
|
||||
|
@ -162,12 +163,30 @@ func (m *mention) fill(mf *microformats.Microformat) bool {
|
|||
}
|
||||
}
|
||||
// Title
|
||||
m.fillTitle(mf)
|
||||
// Content
|
||||
m.fillContent(mf)
|
||||
// Author
|
||||
m.fillAuthor(mf)
|
||||
return true
|
||||
}
|
||||
for _, mfc := range mf.Children {
|
||||
if m.fill(mfc) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *mention) fillTitle(mf *microformats.Microformat) {
|
||||
if name, ok := mf.Properties["name"]; ok && len(name) > 0 {
|
||||
if title, ok := name[0].(string); ok {
|
||||
m.Title = strings.TrimSpace(title)
|
||||
}
|
||||
}
|
||||
// Content
|
||||
}
|
||||
|
||||
func (m *mention) fillContent(mf *microformats.Microformat) {
|
||||
if contents, ok := mf.Properties["content"]; ok && len(contents) > 0 {
|
||||
if content, ok := contents[0].(map[string]string); ok {
|
||||
if contentValue, ok := content["value"]; ok {
|
||||
|
@ -175,7 +194,9 @@ func (m *mention) fill(mf *microformats.Microformat) bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
// Author
|
||||
}
|
||||
|
||||
func (m *mention) fillAuthor(mf *microformats.Microformat) {
|
||||
if authors, ok := mf.Properties["author"]; ok && len(authors) > 0 {
|
||||
if author, ok := authors[0].(*microformats.Microformat); ok {
|
||||
if names, ok := author.Properties["name"]; ok && len(names) > 0 {
|
||||
|
@ -185,16 +206,6 @@ func (m *mention) fill(mf *microformats.Microformat) bool {
|
|||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if len(mf.Children) > 0 {
|
||||
for _, mfc := range mf.Children {
|
||||
if m.fill(mfc) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mfHasType(mf *microformats.Microformat, typ string) bool {
|
||||
|
|
Loading…
Reference in New Issue