README.texi | 1 + cmd/sgblog/http.go | 242 ++++++++++++++++++++++++++++++++++++++++++++--------- cmd/sgblog/main.go | 6 ++++-- common.go | 2 +- diff --git a/README.texi b/README.texi index 47705aa9f7a4f6f4e1f12df8b10f135b57df945a003806126e8ca40014f7a01b..3b0c07af757512beb8ba49d91d6dba0ff477df8ae7b04d8a82f8fc082210c6c7 100644 --- a/README.texi +++ b/README.texi @@ -27,6 +27,7 @@ for working as @url{https://en.wikipedia.org/wiki/Gopher_(protocol), Gopher} 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 f44b5d922f870268d81e349fb77012eec4a173f2484031a2e7d7d78d5766b25d..209c4c6387b09b4b443d697fccb50bcf7de08477846b1fcdea37d8ccb94646cd 100644 --- a/cmd/sgblog/http.go +++ b/cmd/sgblog/http.go @@ -20,6 +20,7 @@ import ( "bytes" "compress/gzip" + "crypto/sha1" "encoding/hex" "encoding/json" "encoding/xml" @@ -46,7 +47,8 @@ "gopkg.in/src-d/go-git.v4/plumbing/object" ) const ( - AtomFeed = "feed.atom" + AtomPostsFeed = "feed.atom" + AtomCommentsFeed = "comments.atom" ) var ( @@ -139,6 +141,13 @@ os.Exit(0) } } +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 @@ etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail)) } 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 @@ } 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 @@ out.Write(refs.Bytes()) 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 @@ commit, err = repoLog.Next() 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 @@ if err != nil { 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 @@ etagHash.Write(data) } etagHash.Write([]byte("ENTRY")) etagHash.Write(commit.Hash[:]) + 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) - commentsRaw := getNote(commentsTree, commit.Hash) - etagHash.Write(commentsRaw) 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() @@ -523,5 +674,16 @@ out.Write([]byte("\n")) if gzipWriter != nil { 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 961ee2c09e904a5ae9acfbc450580bdddbffe14bcfc0c9349ff29b58957918e5..086c716ebccf5b0486532518c3c131684f240ee320678452f869914cf49c1e69 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 @@ PageEntries = 50 ) 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 @@ } 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 ae6da717f29b968494eae71fce1f4158b9d77f474e84d154bd1f097290fc36ec..cc245a43bd273b1b911e512bacf4274811762b93353ef61bf43b921fee19432c 100644 --- a/common.go +++ b/common.go @@ -2,6 +2,6 @@ // SGBlog -- Git-backed CGI/inetd blogging/phlogging engine package sgblog const ( - Version = "0.5.0" + Version = "0.6.0" WhenFmt = "2006-01-02 15:04:05Z07:00" )