From ba00c7c6d6905038028524b7482b2ed96ec54bb6 Mon Sep 17 00:00:00 2001 From: Sergey Matveev Date: Thu, 21 May 2020 19:47:52 +0300 Subject: [PATCH] Comments Atom feeds --- README.texi | 1 + cmd/sgblog/http.go | 242 +++++++++++++++++++++++++++++++++++++-------- cmd/sgblog/main.go | 6 +- common.go | 2 +- 4 files changed, 208 insertions(+), 43 deletions(-) diff --git a/README.texi b/README.texi index 37840b3..c2b5887 100644 --- a/README.texi +++ b/README.texi @@ -27,6 +27,7 @@ Its main competitive features: server @item Supports on the fly generation of @url{https://en.wikipedia.org/wiki/Atom_(feed), Atom} feeds + for posts, comments and per-post comments @item Single binary for email-backed comments posting @item If access is granted, then everyone can easily create an offline copy of your blog/phlog! diff --git a/cmd/sgblog/http.go b/cmd/sgblog/http.go index de92039..1c09ae0 100644 --- a/cmd/sgblog/http.go +++ b/cmd/sgblog/http.go @@ -20,6 +20,7 @@ package main import ( "bytes" "compress/gzip" + "crypto/sha1" "encoding/hex" "encoding/json" "encoding/xml" @@ -46,7 +47,8 @@ import ( ) const ( - AtomFeed = "feed.atom" + AtomPostsFeed = "feed.atom" + AtomCommentsFeed = "comments.atom" ) var ( @@ -139,6 +141,13 @@ func checkETag(etag hash.Hash) { } } +func bytes2uuid(b []byte) string { + raw := new([16]byte) + copy(raw[:], b) + raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4 + return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:]) +} + func serveHTTP() { cfgPath := os.Getenv("SGBLOG_CFG") if cfgPath == "" { @@ -207,8 +216,8 @@ func serveHTTP() { } defaultLinks = append(defaultLinks, ``) - atomURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed - defaultLinks = append(defaultLinks, ``) + atomPostsURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed + atomCommentsURL := cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed headHash, err := initRepo(cfg) if err != nil { @@ -344,8 +353,11 @@ func serveHTTP() { table.WriteString("") var href string - var links []string + links := []string{``} var refs bytes.Buffer + if commentsTree != nil { + links = append(links, ``) + } if offset > 0 { if offsetPrev := offset - PageEntries; offsetPrev > 0 { href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev) @@ -373,23 +385,20 @@ func serveHTTP() { out.Write(table.Bytes()) out.Write(refs.Bytes()) out.Write([]byte("\n")) - } else if pathInfo == "/"+AtomFeed { + } else if pathInfo == "/"+AtomPostsFeed { commit, err := repo.CommitObject(*headHash) if err != nil { makeErr(err) } - etagHash.Write([]byte("ATOM")) + etagHash.Write([]byte("ATOM POSTS")) etagHash.Write(commit.Hash[:]) checkETag(etagHash) feed := atom.Feed{ Title: cfg.Title, ID: cfg.AtomId, Updated: atom.Time(commit.Author.When), - Link: []atom.Link{{ - Rel: "self", - Href: atomURL, - }}, - Author: &atom.Person{Name: cfg.AtomAuthor}, + Link: []atom.Link{{Rel: "self", Href: atomPostsURL}}, + Author: &atom.Person{Name: cfg.AtomAuthor}, } repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) if err != nil { @@ -400,33 +409,100 @@ func serveHTTP() { if err != nil { break } - - feedIdRaw := new([16]byte) - copy(feedIdRaw[:], commit.Hash[:]) - feedIdRaw[6] = (feedIdRaw[6] & 0x0F) | uint8(4<<4) // version 4 - feedId := fmt.Sprintf( - "%x-%x-%x-%x-%x", - feedIdRaw[0:4], - feedIdRaw[4:6], - feedIdRaw[6:8], - feedIdRaw[8:10], - feedIdRaw[10:], - ) - lines := msgSplit(commit.Message) feed.Entry = append(feed.Entry, &atom.Entry{ Title: lines[0], - ID: "urn:uuid:" + feedId, + ID: "urn:uuid:" + bytes2uuid(commit.Hash[:]), Link: []atom.Link{{ Rel: "alternate", Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(), }}, Published: atom.Time(commit.Author.When), Updated: atom.Time(commit.Author.When), - Summary: &atom.Text{ + Summary: &atom.Text{Type: "text", Body: lines[0]}, + Content: &atom.Text{ Type: "text", - Body: lines[0], + Body: strings.Join(lines[2:], "\n"), }, + }) + } + data, err := xml.MarshalIndent(&feed, "", " ") + if err != nil { + makeErr(err) + } + out.Write(data) + goto AtomFinish + } else if pathInfo == "/"+AtomCommentsFeed { + commit, err := repo.CommitObject(commentsRef.Hash()) + if err != nil { + makeErr(err) + } + etagHash.Write([]byte("ATOM COMMENTS")) + etagHash.Write(commit.Hash[:]) + checkETag(etagHash) + idHasher, err := blake2b.New256(nil) + if err != nil { + panic(err) + } + idHasher.Write([]byte("ATOM COMMENTS")) + idHasher.Write([]byte(cfg.AtomId)) + feed := atom.Feed{ + Title: cfg.Title + " comments", + ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)), + Updated: atom.Time(commit.Author.When), + Link: []atom.Link{{Rel: "self", Href: atomCommentsURL}}, + Author: &atom.Person{Name: cfg.AtomAuthor}, + } + repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()}) + if err != nil { + makeErr(err) + } + for i := 0; i < PageEntries; i++ { + commit, err = repoLog.Next() + if err != nil { + break + } + fileStats, err := commit.Stats() + if err != nil { + makeErr(err) + } + t, err := commit.Tree() + if err != nil { + makeErr(err) + } + commentedHash := plumbing.NewHash(strings.ReplaceAll( + fileStats[0].Name, "/", "", + )) + comments := parseComments(getNote(t, commentedHash)) + commit, err = repo.CommitObject(commentedHash) + if err != nil { + makeErr(err) + } + commentN := strconv.Itoa(len(comments) - 1) + lines := strings.Split(comments[len(comments)-1], "\n") + from := strings.TrimPrefix(lines[0], "From: ") + date := strings.TrimPrefix(lines[1], "Date: ") + idHasher.Reset() + idHasher.Write([]byte("COMMENT")) + idHasher.Write(commit.Hash[:]) + idHasher.Write([]byte(commentN)) + feed.Entry = append(feed.Entry, &atom.Entry{ + Title: strings.Join([]string{ + "Comment ", commentN, + " for \"", msgSplit(commit.Message)[0], + "\" by ", from, + }, ""), + Author: &atom.Person{Name: from}, + ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)), + Link: []atom.Link{{ + Rel: "alternate", + Href: strings.Join([]string{ + cfg.AtomBaseURL, cfg.URLPrefix, "/", + commit.Hash.String(), "#comment", commentN, + }, ""), + }}, + Published: atom.TimeStr(date), + Updated: atom.TimeStr(date), Content: &atom.Text{ Type: "text", Body: strings.Join(lines[2:], "\n"), @@ -438,17 +514,9 @@ func serveHTTP() { makeErr(err) } out.Write(data) - os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n") - os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n") - if gzipWriter != nil { - os.Stdout.WriteString("Content-Encoding: gzip\n") - gzipWriter.Close() - } - os.Stdout.WriteString("\n") - os.Stdout.Write(outBuf.Bytes()) - return + goto AtomFinish } else if sha1DigestRe.MatchString(pathInfo[1:]) { - commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:])) + commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2])) if err != nil { makeErr(err) } @@ -457,16 +525,99 @@ func serveHTTP() { } etagHash.Write([]byte("ENTRY")) etagHash.Write(commit.Hash[:]) - notesRaw := getNote(notesTree, commit.Hash) - etagHash.Write(notesRaw) + atomCommentsURL = strings.Join([]string{ + cfg.AtomBaseURL, cfg.URLPrefix, "/", + commit.Hash.String(), "/", AtomCommentsFeed, + }, "") commentsRaw := getNote(commentsTree, commit.Hash) etagHash.Write(commentsRaw) + if strings.HasSuffix(pathInfo, AtomCommentsFeed) { + etagHash.Write([]byte("ATOM COMMENTS")) + checkETag(etagHash) + type Comment struct { + n string + from string + date string + body []string + } + commentsRaw := parseComments(commentsRaw) + var toSkip int + if len(commentsRaw) > PageEntries { + toSkip = len(commentsRaw) - PageEntries + } + comments := make([]Comment, 0, len(commentsRaw)-toSkip) + for i := len(commentsRaw) - 1; i >= toSkip; i-- { + lines := strings.Split(commentsRaw[i], "\n") + from := strings.TrimPrefix(lines[0], "From: ") + date := strings.TrimPrefix(lines[1], "Date: ") + comments = append(comments, Comment{ + n: strconv.Itoa(i), + from: from, + date: strings.Replace(date, " ", "T", 1), + body: lines[3:], + }) + } + idHasher, err := blake2b.New256(nil) + if err != nil { + panic(err) + } + idHasher.Write([]byte("ATOM COMMENTS")) + idHasher.Write(commit.Hash[:]) + feed := atom.Feed{ + Title: fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]), + ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)), + Link: []atom.Link{{Rel: "self", Href: atomCommentsURL}}, + Author: &atom.Person{Name: cfg.AtomAuthor}, + } + if len(comments) > 0 { + feed.Updated = atom.TimeStr(comments[0].date) + } else { + feed.Updated = atom.Time(commit.Author.When) + } + for _, comment := range comments { + idHasher.Reset() + idHasher.Write([]byte("COMMENT")) + idHasher.Write(commit.Hash[:]) + idHasher.Write([]byte(comment.n)) + feed.Entry = append(feed.Entry, &atom.Entry{ + Title: strings.Join([]string{ + "Comment", comment.n, + "by", comment.from, + }, " "), + Author: &atom.Person{Name: comment.from}, + ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)), + Link: []atom.Link{{ + Rel: "alternate", + Href: strings.Join([]string{ + cfg.AtomBaseURL, + cfg.URLPrefix, "/", + commit.Hash.String(), + "#comment", comment.n, + }, ""), + }}, + Published: atom.TimeStr(comment.date), + Updated: atom.TimeStr(comment.date), + Content: &atom.Text{ + Type: "text", + Body: strings.Join(comment.body, "\n"), + }, + }) + } + data, err := xml.MarshalIndent(&feed, "", " ") + if err != nil { + makeErr(err) + } + out.Write(data) + goto AtomFinish + } + notesRaw := getNote(notesTree, commit.Hash) + etagHash.Write(notesRaw) checkETag(etagHash) lines := msgSplit(commit.Message) title := lines[0] when := commit.Author.When.Format(sgblog.WhenFmt) os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) - links := []string{} + links := []string{``} var parent string if len(commit.ParentHashes) > 0 { parent = commit.ParentHashes[0].String() @@ -524,4 +675,15 @@ func serveHTTP() { gzipWriter.Close() } os.Stdout.Write(outBuf.Bytes()) + return + +AtomFinish: + os.Stdout.WriteString("Content-Type: text/xml; charset=UTF-8\n") + os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n") + if gzipWriter != nil { + os.Stdout.WriteString("Content-Encoding: gzip\n") + gzipWriter.Close() + } + os.Stdout.WriteString("\n") + os.Stdout.Write(outBuf.Bytes()) } diff --git a/cmd/sgblog/main.go b/cmd/sgblog/main.go index 9b03e4c..05654c0 100644 --- a/cmd/sgblog/main.go +++ b/cmd/sgblog/main.go @@ -20,6 +20,8 @@ package main import ( "bytes" + "crypto/sha1" + "fmt" "io/ioutil" "os" "regexp" @@ -36,9 +38,10 @@ const ( ) var ( - sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})") + sha1DigestRe = regexp.MustCompilePOSIX(fmt.Sprintf("([0-9a-f]{%d,%d})", sha1.Size*2, sha1.Size*2)) repo *git.Repository notesTree *object.Tree + commentsRef *plumbing.Reference commentsTree *object.Tree ) @@ -134,7 +137,6 @@ func initRepo(cfg *Cfg) (*plumbing.Hash, error) { headHash := head.Hash() if notes, err := repo.Notes(); err == nil { var notesRef *plumbing.Reference - var commentsRef *plumbing.Reference notes.ForEach(func(ref *plumbing.Reference) error { switch string(ref.Name()) { case "refs/notes/commits": diff --git a/common.go b/common.go index 97b29f7..ca7d04f 100644 --- a/common.go +++ b/common.go @@ -2,6 +2,6 @@ package sgblog const ( - Version = "0.5.0" + Version = "0.6.0" WhenFmt = "2006-01-02 15:04:05Z07:00" ) -- 2.44.0