/* 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 package main import ( "bytes" "compress/gzip" "encoding/hex" "encoding/xml" "errors" "fmt" "hash" "io" "io/ioutil" "net/url" "os" "regexp" "strconv" "strings" "github.com/google/uuid" "golang.org/x/crypto/blake2b" "golang.org/x/tools/blog/atom" "gopkg.in/src-d/go-git.v4" "gopkg.in/src-d/go-git.v4/plumbing" "gopkg.in/src-d/go-git.v4/plumbing/object" ) const ( PageEntries = 50 WhenFmt = "2006-01-02 15:04:05Z07:00" AtomFeed = "feed.atom" ) var ( Version = "0.0.1" ETagVersion = []byte("1") 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{}{}, } ) func makeA(href, text string) string { return fmt.Sprintf(`%s`, href, 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 msgSplit(msg string) []string { lines := strings.Split(msg, "\n") lines = lines[:len(lines)-1] if len(lines) < 3 { lines = []string{lines[0], "", ""} } return lines } func getNote(what plumbing.Hash) string { if commentsTree == nil { return "" } entry, err := commentsTree.FindEntry(what.String()) if err != nil { return "" } blob, err := repo.BlobObject(entry.Hash) if err != nil { return "" } r, err := blob.Reader() if err != nil { return "" } data, err := ioutil.ReadAll(r) if err != nil { return "" } return string(data) } 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 startHTML(title string, additional []string) string { return fmt.Sprintf(` %s %s `, Version, title, strings.Join(append(defaultLinks, additional...), "\n "), ) } func makeErr(err error) { fmt.Println("Content-Type: text/plain; charset=UTF-8\n") fmt.Println(err) panic(err) } func checkETag(etag hash.Hash) { ifNoneMatch := os.Getenv("HTTP_IF_NONE_MATCH") if ifNoneMatch != "" && ifNoneMatch == etagString(etag) { fmt.Println("Status: 304\nETag:", ifNoneMatch, "\n") os.Exit(0) } } func main() { gitPath, exists := os.LookupEnv("SGBLOG_GIT_PATH") if !exists { makeErr(errors.New("SGBLOG_GIT_PATH is unset")) } branchName, exists := os.LookupEnv("SGBLOG_BRANCH") if !exists { makeErr(errors.New("SGBLOG_BRANCH is unset")) } blogBaseURL, exists := os.LookupEnv("SGBLOG_BASE_URL") if !exists { makeErr(errors.New("SGBLOG_BASE_URL is unset")) } blogTitle, exists := os.LookupEnv("SGBLOG_TITLE") if !exists { makeErr(errors.New("SGBLOG_TITLE is unset")) } atomId, exists := os.LookupEnv("SGBLOG_ATOM_ID") if !exists { makeErr(errors.New("SGBLOG_ATOM_ID is unset")) } atomAuthorName, exists := os.LookupEnv("SGBLOG_ATOM_AUTHOR") if !exists { makeErr(errors.New("SGBLOG_ATOM_AUTHOR is unset")) } 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, )) pathInfo, exists := os.LookupEnv("PATH_INFO") if !exists { pathInfo = "/" } queryValues, err := url.ParseQuery(os.Getenv("QUERY_STRING")) if err != nil { makeErr(err) } repo, err = git.PlainOpen(gitPath) if err != nil { makeErr(err) } head, err := repo.Reference(plumbing.ReferenceName(branchName), 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() } } } 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 } } var commit *object.Commit if pathInfo == "/" { offset := 0 if offsetRaw, exists := queryValues["offset"]; exists { offset, err = strconv.Atoi(offsetRaw[0]) if err != nil { 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 for i := 0; i < offset; i++ { commit, err = log.Next() if err != nil { break } } for i := 0; i < PageEntries; i++ { commit, err = log.Next() if err != nil { errOccured = true break } if i == 0 { etagHash.Write(commit.Hash[:]) checkETag(etagHash) } lines := msgSplit(commit.Message) domains := []string{} for _, line := range lines[2:] { if u := urlParse(line); u == nil { break } else { 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)") } table.WriteString(fmt.Sprintf( "\n", commit.Author.When.Format(WhenFmt), strings.Join(entry, " "), strings.Join(domains, " "), )) } table.WriteString("
WhenTitleComment of
%s%s%s
") var links []string var refs bytes.Buffer if offset > 0 { offsetPrev := offset - PageEntries if offsetPrev < 0 { offsetPrev = 0 } href := urlPrefix + "/?offset=" + strconv.Itoa(offsetPrev) links = append(links, fmt.Sprintf( ``, href, )) 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]")) } os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) out.Write([]byte(startHTML( fmt.Sprintf("%s (%d-%d)", blogTitle, offset, offset+PageEntries), links, ))) 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()) if err != nil { makeErr(err) } etagHash.Write(commit.Hash[:]) etagHash.Write([]byte("ATOM")) checkETag(etagHash) feed := atom.Feed{ Title: blogTitle, ID: atomId, Updated: atom.Time(commit.Author.When), Link: []atom.Link{{ Rel: "self", Href: atomUrl, }}, Author: &atom.Person{Name: atomAuthorName}, } log, err := repo.Log(&git.LogOptions{From: head.Hash()}) if err != nil { makeErr(err) } for i := 0; i < PageEntries; i++ { commit, err = log.Next() if err != nil { break } 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(), Link: []atom.Link{{ Rel: "alternate", Href: blogBaseURL + 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: "text", Body: strings.Join(lines[2:], "\n"), }, }) } data, err := xml.MarshalIndent(&feed, "", " ") 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 } else if sha1DigestRe.MatchString(pathInfo[1:]) { commit, err = repo.CommitObject(plumbing.NewHash(pathInfo[1:])) if err != nil { makeErr(err) } etagHash.Write(commit.Hash[:]) checkETag(etagHash) lines := msgSplit(commit.Message) title := lines[0] when := commit.Author.When.Format(WhenFmt) os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) parent := commit.ParentHashes[0].String() out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), []string{ fmt.Sprintf(``, "/"+parent), }))) out.Write([]byte(fmt.Sprintf( "[%s] [%s]\n
\n", makeA(urlPrefix+"/"+parent, "older"), when, ))) out.Write([]byte(fmt.Sprintf("

%s

\n
\n", title)))
		for _, line := range lines[2:] {
			line = strings.ReplaceAll(line, "&", "&")
			line = strings.ReplaceAll(line, "<", "<")
			line = strings.ReplaceAll(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"))
			}
			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))) } } 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() } os.Stdout.Write(outBuf.Bytes()) }