From 0481d87d1a0f92ca1f4465d755a89864606bb6df Mon Sep 17 00:00:00 2001 From: Sergey Matveev Date: Sat, 14 Mar 2020 13:34:38 +0300 Subject: [PATCH] Separate HTTP and Gopher related functions --- cmd/sgblog/gopher.go | 177 ++++++++++++ cmd/sgblog/http.go | 518 ++++++++++++++++++++++++++++++++++++ cmd/sgblog/main.go | 620 ------------------------------------------- 3 files changed, 695 insertions(+), 620 deletions(-) create mode 100644 cmd/sgblog/gopher.go create mode 100644 cmd/sgblog/http.go diff --git a/cmd/sgblog/gopher.go b/cmd/sgblog/gopher.go new file mode 100644 index 0000000..808c40f --- /dev/null +++ b/cmd/sgblog/gopher.go @@ -0,0 +1,177 @@ +/* +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 ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "strconv" + "strings" + + "github.com/hjson/hjson-go" + "go.stargrave.org/sgblog" + "gopkg.in/src-d/go-git.v4" + "gopkg.in/src-d/go-git.v4/plumbing" +) + +const CRLF = "\r\n" + +var DashLine = strings.Repeat("-", 72) + +func serveGopher() { + cfgPath := os.Args[2] + cfgRaw, err := ioutil.ReadFile(cfgPath) + if err != nil { + log.Fatalln(err) + } + var cfgGeneral map[string]interface{} + if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil { + log.Fatalln(err) + } + cfgRaw, err = json.Marshal(cfgGeneral) + if err != nil { + log.Fatalln(err) + } + var cfg *Cfg + if err = json.Unmarshal(cfgRaw, &cfg); err != nil { + log.Fatalln(err) + } + if cfg.GopherDomain == "" { + log.Fatalln("GopherDomain is not configured") + } + + headHash, err := initRepo(cfg) + if err != nil { + log.Fatalln(err) + } + + scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 1<<8)) + if !scanner.Scan() { + log.Fatalln(errors.New("no CRLF found")) + } + selector := scanner.Text() + if selector == "" { + selector = "offset/0" + } + if strings.HasPrefix(selector, "offset/") { + offset, err := strconv.Atoi(selector[len("offset/"):]) + if err != nil { + log.Fatalln(err) + } + repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) + if err != nil { + log.Fatalln(err) + } + commitN := 0 + for i := 0; i < offset; i++ { + if _, err = repoLog.Next(); err != nil { + break + } + commitN++ + } + + logEnded := false + var menu bytes.Buffer + for i := 0; i < PageEntries; i++ { + commit, err := repoLog.Next() + if err != nil { + logEnded = true + break + } + commitN++ + lines := msgSplit(commit.Message) + + var commentsValue string + if l := len(parseComments(getNote(commentsTree, commit.Hash))); l > 0 { + commentsValue = fmt.Sprintf(" (%dC)", l) + } + menu.WriteString(fmt.Sprintf( + "0[%s] %s (%dL)%s\t/%s\t%s\t%d%s", + commit.Author.When.Format(sgblog.WhenFmt), + lines[0], + len(lines)-2, + commentsValue, + commit.Hash.String(), + cfg.GopherDomain, 70, CRLF, + )) + } + + var links bytes.Buffer + if offset > 0 { + offsetPrev := offset - PageEntries + if offsetPrev < 0 { + offsetPrev = 0 + } + links.WriteString(fmt.Sprintf( + "1Prev\toffset/%d\t%s\t%d%s", + offsetPrev, + cfg.GopherDomain, 70, CRLF, + )) + } + if !logEnded { + links.WriteString(fmt.Sprintf( + "1Next\toffset/%d\t%s\t%d%s", + offset+PageEntries, + cfg.GopherDomain, 70, CRLF, + )) + } + + fmt.Printf( + "i%s (%d-%d)\t\tnull.host\t1%s", + cfg.Title, + offset, + offset+PageEntries, + CRLF, + ) + if cfg.AboutURL != "" { + fmt.Printf("iAbout: %s\t\tnull.host\t1%s", cfg.AboutURL, CRLF) + } + fmt.Print(links.String()) + fmt.Print(menu.String()) + fmt.Print("." + CRLF) + } else if sha1DigestRe.MatchString(selector) { + commit, err := repo.CommitObject(plumbing.NewHash(selector[1:])) + if err != nil { + log.Fatalln(err) + } + fmt.Printf( + "What: %s\nWhen: %s\n%s\n%s", + commit.Hash.String(), + commit.Author.When.Format(sgblog.WhenFmt), + DashLine, + commit.Message, + ) + notesRaw := getNote(notesTree, commit.Hash) + if len(notesRaw) > 0 { + fmt.Printf("%s\nNote:\n%s\n", DashLine, string(notesRaw)) + } + for i, comment := range parseComments(getNote(commentsTree, commit.Hash)) { + fmt.Printf("%s\ncomment %d:\n%s\n", DashLine, i, comment) + } + } else { + log.Fatalln(errors.New("unknown selector")) + } +} diff --git a/cmd/sgblog/http.go b/cmd/sgblog/http.go new file mode 100644 index 0000000..18477a6 --- /dev/null +++ b/cmd/sgblog/http.go @@ -0,0 +1,518 @@ +/* +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/json" + "encoding/xml" + "errors" + "fmt" + "hash" + "html" + "io" + "io/ioutil" + "log" + "net/url" + "os" + "strconv" + "strings" + + "github.com/hjson/hjson-go" + "go.stargrave.org/sgblog" + "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 ( + AtomFeed = "feed.atom" +) + +var ( + defaultLinks = []string{} + + renderableSchemes = map[string]struct{}{ + "ftp": struct{}{}, + "gopher": struct{}{}, + "http": struct{}{}, + "https": struct{}{}, + "telnet": struct{}{}, + } +) + +type TableEntry struct { + commit *object.Commit + commentsRaw []byte +} + +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 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 + + +`, + sgblog.Version, title, + strings.Join(append(defaultLinks, additional...), "\n "), + ) +} + +func makeErr(err error) { + fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n") + fmt.Println(err) + panic(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 serveHTTP() { + cfgPath := os.Getenv("SGBLOG_CFG") + if cfgPath == "" { + log.Fatalln("SGBLOG_CFG is not set") + } + cfgRaw, err := ioutil.ReadFile(cfgPath) + if err != nil { + makeErr(err) + } + var cfgGeneral map[string]interface{} + if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil { + makeErr(err) + } + cfgRaw, err = json.Marshal(cfgGeneral) + if err != nil { + makeErr(err) + } + var cfg *Cfg + if err = json.Unmarshal(cfgRaw, &cfg); err != nil { + makeErr(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) + } + etagHash.Write([]byte("SGBLOG")) + etagHash.Write([]byte(sgblog.Version)) + etagHash.Write([]byte(cfg.GitPath)) + etagHash.Write([]byte(cfg.Branch)) + etagHash.Write([]byte(cfg.Title)) + etagHash.Write([]byte(cfg.URLPrefix)) + etagHash.Write([]byte(cfg.AtomBaseURL)) + etagHash.Write([]byte(cfg.AtomId)) + etagHash.Write([]byte(cfg.AtomAuthor)) + + etagHashForWeb := [][]byte{} + if cfg.CSS != "" { + defaultLinks = append(defaultLinks, ``) + etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS)) + } + 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.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed + defaultLinks = append(defaultLinks, ``) + + 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) + } + commitN := 0 + for i := 0; i < offset; i++ { + if _, err = repoLog.Next(); err != nil { + break + } + commitN++ + } + + 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 := repoLog.Next() + if err != nil { + logEnded = true + break + } + etagHash.Write(commit.Hash[:]) + commentsRaw := getNote(commentsTree, commit.Hash) + etagHash.Write(commentsRaw) + entries = append(entries, TableEntry{commit, commentsRaw}) + } + checkETag(etagHash) + + var table bytes.Buffer + table.WriteString( + "\n" + + "\n" + + "" + + "" + + "" + + `` + + `` + + "\n") + for _, entry := range entries { + commitN++ + lines := msgSplit(entry.commit.Message) + domains := []string{} + for _, line := range lines[2:] { + if u := urlParse(line); u == nil { + break + } else { + domains = append(domains, makeA(line, u.Host)) + } + } + var commentsValue string + if l := len(parseComments(entry.commentsRaw)); l > 0 { + commentsValue = strconv.Itoa(l) + } else { + commentsValue = " " + } + table.WriteString(fmt.Sprintf( + ""+ + ""+ + ""+ + "\n", + commitN, entry.commit.Author.When.Format(sgblog.WhenFmt), + makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]), + len(lines)-2, + commentsValue, + strings.Join(domains, " "), + )) + } + table.WriteString("
Comments
NWhenTitleLCLinked to
%d%s%s%d%s%s
") + + var href string + var links []string + var refs bytes.Buffer + if offset > 0 { + if offsetPrev := offset - PageEntries; offsetPrev > 0 { + href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev) + } else { + href = cfg.URLPrefix + "/" + } + links = append(links, ``) + refs.WriteString("\n" + makeA(href, "[prev]")) + } + if !logEnded { + href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries) + links = append(links, ``) + refs.WriteString("\n" + makeA(href, "[next]")) + } + + os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) + out.Write([]byte(startHTML( + 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(*headHash) + if err != nil { + makeErr(err) + } + etagHash.Write([]byte("ATOM")) + 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}, + } + repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) + if err != nil { + makeErr(err) + } + for i := 0; i < PageEntries; i++ { + 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, + 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: "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) + } + for _, data := range etagHashForWeb { + etagHash.Write(data) + } + etagHash.Write([]byte("ENTRY")) + etagHash.Write(commit.Hash[:]) + 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{} + var parent string + if len(commit.ParentHashes) > 0 { + parent = commit.ParentHashes[0].String() + links = append(links, ``) + } + out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links))) + if cfg.AboutURL != "" { + out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about")))) + } + out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index")))) + if parent != "" { + out.Write([]byte(fmt.Sprintf( + "[%s]\n", + makeA(cfg.URLPrefix+"/"+parent, "older"), + ))) + } + out.Write([]byte(fmt.Sprintf( + "[%s]\n"+ + "[%s]\n"+ + "
\n

%s

\n
\n",
+			when, commit.Hash.String(), title,
+		)))
+		for _, line := range lines[2:] {
+			out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
+		}
+		out.Write([]byte("
\n
\n")) + if len(notesRaw) > 0 { + out.Write([]byte("Note:
\n" + string(notesRaw) + "\n
\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",
+				i, i, i,
+			)))
+			lines = strings.Split(comment, "\n")
+			for _, line := range lines[:3] {
+				out.Write([]byte(line + "\n"))
+			}
+			for _, line := range lines[3:] {
+				out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
+			}
+			out.Write([]byte("
\n")) + } + out.Write([]byte("
\n")) + } else { + makeErr(errors.New("unknown URL action")) + } + out.Write([]byte("\n")) + if gzipWriter != nil { + gzipWriter.Close() + } + os.Stdout.Write(outBuf.Bytes()) +} diff --git a/cmd/sgblog/main.go b/cmd/sgblog/main.go index a55411f..f3d009a 100644 --- a/cmd/sgblog/main.go +++ b/cmd/sgblog/main.go @@ -19,30 +19,13 @@ along with this program. If not, see . package main import ( - "bufio" "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/hjson/hjson-go" "go.cypherpunks.ru/netstring/v2" - "go.stargrave.org/sgblog" - "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" @@ -50,32 +33,15 @@ import ( const ( PageEntries = 50 - AtomFeed = "feed.atom" - CRLF = "\r\n" ) var ( sha1DigestRe = regexp.MustCompilePOSIX("([0-9a-f]{40,40})") - defaultLinks = []string{} repo *git.Repository notesTree *object.Tree commentsTree *object.Tree - - renderableSchemes = map[string]struct{}{ - "ftp": struct{}{}, - "gopher": struct{}{}, - "http": struct{}{}, - "https": struct{}{}, - "telnet": struct{}{}, - } - DashLine = strings.Repeat("-", 72) ) -type TableEntry struct { - commit *object.Commit - commentsRaw []byte -} - type Cfg struct { GitPath string Branch string @@ -98,23 +64,6 @@ type Cfg struct { GopherDomain 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 msgSplit(msg string) []string { lines := strings.Split(msg, "\n") lines = lines[:len(lines)-1] @@ -124,20 +73,6 @@ func msgSplit(msg string) []string { return lines } -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 getNote(tree *object.Tree, what plumbing.Hash) []byte { if tree == nil { return nil @@ -186,48 +121,6 @@ func parseComments(data []byte) []string { return comments } -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 - - -`, - sgblog.Version, title, - strings.Join(append(defaultLinks, additional...), "\n "), - ) -} - -func makeErr(err error) { - fmt.Print("Content-Type: text/plain; charset=UTF-8\n\n") - fmt.Println(err) - panic(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 initRepo(cfg *Cfg) (*plumbing.Hash, error) { var err error repo, err = git.PlainOpen(cfg.GitPath) @@ -265,519 +158,6 @@ func initRepo(cfg *Cfg) (*plumbing.Hash, error) { return &headHash, nil } -func serveHTTP() { - cfgPath := os.Getenv("SGBLOG_CFG") - if cfgPath == "" { - log.Fatalln("SGBLOG_CFG is not set") - } - cfgRaw, err := ioutil.ReadFile(cfgPath) - if err != nil { - makeErr(err) - } - var cfgGeneral map[string]interface{} - if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil { - makeErr(err) - } - cfgRaw, err = json.Marshal(cfgGeneral) - if err != nil { - makeErr(err) - } - var cfg *Cfg - if err = json.Unmarshal(cfgRaw, &cfg); err != nil { - makeErr(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) - } - etagHash.Write([]byte("SGBLOG")) - etagHash.Write([]byte(sgblog.Version)) - etagHash.Write([]byte(cfg.GitPath)) - etagHash.Write([]byte(cfg.Branch)) - etagHash.Write([]byte(cfg.Title)) - etagHash.Write([]byte(cfg.URLPrefix)) - etagHash.Write([]byte(cfg.AtomBaseURL)) - etagHash.Write([]byte(cfg.AtomId)) - etagHash.Write([]byte(cfg.AtomAuthor)) - - etagHashForWeb := [][]byte{} - if cfg.CSS != "" { - defaultLinks = append(defaultLinks, ``) - etagHashForWeb = append(etagHashForWeb, []byte(cfg.CSS)) - } - 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.AtomBaseURL + cfg.URLPrefix + "/" + AtomFeed - defaultLinks = append(defaultLinks, ``) - - 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) - } - commitN := 0 - for i := 0; i < offset; i++ { - if _, err = repoLog.Next(); err != nil { - break - } - commitN++ - } - - 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 := repoLog.Next() - if err != nil { - logEnded = true - break - } - etagHash.Write(commit.Hash[:]) - commentsRaw := getNote(commentsTree, commit.Hash) - etagHash.Write(commentsRaw) - entries = append(entries, TableEntry{commit, commentsRaw}) - } - checkETag(etagHash) - - var table bytes.Buffer - table.WriteString( - "\n" + - "\n" + - "" + - "" + - "" + - `` + - `` + - "\n") - for _, entry := range entries { - commitN++ - lines := msgSplit(entry.commit.Message) - domains := []string{} - for _, line := range lines[2:] { - if u := urlParse(line); u == nil { - break - } else { - domains = append(domains, makeA(line, u.Host)) - } - } - var commentsValue string - if l := len(parseComments(entry.commentsRaw)); l > 0 { - commentsValue = strconv.Itoa(l) - } else { - commentsValue = " " - } - table.WriteString(fmt.Sprintf( - ""+ - ""+ - ""+ - "\n", - commitN, entry.commit.Author.When.Format(sgblog.WhenFmt), - makeA(cfg.URLPrefix+"/"+entry.commit.Hash.String(), lines[0]), - len(lines)-2, - commentsValue, - strings.Join(domains, " "), - )) - } - table.WriteString("
Comments
NWhenTitleLCLinked to
%d%s%s%d%s%s
") - - var href string - var links []string - var refs bytes.Buffer - if offset > 0 { - if offsetPrev := offset - PageEntries; offsetPrev > 0 { - href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offsetPrev) - } else { - href = cfg.URLPrefix + "/" - } - links = append(links, ``) - refs.WriteString("\n" + makeA(href, "[prev]")) - } - if !logEnded { - href = cfg.URLPrefix + "/?offset=" + strconv.Itoa(offset+PageEntries) - links = append(links, ``) - refs.WriteString("\n" + makeA(href, "[next]")) - } - - os.Stdout.Write([]byte(startHeader(etagHash, gzipWriter != nil))) - out.Write([]byte(startHTML( - 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(*headHash) - if err != nil { - makeErr(err) - } - etagHash.Write([]byte("ATOM")) - 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}, - } - repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) - if err != nil { - makeErr(err) - } - for i := 0; i < PageEntries; i++ { - 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, - 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: "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) - } - for _, data := range etagHashForWeb { - etagHash.Write(data) - } - etagHash.Write([]byte("ENTRY")) - etagHash.Write(commit.Hash[:]) - 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{} - var parent string - if len(commit.ParentHashes) > 0 { - parent = commit.ParentHashes[0].String() - links = append(links, ``) - } - out.Write([]byte(startHTML(fmt.Sprintf("%s (%s)", title, when), links))) - if cfg.AboutURL != "" { - out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.AboutURL, "about")))) - } - out.Write([]byte(fmt.Sprintf("[%s]\n", makeA(cfg.URLPrefix+"/", "index")))) - if parent != "" { - out.Write([]byte(fmt.Sprintf( - "[%s]\n", - makeA(cfg.URLPrefix+"/"+parent, "older"), - ))) - } - out.Write([]byte(fmt.Sprintf( - "[%s]\n"+ - "[%s]\n"+ - "
\n

%s

\n
\n",
-			when, commit.Hash.String(), title,
-		)))
-		for _, line := range lines[2:] {
-			out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
-		}
-		out.Write([]byte("
\n
\n")) - if len(notesRaw) > 0 { - out.Write([]byte("Note:
\n" + string(notesRaw) + "\n
\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",
-				i, i, i,
-			)))
-			lines = strings.Split(comment, "\n")
-			for _, line := range lines[:3] {
-				out.Write([]byte(line + "\n"))
-			}
-			for _, line := range lines[3:] {
-				out.Write([]byte(lineURLize(cfg.URLPrefix, line) + "\n"))
-			}
-			out.Write([]byte("
\n")) - } - out.Write([]byte("
\n")) - } else { - makeErr(errors.New("unknown URL action")) - } - out.Write([]byte("\n")) - if gzipWriter != nil { - gzipWriter.Close() - } - os.Stdout.Write(outBuf.Bytes()) -} - -func serveGopher() { - cfgPath := os.Args[2] - cfgRaw, err := ioutil.ReadFile(cfgPath) - if err != nil { - log.Fatalln(err) - } - var cfgGeneral map[string]interface{} - if err = hjson.Unmarshal(cfgRaw, &cfgGeneral); err != nil { - log.Fatalln(err) - } - cfgRaw, err = json.Marshal(cfgGeneral) - if err != nil { - log.Fatalln(err) - } - var cfg *Cfg - if err = json.Unmarshal(cfgRaw, &cfg); err != nil { - log.Fatalln(err) - } - if cfg.GopherDomain == "" { - log.Fatalln("GopherDomain is not configured") - } - - headHash, err := initRepo(cfg) - if err != nil { - log.Fatalln(err) - } - - scanner := bufio.NewScanner(io.LimitReader(os.Stdin, 1<<8)) - if !scanner.Scan() { - log.Fatalln(errors.New("no CRLF found")) - } - selector := scanner.Text() - if selector == "" { - selector = "offset/0" - } - if strings.HasPrefix(selector, "offset/") { - offset, err := strconv.Atoi(selector[len("offset/"):]) - if err != nil { - log.Fatalln(err) - } - repoLog, err := repo.Log(&git.LogOptions{From: *headHash}) - if err != nil { - log.Fatalln(err) - } - commitN := 0 - for i := 0; i < offset; i++ { - if _, err = repoLog.Next(); err != nil { - break - } - commitN++ - } - - logEnded := false - var menu bytes.Buffer - for i := 0; i < PageEntries; i++ { - commit, err := repoLog.Next() - if err != nil { - logEnded = true - break - } - commitN++ - lines := msgSplit(commit.Message) - - var commentsValue string - if l := len(parseComments(getNote(commentsTree, commit.Hash))); l > 0 { - commentsValue = fmt.Sprintf(" (%dC)", l) - } - menu.WriteString(fmt.Sprintf( - "0[%s] %s (%dL)%s\t/%s\t%s\t%d%s", - commit.Author.When.Format(sgblog.WhenFmt), - lines[0], - len(lines)-2, - commentsValue, - commit.Hash.String(), - cfg.GopherDomain, 70, CRLF, - )) - } - - var links bytes.Buffer - if offset > 0 { - offsetPrev := offset - PageEntries - if offsetPrev < 0 { - offsetPrev = 0 - } - links.WriteString(fmt.Sprintf( - "1Prev\toffset/%d\t%s\t%d%s", - offsetPrev, - cfg.GopherDomain, 70, CRLF, - )) - } - if !logEnded { - links.WriteString(fmt.Sprintf( - "1Next\toffset/%d\t%s\t%d%s", - offset+PageEntries, - cfg.GopherDomain, 70, CRLF, - )) - } - - fmt.Printf( - "i%s (%d-%d)\t\tnull.host\t1%s", - cfg.Title, - offset, - offset+PageEntries, - CRLF, - ) - if cfg.AboutURL != "" { - fmt.Printf("iAbout: %s\t\tnull.host\t1%s", cfg.AboutURL, CRLF) - } - fmt.Print(links.String()) - fmt.Print(menu.String()) - fmt.Print("." + CRLF) - } else if sha1DigestRe.MatchString(selector) { - commit, err := repo.CommitObject(plumbing.NewHash(selector[1:])) - if err != nil { - log.Fatalln(err) - } - fmt.Printf( - "What: %s\nWhen: %s\n%s\n%s", - commit.Hash.String(), - commit.Author.When.Format(sgblog.WhenFmt), - DashLine, - commit.Message, - ) - notesRaw := getNote(notesTree, commit.Hash) - if len(notesRaw) > 0 { - fmt.Printf("%s\nNote:\n%s\n", DashLine, string(notesRaw)) - } - for i, comment := range parseComments(getNote(commentsTree, commit.Hash)) { - fmt.Printf("%s\ncomment %d:\n%s\n", DashLine, i, comment) - } - } else { - log.Fatalln(errors.New("unknown selector")) - } -} - func main() { if len(os.Args) == 3 && os.Args[1] == "-gopher" { serveGopher() -- 2.44.0