/* SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine Copyright (C) 2020-2021 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" "compress/gzip" "crypto/sha1" _ "embed" "encoding/hex" "encoding/xml" "errors" "fmt" "hash" "html" "io" "log" "net/url" "os" "strconv" "strings" "text/template" "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "go.stargrave.org/sgblog" "go.stargrave.org/sgblog/cmd/sgblog/atom" "golang.org/x/crypto/blake2b" ) const ( AtomPostsFeed = "feed.atom" AtomCommentsFeed = "comments.atom" ) var ( renderableSchemes = map[string]struct{}{ "finger": {}, "ftp": {}, "gemini": {}, "gopher": {}, "http": {}, "https": {}, "telnet": {}, } //go:embed http-index.tmpl TmplHTMLIndexRaw string TmplHTMLIndex = template.Must(template.New("http-index").Parse(TmplHTMLIndexRaw)) //go:embed http-entry.tmpl TmplHTMLEntryRaw string TmplHTMLEntry = template.Must(template.New("http-entry").Funcs( template.FuncMap{"lineURLize": lineURLizeInTemplate}, ).Parse(TmplHTMLEntryRaw)) ) type TableEntry struct { Commit *object.Commit CommentsRaw []byte TopicsRaw []byte Num int Title string LinesNum int CommentsNum int DomainURLs []string Topics []string } type CommentEntry struct { HeaderLines []string BodyLines []string } func makeA(href, text string) string { return `` + text + `` } func etagString(etag hash.Hash) string { return `"` + hex.EncodeToString(etag.Sum(nil)) + `"` } func urlParse(what string) *url.URL { if u, err := url.ParseRequestURI(what); err == nil { if _, exists := renderableSchemes[u.Scheme]; exists { return u } } return nil } func lineURLize(urlPrefix, line string) string { cols := strings.Split(html.EscapeString(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")) } return strings.Join(cols, " ") } func lineURLizeInTemplate(urlPrefix, line interface{}) string { return lineURLize(urlPrefix.(string), line.(string)) } func startHeader(etag hash.Hash, gziped bool) string { lines := []string{ "Content-Type: text/html; charset=UTF-8", "ETag: " + etagString(etag), } if gziped { lines = append(lines, "Content-Encoding: gzip") } lines = append(lines, "") lines = append(lines, "") return strings.Join(lines, "\n") } func makeErr(err error) { fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n") fmt.Println(err) log.Fatalln(err) } func checkETag(etag hash.Hash) { ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH") if ifNoneMatch != "" && ifNoneMatch == etagString(etag) { fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch) 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:]) } type CommitIterNext interface { Next() (*object.Commit, error) } func serveHTTP() { cfgPath := os.Getenv("SGBLOG_CFG") if cfgPath == "" { log.Fatalln("SGBLOG_CFG is not set") } cfg, err := readCfg(cfgPath) if err != nil { log.Fatalln(err) } pathInfo, exists := os.LookupEnv("PATH_INFO") if !exists { pathInfo = "/" } queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING")) if err != nil { makeErr(err) } etagHash, err := blake2b.New256(nil) if err != nil { panic(err) } for _, s := range []string{ "SGBLOG", sgblog.Version, cfg.GitPath, cfg.Branch, cfg.Title, cfg.URLPrefix, cfg.AtomBaseURL, cfg.AtomId, cfg.AtomAuthor, } { if _, err = etagHash.Write([]byte(s)); err != nil { panic(err) } } etagHashForWeb := []string{ cfg.CSS, cfg.Webmaster, cfg.AboutURL, cfg.CommentsNotesRef, cfg.CommentsEmail, } for _, gitURL := range cfg.GitURLs { etagHashForWeb = append(etagHashForWeb, gitURL) } headHash, err := initRepo(cfg) if err != nil { makeErr(err) } 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": notesRef = ref case cfg.CommentsNotesRef: commentsRef = ref } return nil }) if notesRef != nil { if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil { notesTree, _ = commentsCommit.Tree() } } if commentsRef != nil { if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil { commentsTree, _ = commentsCommit.Tree() } } } var outBuf bytes.Buffer var out io.Writer out = &outBuf var gzipWriter *gzip.Writer acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING") for _, encoding := range strings.Split(acceptEncoding, ", ") { if encoding == "gzip" { gzipWriter = gzip.NewWriter(&outBuf) out = gzipWriter } } if pathInfo == "/" { offset := 0 if offsetRaw, exists := queryValues["offset"]; exists { offset, err = strconv.Atoi(offsetRaw[0]) if err != nil { makeErr(err) } } repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) if err != nil { makeErr(err) } topicsCache, err := getTopicsCache(cfg, repoLog) if err != nil { makeErr(err) } repoLog, err = repo.Log(&git.LogOptions{From: *headHash}) if err != nil { makeErr(err) } commitN := 0 var commits CommitIterNext var topic string if t, exists := queryValues["topic"]; exists { topic = t[0] hashes := topicsCache[topic] if hashes == nil { makeErr(errors.New("no posts with that topic")) } if len(hashes) > offset { hashes = hashes[offset:] commitN += offset } commits = &HashesIter{hashes} } else { for i := 0; i < offset; i++ { if _, err = repoLog.Next(); err != nil { break } commitN++ } commits = repoLog } entries := make([]TableEntry, 0, PageEntries) logEnded := false for _, data := range etagHashForWeb { etagHash.Write([]byte(data)) } etagHash.Write([]byte("INDEX")) etagHash.Write([]byte(topic)) for i := 0; i < PageEntries; i++ { commit, err := commits.Next() if err != nil { logEnded = true break } etagHash.Write(commit.Hash[:]) commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash) etagHash.Write(commentsRaw) topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash) etagHash.Write(topicsRaw) entries = append(entries, TableEntry{ Commit: commit, CommentsRaw: commentsRaw, TopicsRaw: topicsRaw, }) } checkETag(etagHash) for i, entry := range entries { commitN++ entry.Num = commitN lines := msgSplit(entry.Commit.Message) entry.Title = lines[0] entry.LinesNum = len(lines) - 2 for _, line := range lines[2:] { u := urlParse(line) if u == nil { break } entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host)) } entry.CommentsNum = len(sgblog.ParseComments(entry.CommentsRaw)) entry.Topics = sgblog.ParseTopics(entry.TopicsRaw) entries[i] = entry } offsetPrev := offset - PageEntries if offsetPrev < 0 { offsetPrev = 0 } os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) err = TmplHTMLIndex.Execute(out, struct { Version string Cfg *Cfg Topic string TopicsEnabled bool Topics []string CommentsEnabled bool AtomPostsFeed string AtomCommentsFeed string Offset int OffsetPrev int OffsetNext int LogEnded bool Entries []TableEntry }{ Version: sgblog.Version, Cfg: cfg, Topic: topic, TopicsEnabled: topicsTree != nil, Topics: topicsCache.Topics(), CommentsEnabled: commentsTree != nil, AtomPostsFeed: AtomPostsFeed, AtomCommentsFeed: AtomCommentsFeed, Offset: offset, OffsetPrev: offsetPrev, OffsetNext: offset + PageEntries, LogEnded: logEnded, Entries: entries, }) if err != nil { makeErr(err) } } else if pathInfo == "/"+AtomPostsFeed { commit, err := repo.CommitObject(*headHash) if err != nil { makeErr(err) } var topic string if t, exists := queryValues["topic"]; exists { topic = t[0] } etagHash.Write([]byte("ATOM POSTS")) etagHash.Write([]byte(topic)) etagHash.Write(commit.Hash[:]) checkETag(etagHash) var title string if topic == "" { title = cfg.Title } else { title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic) } idHasher, err := blake2b.New256(nil) if err != nil { panic(err) } idHasher.Write([]byte("ATOM POSTS")) idHasher.Write([]byte(cfg.AtomId)) idHasher.Write([]byte(topic)) feed := atom.Feed{ Title: title, ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)), Updated: atom.Time(commit.Author.When), Link: []atom.Link{{ Rel: "self", Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed, }}, Author: &atom.Person{Name: cfg.AtomAuthor}, } repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) if err != nil { makeErr(err) } var commits CommitIterNext if topic == "" { commits = repoLog } else { topicsCache, err := getTopicsCache(cfg, repoLog) if err != nil { makeErr(err) } hashes := topicsCache[topic] if hashes == nil { makeErr(errors.New("no posts with that topic")) } commits = &HashesIter{hashes} } for i := 0; i < PageEntries; i++ { commit, err = commits.Next() if err != nil { break } lines := msgSplit(commit.Message) var categories []atom.Category for _, topic := range sgblog.ParseTopics(sgblog.GetNote(repo, topicsTree, commit.Hash)) { categories = append(categories, atom.Category{Term: topic}) } htmlized := make([]string, 0, len(lines)) htmlized = append(htmlized, "
")
			for _, l := range lines[2:] {
				htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
			}
			htmlized = append(htmlized, "
") feed.Entry = append(feed.Entry, &atom.Entry{ Title: lines[0], 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{Type: "text", Body: lines[0]}, Content: &atom.Text{ Type: "html", Body: strings.Join(htmlized, "\n"), }, Category: categories, }) } 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: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed, }}, 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, "/", "", )) commit, err = repo.CommitObject(commentedHash) if err != nil { continue } comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash)) if len(comments) == 0 { continue } 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: ") htmlized := make([]string, 0, len(lines)) htmlized = append(htmlized, "
")
			for _, l := range lines[2:] {
				htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
			}
			htmlized = append(htmlized, "
") idHasher.Reset() idHasher.Write([]byte("COMMENT")) idHasher.Write(commit.Hash[:]) idHasher.Write([]byte(commentN)) feed.Entry = append(feed.Entry, &atom.Entry{ Title: fmt.Sprintf( "Comment %s for \"%s\" by %s", commentN, msgSplit(commit.Message)[0], 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(strings.Replace(date, " ", "T", -1)), Updated: atom.TimeStr(strings.Replace(date, " ", "T", -1)), Content: &atom.Text{ Type: "html", Body: strings.Join(htmlized, "\n"), }, }) } data, err := xml.MarshalIndent(&feed, "", " ") if err != nil { makeErr(err) } out.Write(data) goto AtomFinish } else if sha1DigestRe.MatchString(pathInfo[1:]) { commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2])) if err != nil { makeErr(err) } for _, data := range etagHashForWeb { etagHash.Write([]byte(data)) } etagHash.Write([]byte("ENTRY")) etagHash.Write(commit.Hash[:]) atomCommentsURL := strings.Join([]string{ cfg.AtomBaseURL, cfg.URLPrefix, "/", commit.Hash.String(), "/", AtomCommentsFeed, }, "") commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash) etagHash.Write(commentsRaw) topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash) etagHash.Write(topicsRaw) if strings.HasSuffix(pathInfo, AtomCommentsFeed) { etagHash.Write([]byte("ATOM COMMENTS")) checkETag(etagHash) type Comment struct { n string from string date string body []string } commentsRaw := sgblog.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)) htmlized := make([]string, 0, len(comment.body)) htmlized = append(htmlized, "
")
				for _, l := range comment.body {
					htmlized = append(
						htmlized,
						lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
					)
				}
				htmlized = append(htmlized, "
") feed.Entry = append(feed.Entry, &atom.Entry{ Title: fmt.Sprintf("Comment %s by %s", comment.n, 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( strings.Replace(comment.date, " ", "T", -1), ), Updated: atom.TimeStr( strings.Replace(comment.date, " ", "T", -1), ), Content: &atom.Text{ Type: "html", Body: strings.Join(htmlized, "\n"), }, }) } data, err := xml.MarshalIndent(&feed, "", " ") if err != nil { makeErr(err) } out.Write(data) goto AtomFinish } notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash) etagHash.Write(notesRaw) checkETag(etagHash) lines := msgSplit(commit.Message) title := lines[0] when := commit.Author.When.Format(sgblog.WhenFmt) var parent string if len(commit.ParentHashes) > 0 { parent = commit.ParentHashes[0].String() } commentsParsed := sgblog.ParseComments(commentsRaw) comments := make([]CommentEntry, 0, len(commentsParsed)) for _, comment := range commentsParsed { lines := strings.Split(comment, "\n") comments = append(comments, CommentEntry{lines[:3], lines[3:]}) } var notesLines []string if len(notesRaw) > 0 { notesLines = strings.Split(string(notesRaw), "\n") } os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) err = TmplHTMLEntry.Execute(out, struct { Version string Cfg *Cfg Title string TitleEscaped string When string AtomCommentsURL string Parent string Commit *object.Commit Lines []string NoteLines []string Comments []CommentEntry Topics []string }{ Version: sgblog.Version, Cfg: cfg, Title: title, TitleEscaped: url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)), When: when, AtomCommentsURL: atomCommentsURL, Parent: parent, Commit: commit, Lines: lines[2:], NoteLines: notesLines, Comments: comments, Topics: sgblog.ParseTopics(topicsRaw), }) if err != nil { makeErr(err) } } else { makeErr(errors.New("unknown URL action")) } out.Write([]byte("\n")) if gzipWriter != nil { gzipWriter.Close() } os.Stdout.Write(outBuf.Bytes()) return AtomFinish: os.Stdout.WriteString("Content-Type: application/atom+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()) }