diff options
author | Runxi Yu <me@runxiyu.org> | 2025-04-05 21:27:17 +0800 |
---|---|---|
committer | Runxi Yu <me@runxiyu.org> | 2025-04-05 21:27:17 +0800 |
commit | e0635b47c2f30719e1ea026812af85c988632c0e (patch) | |
tree | f112904c018dc294cf6f902423745f1f1932449c /internal | |
parent | Export symbols from database.go (diff) | |
download | forge-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.go | 23 | ||||
-rw-r--r-- | internal/ansiec/reset.go | 3 | ||||
-rw-r--r-- | internal/ansiec/style.go | 8 | ||||
-rw-r--r-- | internal/git2c/client.go | 39 | ||||
-rw-r--r-- | internal/git2c/cmd1.go | 60 | ||||
-rw-r--r-- | internal/git2c/cmd2.go | 89 | ||||
-rw-r--r-- | internal/git2c/git_types.go | 22 | ||||
-rw-r--r-- | internal/misc/misc.go | 21 | ||||
-rw-r--r-- | internal/misc/unsafe.go | 20 | ||||
-rw-r--r-- | internal/misc/url.go | 154 | ||||
-rw-r--r-- | internal/render/chroma.go | 35 | ||||
-rw-r--r-- | internal/render/escape.go | 11 | ||||
-rw-r--r-- | internal/render/readme.go | 41 |
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 + } +} |