From: Sergey Matveev Date: Tue, 14 Jan 2020 19:26:54 +0000 (+0300) Subject: Comments and refactoring X-Git-Tag: v0.1.0^0 X-Git-Url: http://www.git.stargrave.org/?a=commitdiff_plain;h=f087d219aaf93b57e0da08ff78bcd5935351f786;p=sgblog.git Comments and refactoring * Hjson configuration * No github.com/google/uuid dependency --- diff --git a/README b/README index 41ce3ca..20fd5fd 100644 --- a/README +++ b/README @@ -14,38 +14,47 @@ better features: * Each page has ETag and it is checked against the request for client-side caching * Pages can be gzip-compressed, depending on Accept-Encoding header +* Commenting support CONFIGURATION -SGBlog is configured via environment variables: -* SGBLOG_GIT_PATH points to .git directory you want to serve -* SGBLOG_BRANCH points to the branch in it (refs/heads/master for example) -* SGBLOG_BASE_URL points to full URL before the possible SGBLOG_URL_PREFIX -* SGBLOG_TITLE sets the index title -* SGBLOG_ATOM_ID sets Atom feed's id -* SGBLOG_ATOM_AUTHOR sets Atom feed's author name -* If SGBLOG_URL_PREFIX is set, then all link will be prefixed with that URL -* If SGBLOG_CSS is set, then link to that CSS URL will be generated -* If SGBLOG_WEBMASTER is set, then "made" link well be generated -* If SGBLOG_ABOUT is set, then about link is generated at the bottom -* If SGBLOG_GIT_URLS is set, then links to that vcs-git space-separated - URLs will be generated - -Example lighttpd's configuration: - - setenv.add-environment = ( - "SGBLOG_GIT_PATH" => "/home/git/pub/stargrave-blog.git", - "SGBLOG_BRANCH" => "refs/heads/russian", - "SGBLOG_BASE_URL" => "http://blog.stargrave.org", - "SGBLOG_TITLE" => "Russian Stargrave's blog", - "SGBLOG_ATOM_ID" => "urn:uuid:e803a056-1147-44d4-9332-5190cb78cc3d", - "SGBLOG_ATOM_AUTHOR" => "Sergey Matveev", - "SGBLOG_URL_PREFIX" => "/russian", - "SGBLOG_WEBMASTER" => "webmaster@stargrave.org", - "SGBLOG_CSS" => "http://blog.stargrave.org/style.css", - "SGBLOG_GIT_URLS" => "git://git.stargrave.org/stargrave-blog.git https://git.stargrave.org/git/stargrave-blog.git", - "SGBLOG_ABOUT" => "http://blog.stargrave.org/", - ) +SGBlog is configured via Hjson configuration file: + + { + GitPath: /home/git/pub/stargrave-blog.git + Branch: refs/heads/english + Title: "English Stargrave's blog" + + BaseURL: http://blog.stargrave.org + URLPrefix: /english + + AtomId: "urn:uuid:18e2f27c-a668-4e85-822e-b902376be5e3" + AtomAuthor: Sergey Matveev + + # URL to CSS file, optional + CSS: /style.css + # Email address of the webmaster, optional + Webmaster: "webmaster@example.com" + # URL to about page, optional + AboutURL: / + # Optional list of optional Git URLs for corresponding + GitURLs: [ + git://git.stargrave.org/stargrave-blog.git + https://git.stargrave.org/git/stargrave-blog.git + ] + + # If that ref is set, then comments will be loaded from it + CommentsNotesRef: refs/notes/comments + # Display link for comment writing, if email is set + CommentsEmail: something@example.com + } + +COMMENTS + +Each comment is just a plaintext with From and Date headers. They are +stored in concatenated netstring serialized format as a Git note to +corresponding commit. They are added through email by feeding email +message to sgblog-comment-add. LICENCE diff --git a/cmd/sgblog-comment-add/mail.go b/cmd/sgblog-comment-add/mail.go new file mode 100644 index 0000000..363e983 --- /dev/null +++ b/cmd/sgblog-comment-add/mail.go @@ -0,0 +1,142 @@ +/* +SGBlog -- Git-based CGI blogging engine +Copyright (C) 2020 Sergey Matveev + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +package main + +import ( + "bytes" + "encoding/base64" + "errors" + "io" + "io/ioutil" + "mime" + "mime/multipart" + "mime/quotedprintable" + "net/mail" + "strings" +) + +const ( + CT = "Content-Type" + CTE = "Content-Transfer-Encoding" + TP = "text/plain" +) + +func processTP(ct, cte string, body io.Reader) (io.Reader, error) { + _, params, err := mime.ParseMediaType(ct) + if err != nil { + return nil, err + } + if c := params["charset"]; !(c == "" || c == "utf-8" || c == "iso-8859-1" || c == "us-ascii") { + return nil, errors.New("only utf-8/iso-8859-1/us-ascii charsets supported") + } + switch cte { + case "quoted-printable": + return quotedprintable.NewReader(body), nil + case "base64": + return base64.NewDecoder(base64.StdEncoding, body), nil + } + return body, nil +} + +func parseEmail(msg *mail.Message) (subj string, body io.Reader, err error) { + subj = msg.Header.Get("Subject") + if subj == "" { + err = errors.New("no Subject") + return + } + words := strings.Fields(subj) + for i, word := range words { + if strings.HasPrefix(word, "=?") && strings.HasSuffix(word, "?=") { + word, err = new(mime.WordDecoder).Decode(word) + if err != nil { + return + } + words[i] = word + } + } + subj = strings.Join(words, " ") + + ct := msg.Header.Get(CT) + if ct == "" { + ct = "text/plain" + } + if strings.HasPrefix(ct, TP) { + body, err = processTP(ct, msg.Header.Get(CTE), msg.Body) + return + } + ct, params, err := mime.ParseMediaType(ct) + if ct != "multipart/signed" { + err = errors.New("only text/plain and multipart/signed+text/plain Content-Type supported") + return + } + boundary := params["boundary"] + if len(boundary) == 0 { + err = errors.New("no boundary string") + return + } + data, err := ioutil.ReadAll(msg.Body) + if err != nil { + return + } + boundaryIdx := bytes.Index(data, []byte("--"+boundary)) + if boundaryIdx == -1 { + err = errors.New("no boundary found") + return + } + mpr := multipart.NewReader(bytes.NewReader(data[boundaryIdx:]), boundary) + var part *multipart.Part + for { + part, err = mpr.NextPart() + if err != nil { + if err == io.EOF { + break + } + return + } + ct = part.Header.Get(CT) + if strings.HasPrefix(ct, TP) { + body, err = processTP(ct, part.Header.Get(CTE), part) + return + } + if strings.HasPrefix(ct, "multipart/mixed") { + ct, params, err = mime.ParseMediaType(ct) + boundary = params["boundary"] + if len(boundary) == 0 { + err = errors.New("no boundary string") + return + } + mpr := multipart.NewReader(part, boundary) + for { + part, err = mpr.NextPart() + if err != nil { + if err == io.EOF { + break + } + return + } + ct = part.Header.Get(CT) + if strings.HasPrefix(ct, TP) { + body, err = processTP(ct, part.Header.Get(CTE), part) + return + } + } + } + } + err = errors.New("no text/plain part found") + return +} diff --git a/cmd/sgblog-comment-add/main.go b/cmd/sgblog-comment-add/main.go new file mode 100644 index 0000000..b52e68a --- /dev/null +++ b/cmd/sgblog-comment-add/main.go @@ -0,0 +1,119 @@ +/* +SGBlog -- Git-based CGI blogging engine +Copyright (C) 2020 Sergey Matveev + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as +published by the Free Software Foundation, version 3 of the License. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . +*/ + +// Git-based CGI blogging engine email to comments adder +package main + +import ( + "bytes" + "crypto/sha1" + "encoding/hex" + "flag" + "fmt" + "io/ioutil" + "log" + "net/mail" + "os" + "os/exec" + "strconv" + "strings" + "syscall" + "time" + + "go.cypherpunks.ru/netstring/v2" +) + +const WhenFmt = "2006-01-02 15:04:05Z07:00" + +func main() { + gitCmd := flag.String("git-cmd", "/usr/local/bin/git", "Path to git executable") + gitDir := flag.String("git-dir", "", "Path to .git repository") + notesRef := flag.String("ref", "comments", "notes reference name") + umask := flag.String("umask", "027", "umask value") + flag.Parse() + uid := syscall.Geteuid() + if err := syscall.Setuid(uid); err != nil { + log.Fatal(err) + } + umaskInt, err := strconv.ParseUint(*umask, 8, 16) + if err != nil { + panic(err) + } + syscall.Umask(int(umaskInt)) + + msg, err := mail.ReadMessage(os.Stdin) + if err != nil { + log.Fatal(err) + } + subj, r, err := parseEmail(msg) + if err != nil { + log.Fatal(err) + } + body, err := ioutil.ReadAll(r) + if err != nil { + log.Fatal(err) + } + from := msg.Header.Get("From") + if from == "" { + log.Fatal("From is missing") + } + if len(body) == 0 { + log.Fatal("no body") + } + + if h, err := hex.DecodeString(subj); err != nil || len(h) != sha1.Size { + os.Exit(0) + } + fromCols := strings.Fields(from) + from = strings.Join(fromCols[:len(fromCols)-1], " ") + + cmd := exec.Command( + *gitCmd, "--git-dir", *gitDir, + "notes", "--ref", *notesRef, "show", subj, + ) + note, _ := cmd.Output() + note = bytes.TrimSuffix(note, []byte{'\n'}) + + // Remove trailing whitespaces, because git-notes-add will remove + // them anyway, and we have to know exact bytes count + lines := strings.Split(string(body), "\n") + for i, line := range lines { + lines[i] = strings.TrimRight(line, " ") + } + for lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + + buf := bytes.NewBuffer(note) + w := netstring.NewWriter(buf) + w.WriteChunk([]byte(fmt.Sprintf( + "From: %s\nDate: %s\n\n%s", + from, + time.Now().Format(WhenFmt), + strings.Join(lines, "\n"), + ))) + + cmd = exec.Command( + *gitCmd, "--git-dir", *gitDir, + "notes", "--ref", *notesRef, "add", + "-F", "-", "-f", subj, + ) + cmd.Stdin = buf + if err = cmd.Run(); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/sgblog/main.go b/cmd/sgblog/main.go index 0987bac..665e7f3 100644 --- a/cmd/sgblog/main.go +++ b/cmd/sgblog/main.go @@ -22,19 +22,23 @@ import ( "bytes" "compress/gzip" "encoding/hex" + "encoding/json" "encoding/xml" "errors" "fmt" "hash" + "html" "io" "io/ioutil" + "log" "net/url" "os" "regexp" "strconv" "strings" - "github.com/google/uuid" + "github.com/hjson/hjson-go" + "go.cypherpunks.ru/netstring/v2" "golang.org/x/crypto/blake2b" "golang.org/x/tools/blog/atom" "gopkg.in/src-d/go-git.v4" @@ -49,23 +53,47 @@ const ( ) var ( - Version = "0.0.2" - ETagVersion = []byte("2") + Version = "0.1.0" sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})") defaultLinks = []string{} repo *git.Repository commentsTree *object.Tree renderableSchemes = map[string]struct{}{ - "http": struct{}{}, - "https": struct{}{}, "ftp": struct{}{}, "gopher": struct{}{}, + "http": struct{}{}, + "https": struct{}{}, } ) +type TableEntry struct { + commit *object.Commit + commentsRaw []byte +} + +type Cfg struct { + GitPath string + Branch string + Title string + + BaseURL string + URLPrefix string + + AtomId string + AtomAuthor string + + CSS string + Webmaster string + AboutURL string + GitURLs []string + + CommentsNotesRef string + CommentsEmail string +} + func makeA(href, text string) string { - return fmt.Sprintf(`%s`, href, text) + return `` + text + `` } func etagString(etag hash.Hash) string { @@ -90,27 +118,41 @@ func msgSplit(msg string) []string { return lines } -func getNote(what plumbing.Hash) string { +func getCommentsRaw(what plumbing.Hash) []byte { if commentsTree == nil { - return "" + return nil } entry, err := commentsTree.FindEntry(what.String()) if err != nil { - return "" + return nil } blob, err := repo.BlobObject(entry.Hash) if err != nil { - return "" + return nil } r, err := blob.Reader() if err != nil { - return "" + return nil } data, err := ioutil.ReadAll(r) if err != nil { - return "" + return nil } - return string(data) + return bytes.TrimSuffix(data, []byte{'\n'}) +} + +func parseComments(data []byte) []string { + comments := []string{} + nsr := netstring.NewReader(bytes.NewReader(data)) + for { + if _, err := nsr.Next(); err != nil { + break + } + if comment, err := ioutil.ReadAll(nsr); err == nil { + comments = append(comments, string(comment)) + } + } + return comments } func startHeader(etag hash.Hash, gziped bool) string { @@ -142,7 +184,7 @@ func startHTML(title string, additional []string) string { } func makeErr(err error) { - fmt.Println("Content-Type: text/plain; charset=UTF-8\n") + fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n") fmt.Println(err) panic(err) } @@ -150,124 +192,104 @@ func makeErr(err error) { func checkETag(etag hash.Hash) { ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH") if ifNoneMatch != "" && ifNoneMatch == etagString(etag) { - fmt.Println("Status: 304\nETag:", ifNoneMatch, "\n") + fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch) os.Exit(0) } } func main() { - gitPath, exists := os.LookupEnv("SGBLOG_GIT_PATH") - if !exists { - makeErr(errors.New("SGBLOG_GIT_PATH is unset")) + cfgPath := os.Getenv("SGBLOG_CFG") + if cfgPath == "" { + log.Fatalln("SGBLOG_CFG is not set") } - branchName, exists := os.LookupEnv("SGBLOG_BRANCH") + pathInfo, exists := os.LookupEnv("PATH_INFO") if !exists { - makeErr(errors.New("SGBLOG_BRANCH is unset")) + pathInfo = "/" } - blogBaseURL, exists := os.LookupEnv("SGBLOG_BASE_URL") - if !exists { - makeErr(errors.New("SGBLOG_BASE_URL is unset")) + queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING")) + if err != nil { + makeErr(err) } - blogTitle, exists := os.LookupEnv("SGBLOG_TITLE") - if !exists { - makeErr(errors.New("SGBLOG_TITLE is unset")) + + cfgRaw, err := ioutil.ReadFile(cfgPath) + if err != nil { + makeErr(err) } - atomId, exists := os.LookupEnv("SGBLOG_ATOM_ID") - if !exists { - makeErr(errors.New("SGBLOG_ATOM_ID is unset")) + var cfgGeneral map[string]interface{} + if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil { + makeErr(err) } - atomAuthorName, exists := os.LookupEnv("SGBLOG_ATOM_AUTHOR") - if !exists { - makeErr(errors.New("SGBLOG_ATOM_AUTHOR is unset")) + cfgRaw, err = json.Marshal(cfgGeneral) + if err != nil { + makeErr(err) + } + var cfg *Cfg + if err = json.Unmarshal(cfgRaw, &cfg); err != nil { + makeErr(err) } etagHash, err := blake2b.New256(nil) if err != nil { panic(err) } - etagHash.Write(ETagVersion) - etagHash.Write([]byte(gitPath)) - etagHash.Write([]byte(branchName)) - etagHash.Write([]byte(blogBaseURL)) - etagHash.Write([]byte(blogTitle)) - etagHash.Write([]byte(atomId)) - etagHash.Write([]byte(atomAuthorName)) - - // SGBLOG_URL_PREFIX - urlPrefix := os.Getenv("SGBLOG_URL_PREFIX") - etagHash.Write([]byte(urlPrefix)) - - // SGBLOG_CSS - if cssUrl, exists := os.LookupEnv("SGBLOG_CSS"); exists { - defaultLinks = append(defaultLinks, fmt.Sprintf( - ``, - cssUrl, - )) - etagHash.Write([]byte(cssUrl)) - } - - // SGBLOG_WEBMASTER - if webmaster, exists := os.LookupEnv("SGBLOG_WEBMASTER"); exists { - defaultLinks = append(defaultLinks, fmt.Sprintf( - ``, - webmaster, - )) - etagHash.Write([]byte(webmaster)) - } - - // SGBLOG_ABOUT - aboutUrl := os.Getenv("SGBLOG_ABOUT") - etagHash.Write([]byte(aboutUrl)) - - // SGBLOG_GIT_URLS - if gitUrls, exists := os.LookupEnv("SGBLOG_GIT_URLS"); exists { - for _, gitUrl := range strings.Split(gitUrls, " ") { - defaultLinks = append(defaultLinks, fmt.Sprintf( - ``, - gitUrl, - )) - } - etagHash.Write([]byte(gitUrls)) - } - - defaultLinks = append(defaultLinks, fmt.Sprintf( - ``, - urlPrefix, - )) - atomUrl := blogBaseURL + urlPrefix + "/" + AtomFeed - defaultLinks = append(defaultLinks, fmt.Sprintf( - ``, - atomUrl, - )) + etagHash.Write([]byte("SGBLOG")) + etagHash.Write([]byte(cfg.GitPath)) + etagHash.Write([]byte(cfg.Branch)) + etagHash.Write([]byte(cfg.Title)) + etagHash.Write([]byte(cfg.BaseURL)) + etagHash.Write([]byte(cfg.URLPrefix)) + etagHash.Write([]byte(cfg.AtomId)) + etagHash.Write([]byte(cfg.AtomAuthor)) - pathInfo, exists := os.LookupEnv("PATH_INFO") - if !exists { - pathInfo = "/" + etagHashForWeb := [][]byte{} + if cfg.CSS != "" { + defaultLinks = append(defaultLinks, ``) + etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS)) } - queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING")) - if err != nil { - makeErr(err) + if cfg.Webmaster != "" { + defaultLinks = append(defaultLinks, ``) + etagHashForWeb = append(etagHashForWeb, []byte(cfg.Webmaster)) } + if cfg.AboutURL != "" { + etagHashForWeb = append(etagHashForWeb, []byte(cfg.AboutURL)) + } + for _, gitURL := range cfg.GitURLs { + defaultLinks = append(defaultLinks, ``) + etagHashForWeb = append(etagHashForWeb, []byte(gitURL)) + } + if cfg.CommentsNotesRef != "" { + etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsNotesRef)) + } + if cfg.CommentsEmail != "" { + etagHashForWeb = append(etagHashForWeb, []byte(cfg.CommentsEmail)) + } + + defaultLinks = append(defaultLinks, ``) + atomURL := cfg.BaseURL + cfg.URLPrefix + "/" + AtomFeed + defaultLinks = append(defaultLinks, ``) - repo, err = git.PlainOpen(gitPath) + repo, err = git.PlainOpen(cfg.GitPath) if err != nil { makeErr(err) } - head, err := repo.Reference(plumbing.ReferenceName(branchName), false) + head, err := repo.Reference(plumbing.ReferenceName(cfg.Branch), false) if err != nil { makeErr(err) } - if notes, err := repo.Notes(); err == nil { - var comments *plumbing.Reference - notes.ForEach(func(ref *plumbing.Reference) error { - if ref.Name() == "refs/notes/commits" { - comments = ref - } - return nil - }) - if comments != nil { - if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil { - commentsTree, _ = commentsCommit.Tree() + + if cfg.CommentsNotesRef != "" { + if notes, err := repo.Notes(); err == nil { + var comments *plumbing.Reference + notes.ForEach(func(ref *plumbing.Reference) error { + if string(ref.Name()) == cfg.CommentsNotesRef { + comments = ref + } + return nil + }) + if comments != nil { + if commentsCommit, err := repo.CommitObject(comments.Hash()); err == nil { + commentsTree, _ = commentsCommit.Tree() + } } } } @@ -284,7 +306,6 @@ func main() { } } - var commit *object.Commit if pathInfo == "/" { offset := 0 if offsetRaw, exists := queryValues["offset"]; exists { @@ -293,30 +314,49 @@ func main() { makeErr(err) } } - var table bytes.Buffer - table.WriteString("\n\n") log, err := repo.Log(&git.LogOptions{From: head.Hash()}) if err != nil { makeErr(err) } - errOccured := false + commentN := 0 for i := 0; i < offset; i++ { - commit, err = log.Next() - if err != nil { + if _, err = log.Next(); err != nil { break } + commentN++ + } + + entries := make([]TableEntry, 0, PageEntries) + logEnded := false + for _, data := range etagHashForWeb { + etagHash.Write(data) } + etagHash.Write([]byte("INDEX")) for i := 0; i < PageEntries; i++ { - commit, err = log.Next() + commit, err := log.Next() if err != nil { - errOccured = true + logEnded = true break } - if i == 0 { - etagHash.Write(commit.Hash[:]) - checkETag(etagHash) - } - lines := msgSplit(commit.Message) + etagHash.Write(commit.Hash[:]) + commentsRaw := getCommentsRaw(commit.Hash) + etagHash.Write(commentsRaw) + entries = append(entries, TableEntry{commit, commentsRaw}) + } + checkETag(etagHash) + + var table bytes.Buffer + table.WriteString( + "
WhenTitleComment of
\n" + + "" + + "" + + "" + + "" + + "" + + "\n") + for _, entry := range entries { + commentN++ + lines := msgSplit(entry.commit.Message) domains := []string{} for _, line := range lines[2:] { if u := urlParse(line); u == nil { @@ -325,67 +365,73 @@ func main() { domains = append(domains, makeA(line, u.Host)) } } - entry := []string{ - makeA(urlPrefix+"/"+commit.Hash.String(), lines[0]), - fmt.Sprintf("(%dL)", len(lines)-2), - } - if note := getNote(commit.Hash); note != "" { - entry = append(entry, "(N)") + var commentsValue string + if l := len(parseComments(entry.commentsRaw)); l > 0 { + commentsValue = strconv.Itoa(l) + } else { + commentsValue = " " } table.WriteString(fmt.Sprintf( - "\n", - commit.Author.When.Format(WhenFmt), - strings.Join(entry, " "), + ""+ + ""+ + ""+ + "\n", + commentN, entry.commit.Author.When.Format(WhenFmt), + makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]), + len(lines)-2, + commentsValue, strings.Join(domains, " "), )) } table.WriteString("
NWhenTitleLCLinked to
%s%s%s
%d%s%s%d%s%s
") + + var href string var links []string var refs bytes.Buffer if offset > 0 { - offsetPrev := offset - PageEntries - if offsetPrev < 0 { - offsetPrev = 0 + if offsetPrev := offset - PageEntries; offsetPrev > 0 { + href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev) + } else { + href = cfg.URLPrefix + "/" } - href := urlPrefix + "/?offset=" + strconv.Itoa(offsetPrev) - links = append(links, fmt.Sprintf( - ``, href, - )) - refs.WriteString(makeA(href, "[prev]")) + links = append(links, ``) + refs.WriteString(makeA(href, " [prev]")) } - if !errOccured { - href := urlPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries) - links = append(links, fmt.Sprintf( - ``, href, - )) - refs.WriteString(makeA(href, "[next]")) + if !logEnded { + href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries) + links = append(links, ``) + refs.WriteString(makeA(href, " [next]")) } + os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) out.Write([]byte(startHTML( - fmt.Sprintf("%s (%d-%d)", blogTitle, offset, offset+PageEntries), + fmt.Sprintf("%s (%d-%d)", cfg.Title, offset, offset+PageEntries), links, ))) + if cfg.AboutURL != "" { + out.Write([]byte(fmt.Sprintf("[%s]", makeA(cfg.AboutURL, "about")))) + } out.Write(refs.Bytes()) out.Write(table.Bytes()) out.Write(refs.Bytes()) out.Write([]byte("\n")) } else if pathInfo == "/"+AtomFeed { - commit, err = repo.CommitObject(head.Hash()) + commit, err := repo.CommitObject(head.Hash()) if err != nil { makeErr(err) } - etagHash.Write(commit.Hash[:]) etagHash.Write([]byte("ATOM")) + etagHash.Write(commit.Hash[:]) checkETag(etagHash) feed := atom.Feed{ - Title: blogTitle, - ID: atomId, + Title: cfg.Title, + ID: cfg.AtomId, Updated: atom.Time(commit.Author.When), Link: []atom.Link{{ Rel: "self", - Href: atomUrl, + Href: atomURL, }}, - Author: &atom.Person{Name: atomAuthorName}, + Author: &atom.Person{Name: cfg.AtomAuthor}, } log, err := repo.Log(&git.LogOptions{From: head.Hash()}) if err != nil { @@ -396,17 +442,26 @@ func main() { 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) - feedId, err := uuid.FromBytes(commit.Hash[:16]) - if err != nil { - panic(err) - } feed.Entry = append(feed.Entry, &atom.Entry{ Title: lines[0], - ID: "urn:uuid:" + feedId.String(), + ID: "urn:uuid:" + feedId, Link: []atom.Link{{ Rel: "alternate", - Href: blogBaseURL + urlPrefix + "/" + commit.Hash.String(), + Href: cfg.BaseURL + cfg.URLPrefix + "/" + commit.Hash.String(), }}, Published: atom.Time(commit.Author.When), Updated: atom.Time(commit.Author.When), @@ -435,11 +490,17 @@ func main() { os.Stdout.Write(outBuf.Bytes()) return } else if sha1DigestRe.MatchString(pathInfo[1:]) { - commit, err = repo.CommitObject(plumbing.NewHash(pathInfo[1:])) + commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1:])) if err != nil { makeErr(err) } + for _, data := range etagHashForWeb { + etagHash.Write(data) + } + etagHash.Write([]byte("ENTRY")) etagHash.Write(commit.Hash[:]) + commentsRaw := getCommentsRaw(commit.Hash) + etagHash.Write(commentsRaw) checkETag(etagHash) lines := msgSplit(commit.Message) title := lines[0] @@ -449,49 +510,56 @@ func main() { var parent string if len(commit.ParentHashes) > 0 { parent = commit.ParentHashes[0].String() - links = append(links, fmt.Sprintf( - ``, - urlPrefix+"/"+parent, - )) + links = append(links, ``) } out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links))) + if cfg.AboutURL != "" { + out.Write([]byte(fmt.Sprintf("[%s] ", makeA(cfg.AboutURL, "about")))) + } if parent != "" { out.Write([]byte(fmt.Sprintf( - "[%s] [%s]\n
\n", - makeA(urlPrefix+"/"+parent, "older"), - when, + "[%s] ", + makeA(cfg.URLPrefix+"/"+parent, "older"), ))) } - out.Write([]byte(fmt.Sprintf("

