1 // SGBlog -- Git-backed CGI/UCSPI blogging/phlogging/gemlogging engine
2 // Copyright (C) 2020-2024 Sergey Matveev <stargrave@stargrave.org>
4 // This program is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU Affero General Public License as
6 // published by the Free Software Foundation, version 3 of the License.
8 // This program is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU Affero General Public License
14 // along with this program. If not, see <http://www.gnu.org/licenses/>.
38 "github.com/go-git/go-git/v5"
39 "github.com/go-git/go-git/v5/plumbing"
40 "github.com/go-git/go-git/v5/plumbing/object"
41 "github.com/klauspost/compress/zstd"
42 "github.com/vorlif/spreak"
43 "go.stargrave.org/sgblog"
44 "go.stargrave.org/sgblog/cmd/sgblog/atom"
45 "lukechampine.com/blake3"
49 AtomPostsFeed = "feed.atom"
50 AtomCommentsFeed = "comments.atom"
54 renderableSchemes = map[string]struct{}{
67 //go:embed http-index.tmpl
68 TmplHTMLIndexRaw string
69 TmplHTMLIndex = template.Must(template.New("http-index").Parse(TmplHTMLIndexRaw))
71 //go:embed http-entry.tmpl
72 TmplHTMLEntryRaw string
73 TmplHTMLEntry = template.Must(template.New("http-entry").Funcs(
74 template.FuncMap{"lineURLize": lineURLizeInTemplate},
75 ).Parse(TmplHTMLEntryRaw))
78 type TableEntry struct {
91 type CommentEntry struct {
96 func makeA(href, text string) string {
97 return `<a href="` + href + `">` + text + `</a>`
100 func etagString(etag hash.Hash) string {
101 return `"` + base64.RawURLEncoding.EncodeToString(etag.Sum(nil)) + `"`
104 func urlParse(what string) *url.URL {
105 if u, err := url.ParseRequestURI(what); err == nil {
106 if _, exists := renderableSchemes[u.Scheme]; exists {
113 func lineURLize(urlPrefix, line string) string {
114 cols := strings.Split(html.EscapeString(line), " ")
115 for i, col := range cols {
116 if u := urlParse(col); u != nil {
117 cols[i] = makeA(col, col)
120 cols[i] = sha1DigestRe.ReplaceAllString(col, makeA(urlPrefix+"/$1", "$1"))
122 return strings.Join(cols, " ")
125 func lineURLizeInTemplate(urlPrefix, line interface{}) string {
126 return lineURLize(urlPrefix.(string), line.(string))
129 func startHeader(etag hash.Hash, zstded bool) string {
131 "Content-Type: text/html; charset=utf-8",
132 "ETag: " + etagString(etag),
135 lines = append(lines, "Content-Encoding: zstd")
137 lines = append(lines, "")
138 lines = append(lines, "")
139 return strings.Join(lines, "\n")
142 func makeErr(err error, status int) {
143 fmt.Println("Status:", status)
144 fmt.Print("Content-Type: text/plain; charset=utf-8\n\n")
149 func checkETag(etag hash.Hash) {
150 ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH")
151 if ifNoneMatch != "" && ifNoneMatch == etagString(etag) {
152 fmt.Printf("Status: 304\nETag: %s\n\n", ifNoneMatch)
157 func bytes2uuid(b []byte) string {
160 raw[6] = (raw[6] & 0x0F) | uint8(4<<4) // version 4
161 return fmt.Sprintf("%x-%x-%x-%x-%x", raw[0:4], raw[4:6], raw[6:8], raw[8:10], raw[10:])
164 type CommitIterNext interface {
165 Next() (*object.Commit, error)
169 cfgPath := os.Getenv("SGBLOG_CFG")
171 log.Fatalln("SGBLOG_CFG is not set")
173 cfg, err := readCfg(cfgPath)
177 initLocalizer(cfg.Lang)
179 pathInfo := os.Getenv("PATH_INFO")
180 if len(pathInfo) == 0 {
183 queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING"))
185 makeErr(err, http.StatusBadRequest)
188 etagHash := blake3.New(32, nil)
189 for _, s := range []string{
199 if _, err = etagHash.Write([]byte(s)); err != nil {
203 etagHashForWeb := []string{
207 cfg.CommentsNotesRef,
210 etagHashForWeb = append(etagHashForWeb, cfg.GitURLs...)
212 headHash, err := initRepo(cfg)
214 makeErr(err, http.StatusInternalServerError)
217 if notes, err := repo.Notes(); err == nil {
218 var notesRef *plumbing.Reference
219 var commentsRef *plumbing.Reference
220 notes.ForEach(func(ref *plumbing.Reference) error {
221 switch string(ref.Name()) {
222 case "refs/notes/commits":
224 case cfg.CommentsNotesRef:
230 if commentsCommit, err := repo.CommitObject(notesRef.Hash()); err == nil {
231 notesTree, _ = commentsCommit.Tree()
234 if commentsRef != nil {
235 if commentsCommit, err := repo.CommitObject(commentsRef.Hash()); err == nil {
236 commentsTree, _ = commentsCommit.Tree()
241 var outBuf bytes.Buffer
244 var zstdWriter *zstd.Encoder
245 acceptEncoding := os.Getenv("HTTP_ACCEPT_ENCODING")
246 for _, encoding := range strings.Split(acceptEncoding, ", ") {
247 if encoding == "zstd" {
248 zstdWriter, err = zstd.NewWriter(&outBuf, zstd.WithEncoderLevel(zstd.SpeedDefault))
258 if offsetRaw, exists := queryValues["offset"]; exists {
259 offset, err = strconv.Atoi(offsetRaw[0])
261 makeErr(err, http.StatusBadRequest)
264 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
266 makeErr(err, http.StatusInternalServerError)
268 topicsCache, err := getTopicsCache(cfg, repoLog)
270 makeErr(err, http.StatusInternalServerError)
272 repoLog, err = repo.Log(&git.LogOptions{From: *headHash})
274 makeErr(err, http.StatusInternalServerError)
278 var commits CommitIterNext
280 if t, exists := queryValues["topic"]; exists {
282 hashes := topicsCache[topic]
284 makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
286 if len(hashes) > offset {
287 hashes = hashes[offset:]
290 commits = &HashesIter{hashes}
292 for i := 0; i < offset; i++ {
293 if _, err = repoLog.Next(); err != nil {
301 entries := make([]TableEntry, 0, PageEntries)
303 for _, data := range etagHashForWeb {
304 etagHash.Write([]byte(data))
306 etagHash.Write([]byte("INDEX"))
307 etagHash.Write([]byte(topic))
308 for i := 0; i < PageEntries; i++ {
309 commit, err := commits.Next()
314 etagHash.Write(commit.Hash[:])
315 commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
316 etagHash.Write(commentsRaw)
317 topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
318 etagHash.Write(topicsRaw)
319 entries = append(entries, TableEntry{
321 CommentsRaw: commentsRaw,
322 TopicsRaw: topicsRaw,
327 for i, entry := range entries {
330 lines := msgSplit(entry.Commit.Message)
331 entry.Title = lines[0]
332 entry.LinesNum = len(lines) - 2
333 for _, line := range lines[2:] {
338 entry.DomainURLs = append(entry.DomainURLs, makeA(line, u.Host))
340 entry.CommentsNum = len(sgblog.ParseComments(entry.CommentsRaw))
341 entry.ImagesNum = len(listImgs(cfg, entry.Commit.Hash))
342 entry.Topics = sgblog.ParseTopics(entry.TopicsRaw)
345 offsetPrev := offset - PageEntries
349 os.Stdout.Write([]byte(startHeader(etagHash, zstdWriter != nil)))
350 err = TmplHTMLIndex.Execute(out, struct {
359 AtomCommentsFeed string
367 Version: sgblog.Version,
370 TopicsEnabled: topicsTree != nil,
371 Topics: topicsCache.Topics(),
372 CommentsEnabled: commentsTree != nil,
373 AtomPostsFeed: AtomPostsFeed,
374 AtomCommentsFeed: AtomCommentsFeed,
376 OffsetPrev: offsetPrev,
377 OffsetNext: offset + PageEntries,
382 makeErr(err, http.StatusInternalServerError)
384 } else if pathInfo == "/twtxt.txt" {
385 commit, err := repo.CommitObject(*headHash)
387 makeErr(err, http.StatusInternalServerError)
389 etagHash.Write([]byte("TWTXT POSTS"))
390 etagHash.Write(commit.Hash[:])
392 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
394 makeErr(err, http.StatusInternalServerError)
396 for i := 0; i < PageEntries; i++ {
397 commit, err = repoLog.Next()
403 commit.Author.When.Format(time.RFC3339),
404 msgSplit(commit.Message)[0],
407 os.Stdout.WriteString("Content-Type: text/plain; charset=utf-8\n")
408 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
409 if zstdWriter != nil {
410 os.Stdout.WriteString("Content-Encoding: zstd\n")
413 os.Stdout.WriteString("\n")
414 os.Stdout.Write(outBuf.Bytes())
416 } else if pathInfo == "/"+AtomPostsFeed {
417 commit, err := repo.CommitObject(*headHash)
419 makeErr(err, http.StatusInternalServerError)
423 if t, exists := queryValues["topic"]; exists {
427 etagHash.Write([]byte("ATOM POSTS"))
428 etagHash.Write([]byte(topic))
429 etagHash.Write(commit.Hash[:])
435 title = fmt.Sprintf("%s (topic: %s)", cfg.Title, topic)
437 idHasher := blake3.New(32, nil)
438 idHasher.Write([]byte("ATOM POSTS"))
439 idHasher.Write([]byte(cfg.AtomId))
440 idHasher.Write([]byte(topic))
443 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
444 Updated: atom.Time(commit.Author.When),
447 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomPostsFeed,
449 Author: &atom.Person{Name: cfg.AtomAuthor},
452 repoLog, err := repo.Log(&git.LogOptions{From: *headHash})
454 makeErr(err, http.StatusInternalServerError)
456 var commits CommitIterNext
460 topicsCache, err := getTopicsCache(cfg, repoLog)
462 makeErr(err, http.StatusInternalServerError)
464 hashes := topicsCache[topic]
466 makeErr(errors.New("no posts with that topic"), http.StatusBadRequest)
468 commits = &HashesIter{hashes}
471 for i := 0; i < PageEntries; i++ {
472 commit, err = commits.Next()
476 lines := msgSplit(commit.Message)
477 var categories []atom.Category
478 for _, topic := range sgblog.ParseTopics(sgblog.GetNote(
479 repo, topicsTree, commit.Hash,
481 categories = append(categories, atom.Category{Term: topic})
483 htmlized := make([]string, 0, len(lines))
484 htmlized = append(htmlized, "<pre>")
485 for _, l := range lines[2:] {
486 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
488 htmlized = append(htmlized, "</pre>")
489 links := []atom.Link{{
491 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + commit.Hash.String(),
493 for _, img := range listImgs(cfg, commit.Hash) {
494 links = append(links, atom.Link{
496 Href: "http://" + cfg.ImgDomain + "/" + img.Path,
498 Length: uint(img.Size),
501 feed.Entry = append(feed.Entry, &atom.Entry{
503 ID: "urn:uuid:" + bytes2uuid(commit.Hash[:]),
505 Published: atom.Time(commit.Author.When),
506 Updated: atom.Time(commit.Author.When),
507 Summary: &atom.Text{Type: "text", Body: lines[0]},
510 Body: strings.Join(htmlized, "\n"),
512 Category: categories,
515 data, err := xml.MarshalIndent(&feed, "", " ")
517 makeErr(err, http.StatusInternalServerError)
519 out.Write([]byte(xml.Header))
522 } else if pathInfo == "/"+AtomCommentsFeed {
523 commit, err := repo.CommitObject(commentsRef.Hash())
525 makeErr(err, http.StatusInternalServerError)
527 etagHash.Write([]byte("ATOM COMMENTS"))
528 etagHash.Write(commit.Hash[:])
530 idHasher := blake3.New(32, nil)
531 idHasher.Write([]byte("ATOM COMMENTS"))
532 idHasher.Write([]byte(cfg.AtomId))
534 Title: cfg.Title + " comments",
535 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
536 Updated: atom.Time(commit.Author.When),
539 Href: cfg.AtomBaseURL + cfg.URLPrefix + "/" + AtomCommentsFeed,
541 Author: &atom.Person{Name: cfg.AtomAuthor},
543 repoLog, err := repo.Log(&git.LogOptions{From: commentsRef.Hash()})
545 makeErr(err, http.StatusInternalServerError)
547 for i := 0; i < PageEntries; i++ {
548 commit, err = repoLog.Next()
552 fileStats, err := commit.Stats()
554 makeErr(err, http.StatusInternalServerError)
556 t, err := commit.Tree()
558 makeErr(err, http.StatusInternalServerError)
560 commentedHash := plumbing.NewHash(strings.ReplaceAll(
561 fileStats[0].Name, "/", "",
563 commit, err = repo.CommitObject(commentedHash)
567 comments := sgblog.ParseComments(sgblog.GetNote(repo, t, commentedHash))
568 if len(comments) == 0 {
571 commentN := strconv.Itoa(len(comments) - 1)
572 lines := strings.Split(comments[len(comments)-1], "\n")
573 from := strings.TrimPrefix(lines[0], "From: ")
574 date := strings.TrimPrefix(lines[1], "Date: ")
575 htmlized := make([]string, 0, len(lines))
576 htmlized = append(htmlized, "<pre>")
577 for _, l := range lines[2:] {
578 htmlized = append(htmlized, lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l))
580 htmlized = append(htmlized, "</pre>")
582 idHasher.Write([]byte("COMMENT"))
583 idHasher.Write(commit.Hash[:])
584 idHasher.Write([]byte(commentN))
585 feed.Entry = append(feed.Entry, &atom.Entry{
587 "Comment %s for \"%s\" by %s",
588 commentN, msgSplit(commit.Message)[0], from,
590 Author: &atom.Person{Name: from},
591 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
594 Href: strings.Join([]string{
595 cfg.AtomBaseURL, cfg.URLPrefix, "/",
596 commit.Hash.String(), "#comment", commentN,
599 Published: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
600 Updated: atom.TimeStr(strings.Replace(date, " ", "T", -1)),
603 Body: strings.Join(htmlized, "\n"),
607 data, err := xml.MarshalIndent(&feed, "", " ")
609 makeErr(err, http.StatusInternalServerError)
611 out.Write([]byte(xml.Header))
614 } else if sha1DigestRe.MatchString(pathInfo[1:]) {
615 commit, err := repo.CommitObject(plumbing.NewHash(pathInfo[1 : 1+sha1.Size*2]))
617 makeErr(err, http.StatusBadRequest)
619 for _, data := range etagHashForWeb {
620 etagHash.Write([]byte(data))
622 etagHash.Write([]byte("ENTRY"))
623 etagHash.Write(commit.Hash[:])
624 atomCommentsURL := strings.Join([]string{
625 cfg.AtomBaseURL, cfg.URLPrefix, "/",
626 commit.Hash.String(), "/", AtomCommentsFeed,
628 commentsRaw := sgblog.GetNote(repo, commentsTree, commit.Hash)
629 etagHash.Write(commentsRaw)
630 topicsRaw := sgblog.GetNote(repo, topicsTree, commit.Hash)
631 etagHash.Write(topicsRaw)
632 if strings.HasSuffix(pathInfo, AtomCommentsFeed) {
633 etagHash.Write([]byte("ATOM COMMENTS"))
635 type Comment struct {
641 commentsRaw := sgblog.ParseComments(commentsRaw)
643 if len(commentsRaw) > PageEntries {
644 toSkip = len(commentsRaw) - PageEntries
646 comments := make([]Comment, 0, len(commentsRaw)-toSkip)
647 for i := len(commentsRaw) - 1; i >= toSkip; i-- {
648 lines := strings.Split(commentsRaw[i], "\n")
649 from := strings.TrimPrefix(lines[0], "From: ")
650 date := strings.TrimPrefix(lines[1], "Date: ")
651 comments = append(comments, Comment{
654 date: strings.Replace(date, " ", "T", 1),
658 idHasher := blake3.New(32, nil)
659 idHasher.Write([]byte("ATOM COMMENTS"))
660 idHasher.Write(commit.Hash[:])
662 Title: fmt.Sprintf("\"%s\" comments", msgSplit(commit.Message)[0]),
663 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
664 Link: []atom.Link{{Rel: "self", Href: atomCommentsURL}},
665 Author: &atom.Person{Name: cfg.AtomAuthor},
667 if len(comments) > 0 {
668 feed.Updated = atom.TimeStr(comments[0].date)
670 feed.Updated = atom.Time(commit.Author.When)
672 for _, comment := range comments {
674 idHasher.Write([]byte("COMMENT"))
675 idHasher.Write(commit.Hash[:])
676 idHasher.Write([]byte(comment.n))
677 htmlized := make([]string, 0, len(comment.body))
678 htmlized = append(htmlized, "<pre>")
679 for _, l := range comment.body {
682 lineURLize(cfg.AtomBaseURL+cfg.URLPrefix, l),
685 htmlized = append(htmlized, "</pre>")
686 feed.Entry = append(feed.Entry, &atom.Entry{
687 Title: fmt.Sprintf("Comment %s by %s", comment.n, comment.from),
688 Author: &atom.Person{Name: comment.from},
689 ID: "urn:uuid:" + bytes2uuid(idHasher.Sum(nil)),
692 Href: strings.Join([]string{
695 commit.Hash.String(),
696 "#comment", comment.n,
699 Published: atom.TimeStr(
700 strings.Replace(comment.date, " ", "T", -1),
702 Updated: atom.TimeStr(
703 strings.Replace(comment.date, " ", "T", -1),
707 Body: strings.Join(htmlized, "\n"),
711 data, err := xml.MarshalIndent(&feed, "", " ")
713 makeErr(err, http.StatusInternalServerError)
715 out.Write([]byte(xml.Header))
719 notesRaw := sgblog.GetNote(repo, notesTree, commit.Hash)
720 etagHash.Write(notesRaw)
723 lines := msgSplit(commit.Message)
725 when := commit.Author.When.Format(sgblog.WhenFmt)
727 if len(commit.ParentHashes) > 0 {
728 parent = commit.ParentHashes[0].String()
730 commentsParsed := sgblog.ParseComments(commentsRaw)
731 comments := make([]CommentEntry, 0, len(commentsParsed))
732 for _, comment := range commentsParsed {
733 lines := strings.Split(comment, "\n")
734 comments = append(comments, CommentEntry{lines[:3], lines[3:]})
736 var notesLines []string
737 if len(notesRaw) > 0 {
738 notesLines = strings.Split(string(notesRaw), "\n")
741 os.Stdout.Write([]byte(startHeader(etagHash, zstdWriter != nil)))
742 err = TmplHTMLEntry.Execute(out, struct {
749 AtomCommentsURL string
751 Commit *object.Commit
754 Comments []CommentEntry
759 Version: sgblog.Version,
762 TitleEscaped: url.PathEscape(fmt.Sprintf("Re: %s (%s)", title, commit.Hash)),
764 AtomCommentsURL: atomCommentsURL,
768 NoteLines: notesLines,
770 Topics: sgblog.ParseTopics(topicsRaw),
771 Imgs: listImgs(cfg, commit.Hash),
774 makeErr(err, http.StatusInternalServerError)
777 makeErr(errors.New("unknown URL action"), http.StatusNotFound)
779 out.Write([]byte("</body></html>\n"))
780 if zstdWriter != nil {
783 os.Stdout.Write(outBuf.Bytes())
787 os.Stdout.WriteString("Content-Type: application/atom+xml; charset=utf-8\n")
788 os.Stdout.WriteString("ETag: " + etagString(etagHash) + "\n")
789 if zstdWriter != nil {
790 os.Stdout.WriteString("Content-Encoding: zstd\n")
793 os.Stdout.WriteString("\n")
794 os.Stdout.Write(outBuf.Bytes())