aboutsummaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorRunxi Yu <me@runxiyu.org>2025-04-05 21:27:17 +0800
committerRunxi Yu <me@runxiyu.org>2025-04-05 21:27:17 +0800
commite0635b47c2f30719e1ea026812af85c988632c0e (patch)
treef112904c018dc294cf6f902423745f1f1932449c /internal
parentExport symbols from database.go (diff)
downloadforge-e0635b47c2f30719e1ea026812af85c988632c0e.tar.gz
forge-e0635b47c2f30719e1ea026812af85c988632c0e.tar.zst
forge-e0635b47c2f30719e1ea026812af85c988632c0e.zip
Move things to internal/v0.1.23
Diffstat (limited to 'internal')
-rw-r--r--internal/ansiec/colors.go23
-rw-r--r--internal/ansiec/reset.go3
-rw-r--r--internal/ansiec/style.go8
-rw-r--r--internal/git2c/client.go39
-rw-r--r--internal/git2c/cmd1.go60
-rw-r--r--internal/git2c/cmd2.go89
-rw-r--r--internal/git2c/git_types.go22
-rw-r--r--internal/misc/misc.go21
-rw-r--r--internal/misc/unsafe.go20
-rw-r--r--internal/misc/url.go154
-rw-r--r--internal/render/chroma.go35
-rw-r--r--internal/render/escape.go11
-rw-r--r--internal/render/readme.go41
13 files changed, 526 insertions, 0 deletions
diff --git a/internal/ansiec/colors.go b/internal/ansiec/colors.go
new file mode 100644
index 0000000..fa8ea4f
--- /dev/null
+++ b/internal/ansiec/colors.go
@@ -0,0 +1,23 @@
+package ansiec
+
+var (
+ Black = "\x1b[30m"
+ Red = "\x1b[31m"
+ Green = "\x1b[32m"
+ Yellow = "\x1b[33m"
+ Blue = "\x1b[34m"
+ Magenta = "\x1b[35m"
+ Cyan = "\x1b[36m"
+ White = "\x1b[37m"
+)
+
+var (
+ BrightBlack = "\x1b[30;1m"
+ BrightRed = "\x1b[31;1m"
+ BrightGreen = "\x1b[32;1m"
+ BrightYellow = "\x1b[33;1m"
+ BrightBlue = "\x1b[34;1m"
+ BrightMagenta = "\x1b[35;1m"
+ BrightCyan = "\x1b[36;1m"
+ BrightWhite = "\x1b[37;1m"
+)
diff --git a/internal/ansiec/reset.go b/internal/ansiec/reset.go
new file mode 100644
index 0000000..82a56d5
--- /dev/null
+++ b/internal/ansiec/reset.go
@@ -0,0 +1,3 @@
+package ansiec
+
+var Reset = "\x1b[0m"
diff --git a/internal/ansiec/style.go b/internal/ansiec/style.go
new file mode 100644
index 0000000..18050df
--- /dev/null
+++ b/internal/ansiec/style.go
@@ -0,0 +1,8 @@
+package ansiec
+
+var (
+ Bold = "\x1b[1m"
+ Underline = "\x1b[4m"
+ Reversed = "\x1b[7m"
+ Italic = "\x1b[3m"
+)
diff --git a/internal/git2c/client.go b/internal/git2c/client.go
new file mode 100644
index 0000000..c4c3ab4
--- /dev/null
+++ b/internal/git2c/client.go
@@ -0,0 +1,39 @@
+package git2c
+
+import (
+ "fmt"
+ "net"
+
+ "git.sr.ht/~sircmpwn/go-bare"
+)
+
+type Client struct {
+ SocketPath string
+ conn net.Conn
+ writer *bare.Writer
+ reader *bare.Reader
+}
+
+func NewClient(socketPath string) (*Client, error) {
+ conn, err := net.Dial("unix", socketPath)
+ if err != nil {
+ return nil, fmt.Errorf("git2d connection failed: %w", err)
+ }
+
+ writer := bare.NewWriter(conn)
+ reader := bare.NewReader(conn)
+
+ return &Client{
+ SocketPath: socketPath,
+ conn: conn,
+ writer: writer,
+ reader: reader,
+ }, nil
+}
+
+func (c *Client) Close() error {
+ if c.conn != nil {
+ return c.conn.Close()
+ }
+ return nil
+}
diff --git a/internal/git2c/cmd1.go b/internal/git2c/cmd1.go
new file mode 100644
index 0000000..ba59b5a
--- /dev/null
+++ b/internal/git2c/cmd1.go
@@ -0,0 +1,60 @@
+package git2c
+
+import (
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+)
+
+func (c *Client) Cmd1(repoPath string) ([]Commit, *FilenameContents, error) {
+ if err := c.writer.WriteData([]byte(repoPath)); err != nil {
+ return nil, nil, fmt.Errorf("sending repo path failed: %w", err)
+ }
+ if err := c.writer.WriteUint(1); err != nil {
+ return nil, nil, fmt.Errorf("sending command failed: %w", err)
+ }
+
+ status, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, nil, fmt.Errorf("reading status failed: %w", err)
+ }
+ if status != 0 {
+ return nil, nil, fmt.Errorf("git2d error: %d", status)
+ }
+
+ // README
+ readmeRaw, err := c.reader.ReadData()
+ if err != nil {
+ readmeRaw = nil
+ }
+
+ readmeFilename := "README.md" // TODO
+ readme := &FilenameContents{Filename: readmeFilename, Content: readmeRaw}
+
+ // Commits
+ var commits []Commit
+ for {
+ id, err := c.reader.ReadData()
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return nil, nil, fmt.Errorf("reading commit ID failed: %w", err)
+ }
+ title, _ := c.reader.ReadData()
+ authorName, _ := c.reader.ReadData()
+ authorEmail, _ := c.reader.ReadData()
+ authorDate, _ := c.reader.ReadData()
+
+ commits = append(commits, Commit{
+ Hash: hex.EncodeToString(id),
+ Author: string(authorName),
+ Email: string(authorEmail),
+ Date: string(authorDate),
+ Message: string(title),
+ })
+ }
+
+ return commits, readme, nil
+}
diff --git a/internal/git2c/cmd2.go b/internal/git2c/cmd2.go
new file mode 100644
index 0000000..c688dd2
--- /dev/null
+++ b/internal/git2c/cmd2.go
@@ -0,0 +1,89 @@
+package git2c
+
+import (
+ "errors"
+ "fmt"
+ "io"
+)
+
+func (c *Client) Cmd2(repoPath, pathSpec string) ([]TreeEntry, string, error) {
+ if err := c.writer.WriteData([]byte(repoPath)); err != nil {
+ return nil, "", fmt.Errorf("sending repo path failed: %w", err)
+ }
+ if err := c.writer.WriteUint(2); err != nil {
+ return nil, "", fmt.Errorf("sending command failed: %w", err)
+ }
+ if err := c.writer.WriteData([]byte(pathSpec)); err != nil {
+ return nil, "", fmt.Errorf("sending path failed: %w", err)
+ }
+
+ status, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, "", fmt.Errorf("reading status failed: %w", err)
+ }
+
+ switch status {
+ case 0:
+ kind, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, "", fmt.Errorf("reading object kind failed: %w", err)
+ }
+
+ switch kind {
+ case 1:
+ // Tree
+ count, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, "", fmt.Errorf("reading entry count failed: %w", err)
+ }
+
+ var files []TreeEntry
+ for range count {
+ typeCode, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, "", fmt.Errorf("error reading entry type: %w", err)
+ }
+ mode, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, "", fmt.Errorf("error reading entry mode: %w", err)
+ }
+ size, err := c.reader.ReadUint()
+ if err != nil {
+ return nil, "", fmt.Errorf("error reading entry size: %w", err)
+ }
+ name, err := c.reader.ReadData()
+ if err != nil {
+ return nil, "", fmt.Errorf("error reading entry name: %w", err)
+ }
+
+ files = append(files, TreeEntry{
+ Name: string(name),
+ Mode: fmt.Sprintf("%06o", mode),
+ Size: size,
+ IsFile: typeCode == 2,
+ IsSubtree: typeCode == 1,
+ })
+ }
+
+ return files, "", nil
+
+ case 2:
+ // Blob
+ content, err := c.reader.ReadData()
+ if err != nil && !errors.Is(err, io.EOF) {
+ return nil, "", fmt.Errorf("error reading file content: %w", err)
+ }
+
+ return nil, string(content), nil
+
+ default:
+ return nil, "", fmt.Errorf("unknown kind: %d", kind)
+ }
+
+ case 3:
+ return nil, "", fmt.Errorf("path not found: %s", pathSpec)
+
+ default:
+ return nil, "", fmt.Errorf("unknown status code: %d", status)
+ }
+}
diff --git a/internal/git2c/git_types.go b/internal/git2c/git_types.go
new file mode 100644
index 0000000..da90db6
--- /dev/null
+++ b/internal/git2c/git_types.go
@@ -0,0 +1,22 @@
+package git2c
+
+type Commit struct {
+ Hash string
+ Author string
+ Email string
+ Date string
+ Message string
+}
+
+type FilenameContents struct {
+ Filename string
+ Content []byte
+}
+
+type TreeEntry struct {
+ Name string
+ Mode string
+ Size uint64
+ IsFile bool
+ IsSubtree bool
+}
diff --git a/internal/misc/misc.go b/internal/misc/misc.go
new file mode 100644
index 0000000..ee0fd7a
--- /dev/null
+++ b/internal/misc/misc.go
@@ -0,0 +1,21 @@
+package misc
+
+import "strings"
+
+func FirstOrPanic[T any](v T, err error) T {
+ if err != nil {
+ panic(err)
+ }
+ return v
+}
+
+// sliceContainsNewlines returns true if and only if the given slice contains
+// one or more strings that contains newlines.
+func SliceContainsNewlines(s []string) bool {
+ for _, v := range s {
+ if strings.Contains(v, "\n") {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/misc/unsafe.go b/internal/misc/unsafe.go
new file mode 100644
index 0000000..6c2192f
--- /dev/null
+++ b/internal/misc/unsafe.go
@@ -0,0 +1,20 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+package misc
+
+import "unsafe"
+
+// StringToBytes converts a string to a byte slice without copying the string.
+// Memory is borrowed from the string.
+// The resulting byte slice must not be modified in any form.
+func StringToBytes(s string) (bytes []byte) {
+ return unsafe.Slice(unsafe.StringData(s), len(s))
+}
+
+// BytesToString converts a byte slice to a string without copying the bytes.
+// Memory is borrowed from the byte slice.
+// The source byte slice must not be modified.
+func BytesToString(b []byte) string {
+ return unsafe.String(unsafe.SliceData(b), len(b))
+}
diff --git a/internal/misc/url.go b/internal/misc/url.go
new file mode 100644
index 0000000..b77d8ce
--- /dev/null
+++ b/internal/misc/url.go
@@ -0,0 +1,154 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+package misc
+
+import (
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+)
+
+var (
+ ErrDupRefSpec = errors.New("duplicate ref spec")
+ ErrNoRefSpec = errors.New("no ref spec")
+)
+
+// getParamRefTypeName looks at the query parameters in an HTTP request and
+// returns its ref name and type, if any.
+func GetParamRefTypeName(request *http.Request) (retRefType, retRefName string, err error) {
+ rawQuery := request.URL.RawQuery
+ queryValues, err := url.ParseQuery(rawQuery)
+ if err != nil {
+ return
+ }
+ done := false
+ for _, refType := range []string{"commit", "branch", "tag"} {
+ refName, ok := queryValues[refType]
+ if ok {
+ if done {
+ err = ErrDupRefSpec
+ return
+ }
+ done = true
+ if len(refName) != 1 {
+ err = ErrDupRefSpec
+ return
+ }
+ retRefName = refName[0]
+ retRefType = refType
+ }
+ }
+ if !done {
+ err = ErrNoRefSpec
+ }
+ return
+}
+
+// ParseReqURI parses an HTTP request URL, and returns a slice of path segments
+// and the query parameters. It handles %2F correctly.
+func ParseReqURI(requestURI string) (segments []string, params url.Values, err error) {
+ path, paramsStr, _ := strings.Cut(requestURI, "?")
+
+ segments, err = PathToSegments(path)
+ if err != nil {
+ return
+ }
+
+ params, err = url.ParseQuery(paramsStr)
+ return
+}
+
+func PathToSegments(path string) (segments []string, err error) {
+ segments = strings.Split(strings.TrimPrefix(path, "/"), "/")
+
+ for i, segment := range segments {
+ segments[i], err = url.PathUnescape(segment)
+ if err != nil {
+ return
+ }
+ }
+
+ return
+}
+
+// RedirectDir returns true and redirects the user to a version of the URL with
+// a trailing slash, if and only if the request URL does not already have a
+// trailing slash.
+func RedirectDir(writer http.ResponseWriter, request *http.Request) bool {
+ requestURI := request.RequestURI
+
+ pathEnd := strings.IndexAny(requestURI, "?#")
+ var path, rest string
+ if pathEnd == -1 {
+ path = requestURI
+ } else {
+ path = requestURI[:pathEnd]
+ rest = requestURI[pathEnd:]
+ }
+
+ if !strings.HasSuffix(path, "/") {
+ http.Redirect(writer, request, path+"/"+rest, http.StatusSeeOther)
+ return true
+ }
+ return false
+}
+
+// RedirectNoDir returns true and redirects the user to a version of the URL
+// without a trailing slash, if and only if the request URL has a trailing
+// slash.
+func RedirectNoDir(writer http.ResponseWriter, request *http.Request) bool {
+ requestURI := request.RequestURI
+
+ pathEnd := strings.IndexAny(requestURI, "?#")
+ var path, rest string
+ if pathEnd == -1 {
+ path = requestURI
+ } else {
+ path = requestURI[:pathEnd]
+ rest = requestURI[pathEnd:]
+ }
+
+ if strings.HasSuffix(path, "/") {
+ http.Redirect(writer, request, strings.TrimSuffix(path, "/")+rest, http.StatusSeeOther)
+ return true
+ }
+ return false
+}
+
+// RedirectUnconditionally unconditionally redirects the user back to the
+// current page while preserving query parameters.
+func RedirectUnconditionally(writer http.ResponseWriter, request *http.Request) {
+ requestURI := request.RequestURI
+
+ pathEnd := strings.IndexAny(requestURI, "?#")
+ var path, rest string
+ if pathEnd == -1 {
+ path = requestURI
+ } else {
+ path = requestURI[:pathEnd]
+ rest = requestURI[pathEnd:]
+ }
+
+ http.Redirect(writer, request, path+rest, http.StatusSeeOther)
+}
+
+// SegmentsToURL joins URL segments to the path component of a URL.
+// Each segment is escaped properly first.
+func SegmentsToURL(segments []string) string {
+ for i, segment := range segments {
+ segments[i] = url.PathEscape(segment)
+ }
+ return strings.Join(segments, "/")
+}
+
+// AnyContain returns true if and only if ss contains a string that contains c.
+func AnyContain(ss []string, c string) bool {
+ for _, s := range ss {
+ if strings.Contains(s, c) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/render/chroma.go b/internal/render/chroma.go
new file mode 100644
index 0000000..c7d64ec
--- /dev/null
+++ b/internal/render/chroma.go
@@ -0,0 +1,35 @@
+package render
+
+import (
+ "bytes"
+ "html/template"
+
+ chromaHTML "github.com/alecthomas/chroma/v2/formatters/html"
+ chromaLexers "github.com/alecthomas/chroma/v2/lexers"
+ chromaStyles "github.com/alecthomas/chroma/v2/styles"
+)
+
+func Highlight(filename, content string) template.HTML {
+ lexer := chromaLexers.Match(filename)
+ if lexer == nil {
+ lexer = chromaLexers.Fallback
+ }
+
+ iterator, err := lexer.Tokenise(nil, content)
+ if err != nil {
+ return template.HTML("<pre>Error tokenizing file: " + err.Error() + "</pre>") //#nosec G203`
+ }
+
+ var buf bytes.Buffer
+ style := chromaStyles.Get("autumn")
+ formatter := chromaHTML.New(
+ chromaHTML.WithClasses(true),
+ chromaHTML.TabWidth(8),
+ )
+
+ if err := formatter.Format(&buf, style, iterator); err != nil {
+ return template.HTML("<pre>Error formatting file: " + err.Error() + "</pre>") //#nosec G203
+ }
+
+ return template.HTML(buf.Bytes()) //#nosec G203
+}
diff --git a/internal/render/escape.go b/internal/render/escape.go
new file mode 100644
index 0000000..44c56f3
--- /dev/null
+++ b/internal/render/escape.go
@@ -0,0 +1,11 @@
+package render
+
+import (
+ "html"
+ "html/template"
+)
+
+// EscapeHTML just escapes a string and wraps it in [template.HTML].
+func EscapeHTML(s string) template.HTML {
+ return template.HTML(html.EscapeString(s)) //#nosec G203
+}
diff --git a/internal/render/readme.go b/internal/render/readme.go
new file mode 100644
index 0000000..aecd2f7
--- /dev/null
+++ b/internal/render/readme.go
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: AGPL-3.0-only
+// SPDX-FileCopyrightText: Copyright (c) 2025 Runxi Yu <https://runxiyu.org>
+
+package render
+
+import (
+ "bytes"
+ "html"
+ "html/template"
+ "strings"
+
+ "github.com/microcosm-cc/bluemonday"
+ "github.com/niklasfasching/go-org/org"
+ "github.com/yuin/goldmark"
+ "github.com/yuin/goldmark/extension"
+ "go.lindenii.runxiyu.org/forge/internal/misc"
+)
+
+var markdownConverter = goldmark.New(goldmark.WithExtensions(extension.GFM))
+
+// renderReadme renders and sanitizes README content from a byte slice and filename.
+func Readme(data []byte, filename string) (string, template.HTML) {
+ switch strings.ToLower(filename) {
+ case "readme":
+ return "README", template.HTML("<pre>" + html.EscapeString(misc.BytesToString(data)) + "</pre>") //#nosec G203
+ case "readme.md":
+ var buf bytes.Buffer
+ if err := markdownConverter.Convert(data, &buf); err != nil {
+ return "Error fetching README", EscapeHTML("Unable to render README: " + err.Error())
+ }
+ return "README.md", template.HTML(bluemonday.UGCPolicy().SanitizeBytes(buf.Bytes())) //#nosec G203
+ case "readme.org":
+ htmlStr, err := org.New().Parse(strings.NewReader(misc.BytesToString(data)), filename).Write(org.NewHTMLWriter())
+ if err != nil {
+ return "Error fetching README", EscapeHTML("Unable to render README: " + err.Error())
+ }
+ return "README.org", template.HTML(bluemonday.UGCPolicy().Sanitize(htmlStr)) //#nosec G203
+ default:
+ return filename, template.HTML("<pre>" + html.EscapeString(misc.BytesToString(data)) + "</pre>") //#nosec G203
+ }
+}