%s

\n
\n", title)))
+		out.Write([]byte(fmt.Sprintf(
+			"[%s] [%s]
\n

%s

\n
\n",
+			when, commit.Hash.String(), title,
+		)))
 		for _, line := range lines[2:] {
-			line = strings.ReplaceAll(line, "&", "&")
-			line = strings.ReplaceAll(line, "<", "<")
-			line = strings.ReplaceAll(line, ">", ">")
+			line = html.EscapeString(line)
 			cols := strings.Split(line, " ")
 			for i, col := range cols {
 				if u := urlParse(col); u != nil {
 					cols[i] = makeA(col, col)
 					continue
 				}
-				cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
+				cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(
+					cfg.URLPrefix+"/$1", "$1",
+				))
 			}
 			line = strings.Join(cols, " ")
 			out.Write([]byte(line + "\n"))
 		}
-		out.Write([]byte("
\n")) - if note := getNote(commit.Hash); note != "" { - out.Write([]byte(fmt.Sprintf("Note:\n
\n%s
\n", note))) + out.Write([]byte("
\n
\n")) + if cfg.CommentsEmail != "" { + out.Write([]byte("[" + makeA( + "mailto:"+cfg.CommentsEmail+"?subject="+commit.Hash.String(), + "write comment", + ) + "]\n")) } + out.Write([]byte("
\n")) + for i, comment := range parseComments(commentsRaw) { + out.Write([]byte(fmt.Sprintf( + "
comment %d:"+ + "
\n
\n%s\n
\n", + i, i, i, html.EscapeString(comment), + ))) + } + out.Write([]byte("
\n")) } else { makeErr(errors.New("unknown URL action")) } - if aboutUrl != "" { - out.Write([]byte(fmt.Sprintf( - "
%s %s\n", - makeA(aboutUrl, "About"), - blogTitle, - ))) - } out.Write([]byte("\n")) if gzipWriter != nil { gzipWriter.Close() diff --git a/go.mod b/go.mod index e9ff030..c1f6c6c 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,8 @@ module go.stargrave.org/sgblog go 1.13 require ( - github.com/google/uuid v1.1.1 + github.com/hjson/hjson-go v3.0.1+incompatible + go.cypherpunks.ru/netstring/v2 v2.0.0 golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 golang.org/x/tools v0.0.0-20190729092621-ff9f1409240a gopkg.in/src-d/go-git.v4 v4.13.1 diff --git a/go.sum b/go.sum index 71eda25..2458f45 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/gliderlabs/ssh v0.2.2 h1:6zsha5zo/TWhRhwqCD3+EarCAgZ2yN28ipRnGPnwkI0= github.com/gliderlabs/ssh v0.2.2/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0= github.com/google/go-cmp v0.3.0 h1:crn/baboCvb5fXaQ0IJ1SGTsTVrWpDsCWC8EGETZijY= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= -github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hjson/hjson-go v3.0.1+incompatible h1:JwOXblcMiBbiWue7iPkoFK9oXSnW8n+qXh/0Fio6TCo= +github.com/hjson/hjson-go v3.0.1+incompatible/go.mod h1:qsetwF8NlsTsOTwZTApNlTCerV+b2GjYRRcIk4JMFio= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -46,6 +46,8 @@ github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/xanzy/ssh-agent v0.2.1 h1:TCbipTQL2JiiCprBWx9frJ2eJlCYT00NmctrHxVAr70= github.com/xanzy/ssh-agent v0.2.1/go.mod h1:mLlQY/MoOhWBj+gOGMQkOeiEvkx+8pJSI+0Bx9h2kr4= +go.cypherpunks.ru/netstring/v2 v2.0.0 h1:or1LDZO3fSd6iITGR3jJUfUrjvRgeNlUpEYI13qaRBk= +go.cypherpunks.ru/netstring/v2 v2.0.0/go.mod h1:6YDx4gW414SmHdvSBMKbHaB2/7w9WZ04NQb7XIUV/pA= golang.org/x/crypto v0.0.0-20190219172222-a4c6cb3142f2/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4 h1:HuIa8hRrWRSrqYzx1qI49NNxhdi2PrY7gxVSq1JjLDc=