aboutsummaryrefslogtreecommitdiff
path: root/pkg
diff options
context:
space:
mode:
Diffstat (limited to 'pkg')
-rw-r--r--pkg/config/config.go219
-rw-r--r--pkg/config/config_test.go53
-rw-r--r--pkg/ext/auth.go85
-rw-r--r--pkg/ext/compression.go31
-rw-r--r--pkg/ext/log.go4
-rw-r--r--pkg/ext/request.go14
-rw-r--r--pkg/ext/router.go42
-rw-r--r--pkg/git/git.go265
-rw-r--r--pkg/handler/about/handler.go5
-rw-r--r--pkg/handler/auth/login.go97
-rw-r--r--pkg/handler/config/handler.go63
-rw-r--r--pkg/handler/git/handler.go193
-rw-r--r--pkg/handler/router.go23
-rw-r--r--pkg/handler/static/handler.go4
-rw-r--r--pkg/humanize/times.go141
-rw-r--r--pkg/service/auth.go123
-rw-r--r--pkg/service/auth_test.go119
-rw-r--r--pkg/service/git.go87
-rw-r--r--pkg/u/list.go22
19 files changed, 1389 insertions, 201 deletions
diff --git a/pkg/config/config.go b/pkg/config/config.go
index fd19808..c00586b 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"io"
+ "log/slog"
"os"
"path"
"path/filepath"
"strconv"
+ "strings"
"git.gabrielgio.me/cerrado/pkg/u"
"git.sr.ht/~emersion/go-scfg"
@@ -19,6 +21,16 @@ var (
ErrInvalidProperty = errors.New("Invalid property")
)
+type OrderBy int
+
+const (
+ Unordered OrderBy = iota
+ AlphabeticalAsc
+ AlphabeticalDesc
+ LastCommitAsc
+ LastCommitDesc
+)
+
type (
// scan represents piece of the scan from the configuration file.
@@ -30,10 +42,16 @@ type (
// configuration represents file configuration.
// fields needs to be exported to cmp to work
configuration struct {
- Scan *scan
- RootReadme string
- ListenAddr string
- Repositories []*GitRepositoryConfiguration
+ AESKey string
+ ListenAddr string
+ OrderBy string
+ Passphrase string
+ RemoveSuffix bool
+ Repositories []*GitRepositoryConfiguration
+ RootReadme string
+ Scans []*scan
+ SyntaxHighlight string
+ Hostname string
}
// This is a per repository configuration.
@@ -50,9 +68,15 @@ type (
// This holds all the function necessary to ask for configuration
// information.
ConfigurationRepository struct {
- rootReadme string
- listenAddr string
- repositories []*GitRepositoryConfiguration
+ aesKey []byte
+ listenAddr string
+ orderBy OrderBy
+ passphrase []byte
+ removeSuffix bool
+ repositories []*GitRepositoryConfiguration
+ rootReadme string
+ syntaxHighlight string
+ hostname string
}
)
@@ -68,20 +92,27 @@ func LoadConfigurationRepository(configPath string) (*ConfigurationRepository, e
}
repo := &ConfigurationRepository{
- rootReadme: config.RootReadme,
- listenAddr: config.ListenAddr,
- repositories: config.Repositories,
+ aesKey: []byte(config.AESKey),
+ listenAddr: config.ListenAddr,
+ passphrase: []byte(config.Passphrase),
+ repositories: config.Repositories,
+ rootReadme: config.RootReadme,
+ hostname: config.Hostname,
+ syntaxHighlight: config.SyntaxHighlight,
+ removeSuffix: config.RemoveSuffix,
+ orderBy: parseOrderBy(config.OrderBy),
}
- if config.Scan.Path != "" {
- err = repo.expandOnScanPath(config.Scan.Path, config.Scan.Public)
- if err != nil {
- return nil, err
+ for _, scan := range config.Scans {
+ if scan.Path != "" {
+ err = repo.expandOnScanPath(scan.Path, scan.Public)
+ if err != nil {
+ return nil, err
+ }
}
}
return repo, nil
-
}
// GetRootReadme returns root read path
@@ -89,10 +120,34 @@ func (c *ConfigurationRepository) GetRootReadme() string {
return c.rootReadme
}
+func (c *ConfigurationRepository) GetHostname() string {
+ return c.hostname
+}
+
+func (c *ConfigurationRepository) GetOrderBy() OrderBy {
+ return c.orderBy
+}
+
+func (c *ConfigurationRepository) GetSyntaxHighlight() string {
+ return c.syntaxHighlight
+}
+
func (c *ConfigurationRepository) GetListenAddr() string {
return c.listenAddr
}
+func (c *ConfigurationRepository) GetPassphrase() []byte {
+ return c.passphrase
+}
+
+func (c *ConfigurationRepository) GetBase64AesKey() []byte {
+ return c.aesKey
+}
+
+func (c *ConfigurationRepository) IsAuthEnabled() bool {
+ return len(c.passphrase) != 0
+}
+
// GetByName returns configuration of repository for a given name.
// It returns nil if there is not match for it.
func (c *ConfigurationRepository) GetByName(name string) *GitRepositoryConfiguration {
@@ -128,8 +183,14 @@ func (c *ConfigurationRepository) expandOnScanPath(scanPath string, public bool)
fullPath := path.Join(scanPath, e.Name())
if !c.repoExits(fullPath) {
+
+ name := e.Name()
+ if c.removeSuffix {
+ name = strings.TrimSuffix(name, ".git")
+ }
+
c.repositories = append(c.repositories, &GitRepositoryConfiguration{
- Name: e.Name(),
+ Name: name,
Path: fullPath,
Public: public,
})
@@ -155,7 +216,7 @@ func parse(r io.Reader) (*configuration, error) {
config := defaultConfiguration()
- err = setScan(block, config.Scan)
+ err = setScan(block, &config.Scans)
if err != nil {
return nil, err
}
@@ -165,11 +226,41 @@ func parse(r io.Reader) (*configuration, error) {
return nil, err
}
+ err = setHostname(block, &config.Hostname)
+ if err != nil {
+ return nil, err
+ }
+
err = setListenAddr(block, &config.ListenAddr)
if err != nil {
return nil, err
}
+ err = setPassphrase(block, &config.Passphrase)
+ if err != nil {
+ return nil, err
+ }
+
+ err = setAESKey(block, &config.AESKey)
+ if err != nil {
+ return nil, err
+ }
+
+ err = setSyntaxHighlight(block, &config.SyntaxHighlight)
+ if err != nil {
+ return nil, err
+ }
+
+ err = setOrderby(block, &config.OrderBy)
+ if err != nil {
+ return nil, err
+ }
+
+ err = setRemoveSuffix(block, &config.RemoveSuffix)
+ if err != nil {
+ return nil, err
+ }
+
err = setRepositories(block, &config.Repositories)
if err != nil {
return nil, err
@@ -230,18 +321,20 @@ func setRepositories(block scfg.Block, repositories *[]*GitRepositoryConfigurati
func defaultConfiguration() *configuration {
return &configuration{
- Scan: defaultScan(),
+ Scans: defaultScans(),
RootReadme: "",
+ Hostname: defaultHostname(),
ListenAddr: defaultAddr(),
Repositories: make([]*GitRepositoryConfiguration, 0),
}
}
-func defaultScan() *scan {
- return &scan{
- Public: false,
- Path: "",
- }
+func defaultHostname() string {
+ return "https://localhost:8080"
+}
+
+func defaultScans() []*scan {
+ return []*scan{}
}
func defaultAddr() string {
@@ -263,23 +356,62 @@ func setRootReadme(block scfg.Block, readme *string) error {
return setString(scanDir, readme)
}
+func setHostname(block scfg.Block, hostname *string) error {
+ scanDir := block.Get("hostname")
+ return setString(scanDir, hostname)
+}
+
+func setPassphrase(block scfg.Block, listenAddr *string) error {
+ scanDir := block.Get("passphrase")
+ return setString(scanDir, listenAddr)
+}
+
+func setAESKey(block scfg.Block, listenAddr *string) error {
+ scanDir := block.Get("aes-key")
+ return setString(scanDir, listenAddr)
+}
+
+func setSyntaxHighlight(block scfg.Block, listenAddr *string) error {
+ scanDir := block.Get("syntax-highlight")
+ return setString(scanDir, listenAddr)
+}
+
+func setOrderby(block scfg.Block, orderBy *string) error {
+ scanDir := block.Get("order-by")
+ return setString(scanDir, orderBy)
+}
+
func setListenAddr(block scfg.Block, listenAddr *string) error {
scanDir := block.Get("listen-addr")
return setString(scanDir, listenAddr)
}
-func setScan(block scfg.Block, scan *scan) error {
- scanDir := block.Get("scan")
- if scanDir == nil {
- return nil
- }
- err := setString(scanDir, &scan.Path)
- if err != nil {
- return err
+func setRemoveSuffix(block scfg.Block, remove *bool) error {
+ scanDir := block.Get("remove-suffix")
+ return setBool(scanDir, remove)
+}
+
+func setScan(block scfg.Block, scans *[]*scan) error {
+ for _, scanDir := range block.GetAll("scan") {
+ s := &scan{}
+ if scanDir == nil {
+ return nil
+ }
+ err := setString(scanDir, &s.Path)
+ if err != nil {
+ return err
+ }
+
+ public := scanDir.Children.Get("public")
+ err = setBool(public, &s.Public)
+ if err != nil {
+ return err
+ }
+
+ *scans = append(*scans, s)
}
- public := scanDir.Children.Get("public")
- return setBool(public, &scan.Public)
+ return nil
}
func setBool(dir *scfg.Directive, field *bool) error {
@@ -301,3 +433,24 @@ func setString(dir *scfg.Directive, field *string) error {
}
return nil
}
+
+func parseOrderBy(s string) OrderBy {
+ switch s {
+ case "":
+ return LastCommitAsc
+ case "unordered":
+ return Unordered
+ case "alphabetical-asc":
+ return AlphabeticalAsc
+ case "alphabetical-desc":
+ return AlphabeticalDesc
+ case "lastcommit-asc":
+ return LastCommitAsc
+ case "lastcommit-desc":
+ return LastCommitDesc
+ default:
+ slog.Warn("Invalid order-by using default unordered")
+ return LastCommitAsc
+
+ }
+}
diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go
index 8c1d27e..31cf1c0 100644
--- a/pkg/config/config_test.go
+++ b/pkg/config/config_test.go
@@ -18,11 +18,14 @@ func TestFileParsing(t *testing.T) {
name: "minimal scan",
config: `scan "/srv/git"`,
expectedConfig: &configuration{
- Scan: &scan{
- Public: false,
- Path: "/srv/git",
+ Scans: []*scan{
+ {
+ Public: false,
+ Path: "/srv/git",
+ },
},
ListenAddr: defaultAddr(),
+ Hostname: defaultHostname(),
Repositories: []*GitRepositoryConfiguration{},
},
},
@@ -33,11 +36,14 @@ scan "/srv/git" {
public true
}`,
expectedConfig: &configuration{
- Scan: &scan{
- Public: true,
- Path: "/srv/git",
+ Scans: []*scan{
+ {
+ Public: true,
+ Path: "/srv/git",
+ },
},
ListenAddr: defaultAddr(),
+ Hostname: defaultHostname(),
Repositories: []*GitRepositoryConfiguration{},
},
},
@@ -45,8 +51,9 @@ scan "/srv/git" {
name: "minimal repository",
config: `repository /srv/git/cerrado.git`,
expectedConfig: &configuration{
- Scan: defaultScan(),
+ Scans: defaultScans(),
ListenAddr: defaultAddr(),
+ Hostname: defaultHostname(),
Repositories: []*GitRepositoryConfiguration{
{
Name: "cerrado.git",
@@ -68,8 +75,9 @@ repository /srv/git/cerrado.git {
about readme.txt
}`,
expectedConfig: &configuration{
- Scan: defaultScan(),
+ Scans: defaultScans(),
ListenAddr: defaultAddr(),
+ Hostname: defaultHostname(),
Repositories: []*GitRepositoryConfiguration{
{
Name: "cerrado",
@@ -85,8 +93,9 @@ repository /srv/git/cerrado.git {
name: "minimal listen",
config: ``,
expectedConfig: &configuration{
- Scan: defaultScan(),
+ Scans: defaultScans(),
ListenAddr: defaultAddr(),
+ Hostname: defaultHostname(),
Repositories: []*GitRepositoryConfiguration{},
},
},
@@ -94,7 +103,8 @@ repository /srv/git/cerrado.git {
name: "complete listen",
config: `listen-addr unix://var/run/cerrado/cerrado.sock`,
expectedConfig: &configuration{
- Scan: defaultScan(),
+ Scans: defaultScans(),
+ Hostname: defaultHostname(),
ListenAddr: "unix://var/run/cerrado/cerrado.sock",
Repositories: []*GitRepositoryConfiguration{},
},
@@ -103,6 +113,12 @@ repository /srv/git/cerrado.git {
name: "complete",
config: `
listen-addr unix://var/run/cerrado/cerrado.sock
+passphrase $2a$14$VnB/ZcB1DUDkMnosRA6Y7.dj8h5eroslDxTeXlLwfQX/x86mh6WAq
+aes-key 8XHptZxSWCGs1m7QzztX5zNQ7D9NiQevVX0DaUTNMbDpRwFzoJiB0U7K6O/kqIt01jJVgzBUfiR8ES46ZLLb4w==
+syntax-highlight monokailight
+order-by lastcommit-desc
+remove-suffix true
+hostname https://domain.tld
scan "/srv/git" {
public true
@@ -117,11 +133,19 @@ repository /srv/git/cerrado.git {
about readme.txt
}`,
expectedConfig: &configuration{
- Scan: &scan{
- Public: true,
- Path: "/srv/git",
+ Scans: []*scan{
+ {
+ Public: true,
+ Path: "/srv/git",
+ },
},
- ListenAddr: "unix://var/run/cerrado/cerrado.sock",
+ ListenAddr: "unix://var/run/cerrado/cerrado.sock",
+ Passphrase: "$2a$14$VnB/ZcB1DUDkMnosRA6Y7.dj8h5eroslDxTeXlLwfQX/x86mh6WAq",
+ AESKey: "8XHptZxSWCGs1m7QzztX5zNQ7D9NiQevVX0DaUTNMbDpRwFzoJiB0U7K6O/kqIt01jJVgzBUfiR8ES46ZLLb4w==",
+ SyntaxHighlight: "monokailight",
+ OrderBy: "lastcommit-desc",
+ RemoveSuffix: true,
+ Hostname: "https://domain.tld",
Repositories: []*GitRepositoryConfiguration{
{
Name: "linux.git",
@@ -154,6 +178,5 @@ repository /srv/git/cerrado.git {
t.Errorf("Wrong result given - wanted + got\n %s", diff)
}
})
-
}
}
diff --git a/pkg/ext/auth.go b/pkg/ext/auth.go
new file mode 100644
index 0000000..ef126ec
--- /dev/null
+++ b/pkg/ext/auth.go
@@ -0,0 +1,85 @@
+package ext
+
+import (
+ "context"
+ "encoding/base64"
+ "errors"
+ "log/slog"
+ "net/http"
+
+ serverconfig "git.gabrielgio.me/cerrado/pkg/config"
+)
+
+type authService interface {
+ ValidateToken(token []byte) (bool, error)
+}
+
+func DisableAuthentication(next HandlerFunc) HandlerFunc {
+ return func(w http.ResponseWriter, r *Request) {
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, "disableAuthentication", true)
+ r.Request = r.WithContext(ctx)
+ next(w, r)
+ }
+}
+
+func VerifyRespository(
+ config *serverconfig.ConfigurationRepository,
+) func(next HandlerFunc) HandlerFunc {
+ return func(next HandlerFunc) HandlerFunc {
+ return func(w http.ResponseWriter, r *Request) {
+ name := r.PathValue("name")
+ if name != "" {
+ repo := config.GetByName(name)
+ if repo != nil && !repo.Public && !IsLoggedIn(r.Context()) {
+ NotFound(w, r)
+ return
+ }
+ }
+
+ next(w, r)
+ }
+ }
+}
+
+func Authenticate(auth authService) func(next HandlerFunc) HandlerFunc {
+ return func(next HandlerFunc) HandlerFunc {
+ return func(w http.ResponseWriter, r *Request) {
+ cookie, err := r.Cookie("auth")
+ if err != nil {
+ if !errors.Is(err, http.ErrNoCookie) {
+ slog.Error("Error loading cookie", "error", err)
+ }
+
+ next(w, r)
+ return
+ }
+
+ value, err := base64.StdEncoding.DecodeString(cookie.Value)
+ if err != nil {
+ slog.Error("Error decoding", "error", err)
+ next(w, r)
+ return
+ }
+
+ valid, err := auth.ValidateToken(value)
+ if err != nil {
+ slog.Error("Error validating token", "error", err, "cookie", cookie.Value)
+ next(w, r)
+ return
+ }
+
+ ctx := r.Context()
+ ctx = context.WithValue(ctx, "logged", valid)
+ r.Request = r.WithContext(ctx)
+
+ slog.Info("Validated token", "valid?", valid)
+ next(w, r)
+ }
+ }
+}
+
+func IsLoggedIn(ctx context.Context) bool {
+ t, ok := ctx.Value("logged").(bool)
+ return ok && t
+}
diff --git a/pkg/ext/compression.go b/pkg/ext/compression.go
index 6c7a219..d3a3df1 100644
--- a/pkg/ext/compression.go
+++ b/pkg/ext/compression.go
@@ -15,18 +15,37 @@ import (
"github.com/klauspost/compress/zstd"
)
-var (
- errInvalidParam = errors.New("Invalid weighted param")
-)
+var errInvalidParam = errors.New("Invalid weighted param")
type CompressionResponseWriter struct {
innerWriter http.ResponseWriter
compressWriter io.Writer
}
-func Compress(next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
+func Compress(next HandlerFunc) HandlerFunc {
+ return func(w http.ResponseWriter, r *Request) {
+ // TODO: hand this better
+ if strings.HasSuffix(r.URL.Path, ".tar.gz") {
+ next(w, r)
+ return
+ }
+
+ if accept, ok := r.Header["Accept-Encoding"]; ok {
+ if compress, algo := GetCompressionWriter(u.FirstOrZero(accept), w); algo != "" {
+ defer compress.Close()
+ w.Header().Add("Content-Encoding", algo)
+ w = &CompressionResponseWriter{
+ innerWriter: w,
+ compressWriter: compress,
+ }
+ }
+ }
+ next(w, r)
+ }
+}
+func Decompress(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
// TODO: hand this better
if strings.HasSuffix(r.URL.Path, ".tar.gz") {
next(w, r)
@@ -61,12 +80,12 @@ func GetCompressionWriter(header string, inner io.Writer) (io.WriteCloser, strin
default:
return nil, ""
}
-
}
func (c *CompressionResponseWriter) Header() http.Header {
return c.innerWriter.Header()
}
+
func (c *CompressionResponseWriter) Write(b []byte) (int, error) {
return c.compressWriter.Write(b)
}
diff --git a/pkg/ext/log.go b/pkg/ext/log.go
index 8e68134..e0ad89f 100644
--- a/pkg/ext/log.go
+++ b/pkg/ext/log.go
@@ -39,8 +39,8 @@ func wrap(w http.ResponseWriter) *statusWraper {
}
}
-func Log(next http.HandlerFunc) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
+func Log(next HandlerFunc) HandlerFunc {
+ return func(w http.ResponseWriter, r *Request) {
t := time.Now()
s := wrap(w)
next(s, r)
diff --git a/pkg/ext/request.go b/pkg/ext/request.go
new file mode 100644
index 0000000..d1593b2
--- /dev/null
+++ b/pkg/ext/request.go
@@ -0,0 +1,14 @@
+package ext
+
+import (
+ "io"
+ "net/http"
+)
+
+type Request struct {
+ *http.Request
+}
+
+func (r *Request) ReadBody() io.ReadCloser {
+ return r.Body
+}
diff --git a/pkg/ext/router.go b/pkg/ext/router.go
index 96da1c9..bbbffa1 100644
--- a/pkg/ext/router.go
+++ b/pkg/ext/router.go
@@ -3,10 +3,12 @@ package ext
import (
"errors"
"fmt"
+ "log/slog"
"net/http"
"git.gabrielgio.me/cerrado/pkg/service"
"git.gabrielgio.me/cerrado/templates"
+ "github.com/go-git/go-git/v5/plumbing"
)
type (
@@ -14,8 +16,9 @@ type (
middlewares []Middleware
router *http.ServeMux
}
- Middleware func(next http.HandlerFunc) http.HandlerFunc
- ErrorRequestHandler func(w http.ResponseWriter, r *http.Request) error
+ HandlerFunc func(http.ResponseWriter, *Request)
+ Middleware func(next HandlerFunc) HandlerFunc
+ ErrorRequestHandler func(w http.ResponseWriter, r *Request) error
)
func NewRouter() *Router {
@@ -23,6 +26,7 @@ func NewRouter() *Router {
router: http.NewServeMux(),
}
}
+
func (r *Router) Handler() http.Handler {
return r.router
}
@@ -31,13 +35,15 @@ func (r *Router) AddMiddleware(middleware Middleware) {
r.middlewares = append(r.middlewares, middleware)
}
-func wrapError(next ErrorRequestHandler) http.HandlerFunc {
- return func(w http.ResponseWriter, r *http.Request) {
+func wrapError(next ErrorRequestHandler) HandlerFunc {
+ return func(w http.ResponseWriter, r *Request) {
if err := next(w, r); err != nil {
- if errors.Is(err, service.ErrRepositoryNotFound) {
- NotFound(w)
+ if errors.Is(err, service.ErrRepositoryNotFound) ||
+ errors.Is(err, plumbing.ErrReferenceNotFound) {
+ NotFound(w, r)
} else {
- InternalServerError(w, err)
+ slog.Error("Internal Server Error", "error", err)
+ InternalServerError(w, r, err)
}
}
}
@@ -49,7 +55,7 @@ func (r *Router) run(next ErrorRequestHandler) http.HandlerFunc {
for _, r := range r.middlewares {
req = r(req)
}
- req(w, re)
+ req(w, &Request{Request: re})
}
}
@@ -57,16 +63,28 @@ func (r *Router) HandleFunc(path string, handler ErrorRequestHandler) {
r.router.HandleFunc(path, r.run(handler))
}
-func NotFound(w http.ResponseWriter) {
+func NotFound(w http.ResponseWriter, r *Request) {
w.WriteHeader(http.StatusNotFound)
templates.WritePageTemplate(w, &templates.ErrorPage{
Message: "Not Found",
- })
+ }, r.Context())
+}
+
+func BadRequest(w http.ResponseWriter, r *Request, msg string) {
+ w.WriteHeader(http.StatusBadRequest)
+ templates.WritePageTemplate(w, &templates.ErrorPage{
+ Message: msg,
+ }, r.Context())
+}
+
+func Redirect(w http.ResponseWriter, location string) {
+ w.Header().Add("location", location)
+ w.WriteHeader(http.StatusTemporaryRedirect)
}
-func InternalServerError(w http.ResponseWriter, err error) {
+func InternalServerError(w http.ResponseWriter, r *Request, err error) {
w.WriteHeader(http.StatusInternalServerError)
templates.WritePageTemplate(w, &templates.ErrorPage{
Message: fmt.Sprintf("Internal Server Error:\n%s", err.Error()),
- })
+ }, r.Context())
}
diff --git a/pkg/git/git.go b/pkg/git/git.go
index 6221e33..83f3f93 100644
--- a/pkg/git/git.go
+++ b/pkg/git/git.go
@@ -3,10 +3,13 @@ package git
import (
"archive/tar"
"bytes"
+ "context"
"errors"
"fmt"
"io"
"io/fs"
+ "log/slog"
+ "os/exec"
"path"
"sort"
"time"
@@ -16,11 +19,10 @@ import (
"github.com/go-git/go-git/v5/plumbing/object"
)
-var ()
-
var (
MissingRefErr = errors.New("Reference not found")
TreeForFileErr = errors.New("Trying to get tree of a file")
+ eofIter = errors.New("End of a iterator")
)
type (
@@ -34,6 +36,10 @@ type (
ref *plumbing.Reference
tag *object.Tag
}
+ CommitReference struct {
+ commit *object.Commit
+ refs []*plumbing.Reference
+ }
infoWrapper struct {
name string
size int64
@@ -83,7 +89,7 @@ func (g *GitRepository) Path() string {
return g.path
}
-func (g *GitRepository) LastCommit() (*object.Commit, error) {
+func (g *GitRepository) LastCommit() (*CommitReference, error) {
err := g.validateRef()
if err != nil {
return nil, err
@@ -93,39 +99,157 @@ func (g *GitRepository) LastCommit() (*object.Commit, error) {
if err != nil {
return nil, err
}
- return c, nil
+
+ iter, err := g.repository.Tags()
+ if err != nil {
+ return nil, err
+ }
+
+ commitRef := &CommitReference{commit: c}
+ if err := iter.ForEach(func(ref *plumbing.Reference) error {
+ obj, err := g.repository.TagObject(ref.Hash())
+ switch err {
+ case nil:
+ if obj.Target == commitRef.commit.Hash {
+ commitRef.AddReference(ref)
+ }
+ case plumbing.ErrObjectNotFound:
+ if commitRef.commit.Hash == ref.Hash() {
+ commitRef.AddReference(ref)
+ }
+ default:
+ return err
+ }
+
+ return nil
+ }); err != nil {
+ return nil, err
+ }
+
+ return commitRef, nil
}
-func (g *GitRepository) Commits(count int) ([]*object.Commit, error) {
+func (g *GitRepository) Commits(count int, from string) ([]*CommitReference, *object.Commit, error) {
err := g.validateRef()
if err != nil {
- return nil, err
+ return nil, nil, err
+ }
+
+ opts := &git.LogOptions{Order: git.LogOrderCommitterTime}
+
+ if from != "" {
+ hash, err := g.repository.ResolveRevision(plumbing.Revision(from))
+ if err != nil {
+ return nil, nil, errors.Join(MissingRefErr, err)
+ }
+ opts.From = *hash
}
- ci, err := g.repository.Log(&git.LogOptions{From: g.ref})
+ ci, err := g.repository.Log(opts)
if err != nil {
- return nil, fmt.Errorf("commits from ref: %w", err)
+ return nil, nil, fmt.Errorf("commits from ref: %w", err)
}
- commits := []*object.Commit{}
- // TODO: for now only load first 1000
- for x := 0; x < count; x++ {
+ commitRefs := []*CommitReference{}
+ var next *object.Commit
+
+ // iterate one more item so we can fetch the next commit
+ for x := 0; x < (count + 1); x++ {
c, err := ci.Next()
if err != nil && errors.Is(err, io.EOF) {
break
} else if err != nil {
- return nil, err
+ return nil, nil, err
}
- commits = append(commits, c)
+ if x == count {
+ next = c
+ } else {
+ commitRefs = append(commitRefs, &CommitReference{commit: c})
+ }
+ }
+
+ // new we fetch for possible tags for each commit
+ iter, err := g.repository.References()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if err := iter.ForEach(func(ref *plumbing.Reference) error {
+ for _, c := range commitRefs {
+ obj, err := g.repository.TagObject(ref.Hash())
+ switch err {
+ case nil:
+ if obj.Target == c.commit.Hash {
+ c.AddReference(ref)
+ }
+ case plumbing.ErrObjectNotFound:
+ if c.commit.Hash == ref.Hash() {
+ c.AddReference(ref)
+ }
+ default:
+ return err
+ }
+ }
+ return nil
+ }); err != nil {
+ return nil, nil, err
}
- return commits, nil
+ return commitRefs, next, nil
}
func (g *GitRepository) Head() (*plumbing.Reference, error) {
return g.repository.Head()
}
+func (g *GitRepository) Tag() (*object.Commit, *TagReference, error) {
+ err := g.validateRef()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ c, err := g.repository.CommitObject(g.ref)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ var tagReference *TagReference
+
+ iter, err := g.repository.Tags()
+ if err != nil {
+ return nil, nil, err
+ }
+
+ if err := iter.ForEach(func(ref *plumbing.Reference) error {
+ obj, err := g.repository.TagObject(ref.Hash())
+ switch err {
+ case nil:
+ if obj.Target == c.Hash {
+ tagReference = &TagReference{
+ ref: ref,
+ tag: obj,
+ }
+ return eofIter
+ }
+ return nil
+ case plumbing.ErrObjectNotFound:
+ if c.Hash == ref.Hash() {
+ tagReference = &TagReference{
+ ref: ref,
+ }
+ return eofIter
+ }
+ return nil
+ default:
+ return err
+ }
+ }); err != nil && !errors.Is(eofIter, err) {
+ return nil, nil, err
+ }
+
+ return c, tagReference, nil
+}
+
func (g *GitRepository) Tags() ([]*TagReference, error) {
iter, err := g.repository.Tags()
if err != nil {
@@ -311,6 +435,68 @@ func (g *GitRepository) FileContent(path string) ([]byte, error) {
return buf.Bytes(), nil
}
+func (g *GitRepository) WriteInfoRefs(ctx context.Context, w io.Writer) error {
+ cmd := exec.CommandContext(
+ ctx,
+ "git-upload-pack",
+ "--stateless-rpc",
+ "--advertise-refs",
+ ".",
+ )
+
+ cmd.Dir = g.path
+ cmd.Env = []string{
+ // TODO: get this from header.
+ "GIT_PROTOCOL=version=2",
+ }
+
+ var errBuff bytes.Buffer
+ cmd.Stderr = &errBuff
+ cmd.Stdout = w
+
+ err := packLine(w, "# service=git-upload-pack\n")
+ if err != nil {
+ return err
+ }
+
+ err = packFlush(w)
+ if err != nil {
+ return err
+ }
+
+ err = cmd.Run()
+ if err != nil {
+ slog.Error("Error upload pack refs", "message", errBuff.String())
+ return err
+ }
+ return nil
+}
+
+func (g *GitRepository) WriteUploadPack(ctx context.Context, r io.Reader, w io.Writer) error {
+ cmd := exec.CommandContext(
+ ctx,
+ "git-upload-pack",
+ "--stateless-rpc",
+ ".",
+ )
+ cmd.Dir = g.Path()
+ cmd.Env = []string{
+ // TODO: get this from header.
+ "GIT_PROTOCOL=version=2",
+ }
+ var errBuff bytes.Buffer
+ cmd.Stderr = &errBuff
+ cmd.Stdout = w
+ cmd.Stdin = r
+
+ if err := cmd.Run(); err != nil {
+ slog.ErrorContext(ctx, "Git upload pack failed", "error", err, "message", errBuff.String())
+ return err
+ }
+
+ return nil
+}
+
func (g *GitRepository) WriteTar(w io.Writer, prefix string) error {
tw := tar.NewWriter(w)
defer tw.Close()
@@ -438,7 +624,22 @@ func (t *TagReference) Message() string {
return t.tag.Message
}
return ""
+}
+
+func (c *CommitReference) Commit() *object.Commit {
+ return c.commit
+}
+
+func (c *CommitReference) HasReference() bool {
+ return len(c.refs) > 0
+}
+
+func (c *CommitReference) References() []*plumbing.Reference {
+ return c.refs
+}
+func (c *CommitReference) AddReference(ref *plumbing.Reference) {
+ c.refs = append(c.refs, ref)
}
func (self *tagList) Len() int {
@@ -477,3 +678,39 @@ func (self *tagList) Less(i, j int) bool {
return dateI.After(dateJ)
}
+
+func packLine(w io.Writer, s string) error {
+ _, err := fmt.Fprintf(w, "%04x%s", len(s)+4, s)
+ return err
+}
+
+func packFlush(w io.Writer) error {
+ _, err := fmt.Fprint(w, "0000")
+ return err
+}
+
+type debugReader struct {
+ r io.Reader
+}
+
+func (d *debugReader) Read(p []byte) (n int, err error) {
+ r, err := d.r.Read(p)
+ if err != nil {
+ if errors.Is(io.EOF, err) {
+ fmt.Printf("READ: EOF\n")
+ }
+ return r, err
+ }
+
+ fmt.Printf("READ: %s\n", p[:r])
+ return r, nil
+}
+
+type debugWriter struct {
+ w io.Writer
+}
+
+func (d *debugWriter) Write(p []byte) (n int, err error) {
+ fmt.Printf("WRITE: %s\n", p)
+ return d.w.Write(p)
+}
diff --git a/pkg/handler/about/handler.go b/pkg/handler/about/handler.go
index ac3d314..b3a1593 100644
--- a/pkg/handler/about/handler.go
+++ b/pkg/handler/about/handler.go
@@ -9,6 +9,7 @@ import (
"github.com/gomarkdown/markdown/html"
"github.com/gomarkdown/markdown/parser"
+ "git.gabrielgio.me/cerrado/pkg/ext"
"git.gabrielgio.me/cerrado/templates"
)
@@ -26,7 +27,7 @@ func NewAboutHandler(configRepo configurationRepository) *AboutHandler {
return &AboutHandler{configRepo.GetRootReadme()}
}
-func (g *AboutHandler) About(w http.ResponseWriter, _ *http.Request) error {
+func (g *AboutHandler) About(w http.ResponseWriter, r *ext.Request) error {
f, err := os.Open(g.readmePath)
if err != nil {
return err
@@ -50,6 +51,6 @@ func (g *AboutHandler) About(w http.ResponseWriter, _ *http.Request) error {
gitList := &templates.AboutPage{
Body: bs,
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
diff --git a/pkg/handler/auth/login.go b/pkg/handler/auth/login.go
new file mode 100644
index 0000000..9cc13cc
--- /dev/null
+++ b/pkg/handler/auth/login.go
@@ -0,0 +1,97 @@
+package auth
+
+import (
+ "encoding/base64"
+ "net/http"
+ "time"
+
+ "git.gabrielgio.me/cerrado/pkg/ext"
+ "git.gabrielgio.me/cerrado/templates"
+)
+
+type (
+ LoginHandler struct {
+ auth authService
+ }
+
+ authService interface {
+ CheckAuth(username, password string) bool
+ IssueToken() ([]byte, error)
+ }
+)
+
+func NewLoginHandler(auth authService) *LoginHandler {
+ return &LoginHandler{
+ auth: auth,
+ }
+}
+
+func (g *LoginHandler) Logout(w http.ResponseWriter, r *ext.Request) error {
+ cookie := &http.Cookie{
+ Name: "auth",
+ Value: "",
+ Path: "/",
+ Expires: time.Unix(0, 0),
+ }
+
+ referer := r.Header.Get("Referer")
+ if referer == "" {
+ referer = "/"
+ }
+
+ http.SetCookie(w, cookie)
+ ext.Redirect(w, referer)
+ return nil
+}
+
+func (g *LoginHandler) Login(w http.ResponseWriter, r *ext.Request) error {
+ referer := r.URL.Query().Get("referer")
+
+ // if query value is empty tries to get from header
+ if referer == "" {
+ referer = r.Header.Get("Referer")
+ }
+
+ if r.Method == "GET" {
+ ext.SetHTML(w)
+
+ login := &templates.LoginPage{
+ Referer: referer,
+ }
+ templates.WritePageTemplate(w, login, r.Context())
+ } else if r.Method == "POST" {
+
+ username := r.FormValue("username")
+ password := r.FormValue("password")
+
+ if !g.auth.CheckAuth(username, password) {
+ login := &templates.LoginPage{
+ Referer: referer,
+ ErrorMessage: "Invalid login",
+ }
+ templates.WritePageTemplate(w, login, r.Context())
+ } else {
+
+ bytes, err := g.auth.IssueToken()
+ if err != nil {
+ return err
+ }
+
+ cookie := &http.Cookie{
+ Name: "auth",
+ Value: base64.StdEncoding.EncodeToString(bytes),
+ Path: "/",
+ MaxAge: 3600,
+ HttpOnly: true,
+ Secure: true,
+ SameSite: http.SameSiteStrictMode,
+ }
+
+ http.SetCookie(w, cookie)
+ ext.Redirect(w, referer)
+ }
+
+ }
+
+ return nil
+}
diff --git a/pkg/handler/config/handler.go b/pkg/handler/config/handler.go
deleted file mode 100644
index c43b54d..0000000
--- a/pkg/handler/config/handler.go
+++ /dev/null
@@ -1,63 +0,0 @@
-package config
-
-import (
- "bytes"
- "encoding/json"
- "net/http"
-
- "github.com/alecthomas/chroma/v2/formatters/html"
- "github.com/alecthomas/chroma/v2/lexers"
- "github.com/alecthomas/chroma/v2/styles"
-
- "git.gabrielgio.me/cerrado/pkg/config"
- "git.gabrielgio.me/cerrado/pkg/ext"
- "git.gabrielgio.me/cerrado/templates"
-)
-
-type (
- configurationRepository interface {
- GetRootReadme() string
- List() []*config.GitRepositoryConfiguration
- }
-)
-
-func ConfigFile(configRepo configurationRepository) ext.ErrorRequestHandler {
- return func(w http.ResponseWriter, _ *http.Request) error {
-
- config := struct {
- RootReadme string
- Repositories []*config.GitRepositoryConfiguration
- }{
- RootReadme: configRepo.GetRootReadme(),
- Repositories: configRepo.List(),
- }
-
- b, err := json.MarshalIndent(config, "", " ")
- if err != nil {
- return err
- }
-
- lexer := lexers.Get("json")
- style := styles.Get("monokailight")
- formatter := html.New(
- html.WithLineNumbers(true),
- )
- iterator, err := lexer.Tokenise(nil, string(b))
- if err != nil {
- return err
- }
-
- var code bytes.Buffer
- err = formatter.Format(&code, style, iterator)
- if err != nil {
- return err
- }
-
- hello := &templates.ConfigPage{
- Body: code.Bytes(),
- }
-
- templates.WritePageTemplate(w, hello)
- return nil
- }
-}
diff --git a/pkg/handler/git/handler.go b/pkg/handler/git/handler.go
index 40fae24..cb202a2 100644
--- a/pkg/handler/git/handler.go
+++ b/pkg/handler/git/handler.go
@@ -2,6 +2,7 @@ package git
import (
"bytes"
+ "compress/gzip"
"errors"
"fmt"
"io"
@@ -9,10 +10,13 @@ import (
"net/http"
"os"
"path/filepath"
+ "sort"
"strings"
+ "git.gabrielgio.me/cerrado/pkg/config"
"git.gabrielgio.me/cerrado/pkg/ext"
"git.gabrielgio.me/cerrado/pkg/service"
+ "git.gabrielgio.me/cerrado/pkg/u"
"git.gabrielgio.me/cerrado/templates"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
@@ -27,28 +31,38 @@ import (
type (
GitHandler struct {
gitService *service.GitService
- readmePath string
+ config configurationRepository
}
configurationRepository interface {
GetRootReadme() string
+ GetSyntaxHighlight() string
+ GetOrderBy() config.OrderBy
}
)
func NewGitHandler(gitService *service.GitService, confRepo configurationRepository) *GitHandler {
return &GitHandler{
gitService: gitService,
- readmePath: confRepo.GetRootReadme(),
+ config: confRepo,
}
}
-func (g *GitHandler) List(w http.ResponseWriter, _ *http.Request) error {
+func (g *GitHandler) List(w http.ResponseWriter, r *ext.Request) error {
+ // this is the only handler that needs to handle authentication itself.
+ // everything else relay on name path parameter
+ logged := ext.IsLoggedIn(r.Context())
+
repos, err := g.gitService.ListRepositories()
if err != nil {
return err
}
- f, err := os.Open(g.readmePath)
+ if !logged {
+ repos = u.Filter(repos, isPublic)
+ }
+
+ f, err := os.Open(g.config.GetRootReadme())
if err != nil {
return err
}
@@ -69,14 +83,14 @@ func (g *GitHandler) List(w http.ResponseWriter, _ *http.Request) error {
bs = markdown.Render(doc, renderer)
gitList := &templates.GitListPage{
- Respositories: repos,
+ Respositories: orderBy(repos, g.config.GetOrderBy()),
About: bs,
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) Archive(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Archive(w http.ResponseWriter, r *ext.Request) error {
ext.SetGZip(w)
name := r.PathValue("name")
file := r.PathValue("file")
@@ -84,7 +98,7 @@ func (g *GitHandler) Archive(w http.ResponseWriter, r *http.Request) error {
// TODO: remove it once we can support more than gzip
if !strings.HasSuffix(file, ".tar.gz") {
- ext.NotFound(w)
+ ext.NotFound(w, r)
return nil
}
@@ -102,7 +116,51 @@ func (g *GitHandler) Archive(w http.ResponseWriter, r *http.Request) error {
return nil
}
-func (g *GitHandler) Summary(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Multiplex(w http.ResponseWriter, r *ext.Request) error {
+ path := r.PathValue("rest")
+ name := r.PathValue("name")
+
+ if r.URL.RawQuery == "service=git-receive-pack" {
+ ext.BadRequest(w, r, "no pushing allowed")
+ return nil
+ }
+
+ if path == "info/refs" && r.URL.RawQuery == "service=git-upload-pack" && r.Method == "GET" {
+ w.Header().Set("content-type", "application/x-git-upload-pack-advertisement")
+
+ err := g.gitService.WriteInfoRefs(r.Context(), name, w)
+ if err != nil {
+ slog.Error("Error WriteInfoRefs", "error", err)
+ }
+ } else if path == "git-upload-pack" && r.Method == "POST" {
+ w.Header().Set("content-type", "application/x-git-upload-pack-result")
+ w.Header().Set("Connection", "Keep-Alive")
+ w.Header().Set("Transfer-Encoding", "chunked")
+ w.WriteHeader(http.StatusOK)
+
+ reader := r.Body
+
+ if r.Header.Get("Content-Encoding") == "gzip" {
+ var err error
+ reader, err = gzip.NewReader(r.Body)
+ if err != nil {
+ return err
+ }
+ defer reader.Close()
+ }
+
+ err := g.gitService.WriteUploadPack(r.Context(), name, reader, w)
+ if err != nil {
+ slog.Error("Error WriteUploadPack", "error", err)
+ }
+ } else if r.Method == "GET" {
+ return g.Summary(w, r)
+ }
+
+ return nil
+}
+
+func (g *GitHandler) Summary(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
ref, err := g.gitService.GetHead(name)
@@ -120,11 +178,15 @@ func (g *GitHandler) Summary(w http.ResponseWriter, r *http.Request) error {
return err
}
- commits, err := g.gitService.ListCommits(name, "", 10)
+ commits, _, err := g.gitService.ListCommits(name, "", "", 10)
if err != nil {
return err
}
+ if len(tags) > 3 {
+ tags = tags[:3]
+ }
+
gitList := &templates.GitItemPage{
Name: name,
Ref: ref.Name().Short(),
@@ -134,11 +196,11 @@ func (g *GitHandler) Summary(w http.ResponseWriter, r *http.Request) error {
Commits: commits,
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) About(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) About(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
ref, err := g.gitService.GetHead(name)
@@ -154,7 +216,7 @@ func (g *GitHandler) About(w http.ResponseWriter, r *http.Request) error {
GitItemBase: &templates.GitItemAboutPage{
About: []byte("About file not configured properly"),
},
- })
+ }, r.Context())
return nil
}
if err != nil {
@@ -178,11 +240,11 @@ func (g *GitHandler) About(w http.ResponseWriter, r *http.Request) error {
About: bs,
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) Refs(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Refs(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
@@ -209,11 +271,11 @@ func (g *GitHandler) Refs(w http.ResponseWriter, r *http.Request) error {
Branches: branches,
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) Tree(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Tree(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
ref := r.PathValue("ref")
@@ -238,11 +300,11 @@ func (g *GitHandler) Tree(w http.ResponseWriter, r *http.Request) error {
Tree: tree,
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) Blob(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Blob(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
ref := r.PathValue("ref")
@@ -269,7 +331,7 @@ func (g *GitHandler) Blob(w http.ResponseWriter, r *http.Request) error {
Content: []byte("Binary file"),
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
@@ -280,7 +342,8 @@ func (g *GitHandler) Blob(w http.ResponseWriter, r *http.Request) error {
filename := filepath.Base(rest)
lexer := GetLexers(filename)
- style := styles.Get("xcode")
+ style := styles.Get(g.config.GetSyntaxHighlight())
+
formatter := html.New(
html.WithLineNumbers(true),
html.WithLinkableLineNumbers(true, "L"),
@@ -305,16 +368,17 @@ func (g *GitHandler) Blob(w http.ResponseWriter, r *http.Request) error {
Content: code.Bytes(),
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) Log(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Log(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
ref := r.PathValue("ref")
+ from := r.URL.Query().Get("from")
- commits, err := g.gitService.ListCommits(name, ref, 1000)
+ commits, next, err := g.gitService.ListCommits(name, ref, from, 100)
if err != nil {
return err
}
@@ -324,13 +388,36 @@ func (g *GitHandler) Log(w http.ResponseWriter, r *http.Request) error {
Ref: ref,
GitItemBase: &templates.GitItemLogPage{
Commits: commits,
+ Next: next,
+ },
+ }
+ templates.WritePageTemplate(w, gitList, r.Context())
+ return nil
+}
+
+func (g *GitHandler) Ref(w http.ResponseWriter, r *ext.Request) error {
+ ext.SetHTML(w)
+ name := r.PathValue("name")
+ ref := r.PathValue("ref")
+
+ commit, tag, err := g.gitService.GetTag(ref, name)
+ if err != nil {
+ return err
+ }
+
+ gitList := &templates.GitItemPage{
+ Name: name,
+ Ref: ref,
+ GitItemBase: &templates.GitItemRefPage{
+ Commit: commit,
+ Reference: tag,
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
-func (g *GitHandler) Commit(w http.ResponseWriter, r *http.Request) error {
+func (g *GitHandler) Commit(w http.ResponseWriter, r *ext.Request) error {
ext.SetHTML(w)
name := r.PathValue("name")
ref := r.PathValue("ref")
@@ -345,15 +432,34 @@ func (g *GitHandler) Commit(w http.ResponseWriter, r *http.Request) error {
return err
}
+ lexer := lexers.Get("diff")
+ style := styles.Get(g.config.GetSyntaxHighlight())
+
+ formatter := html.New(
+ html.WithLineNumbers(true),
+ html.WithLinkableLineNumbers(true, "L"),
+ )
+
+ iterator, err := lexer.Tokenise(nil, diff)
+ if err != nil {
+ return err
+ }
+
+ var code bytes.Buffer
+ err = formatter.Format(&code, style, iterator)
+ if err != nil {
+ return err
+ }
+
gitList := &templates.GitItemPage{
Name: name,
Ref: ref,
GitItemBase: &templates.GitItemCommitPage{
Commit: commit,
- Diff: diff,
+ Diff: code.Bytes(),
},
}
- templates.WritePageTemplate(w, gitList)
+ templates.WritePageTemplate(w, gitList, r.Context())
return nil
}
@@ -362,6 +468,10 @@ func GetLexers(filename string) chroma.Lexer {
return lexers.Get("sh")
}
+ if strings.HasSuffix(filename, ".qtpl") {
+ return lexers.Get("html")
+ }
+
lexer := lexers.Get(filename)
if lexer == nil {
@@ -369,3 +479,30 @@ func GetLexers(filename string) chroma.Lexer {
}
return lexer
}
+
+func isPublic(r *service.Repository) bool {
+ return r.Public
+}
+
+func orderBy(repos []*service.Repository, order config.OrderBy) []*service.Repository {
+ switch order {
+ case config.AlphabeticalAsc:
+ sort.Slice(repos, func(i, j int) bool {
+ return repos[i].Name < repos[j].Name
+ })
+ case config.AlphabeticalDesc:
+ sort.Slice(repos, func(i, j int) bool {
+ return repos[i].Name > repos[j].Name
+ })
+ case config.LastCommitAsc:
+ sort.Slice(repos, func(i, j int) bool {
+ return repos[i].LastCommit.Commit().Committer.When.Before(repos[j].LastCommit.Commit().Committer.When)
+ })
+ case config.LastCommitDesc:
+ sort.Slice(repos, func(i, j int) bool {
+ return repos[i].LastCommit.Commit().Committer.When.After(repos[j].LastCommit.Commit().Committer.When)
+ })
+ }
+
+ return repos
+}
diff --git a/pkg/handler/router.go b/pkg/handler/router.go
index f464ac2..fea8827 100644
--- a/pkg/handler/router.go
+++ b/pkg/handler/router.go
@@ -6,7 +6,7 @@ import (
serverconfig "git.gabrielgio.me/cerrado/pkg/config"
"git.gabrielgio.me/cerrado/pkg/ext"
"git.gabrielgio.me/cerrado/pkg/handler/about"
- "git.gabrielgio.me/cerrado/pkg/handler/config"
+ "git.gabrielgio.me/cerrado/pkg/handler/auth"
"git.gabrielgio.me/cerrado/pkg/handler/git"
"git.gabrielgio.me/cerrado/pkg/handler/static"
"git.gabrielgio.me/cerrado/pkg/service"
@@ -17,12 +17,13 @@ import (
// its sub package don't leak in other places.
func MountHandler(
gitService *service.GitService,
+ authService *service.AuthService,
configRepo *serverconfig.ConfigurationRepository,
) (http.Handler, error) {
var (
- gitHandler = git.NewGitHandler(gitService, configRepo)
- aboutHandler = about.NewAboutHandler(configRepo)
- configHandler = config.ConfigFile(configRepo)
+ gitHandler = git.NewGitHandler(gitService, configRepo)
+ aboutHandler = about.NewAboutHandler(configRepo)
+ loginHandler = auth.NewLoginHandler(authService)
)
staticHandler, err := static.ServeStaticHandler()
@@ -33,17 +34,27 @@ func MountHandler(
mux := ext.NewRouter()
mux.AddMiddleware(ext.Compress)
mux.AddMiddleware(ext.Log)
+ mux.AddMiddleware(ext.VerifyRespository(configRepo))
+
+ if configRepo.IsAuthEnabled() {
+ mux.AddMiddleware(ext.Authenticate(authService))
+ mux.HandleFunc("/login/{$}", loginHandler.Login)
+ mux.HandleFunc("/logout/{$}", loginHandler.Logout)
+ } else {
+ mux.AddMiddleware(ext.DisableAuthentication)
+ }
mux.HandleFunc("/static/{file}", staticHandler)
mux.HandleFunc("/{name}/about/{$}", gitHandler.About)
- mux.HandleFunc("/{name}/", gitHandler.Summary)
+ mux.HandleFunc("/{name}", gitHandler.Multiplex)
+ mux.HandleFunc("/{name}/{rest...}", gitHandler.Multiplex)
mux.HandleFunc("/{name}/refs/{$}", gitHandler.Refs)
mux.HandleFunc("/{name}/tree/{ref}/{rest...}", gitHandler.Tree)
mux.HandleFunc("/{name}/blob/{ref}/{rest...}", gitHandler.Blob)
mux.HandleFunc("/{name}/log/{ref}/", gitHandler.Log)
mux.HandleFunc("/{name}/commit/{ref}/", gitHandler.Commit)
+ mux.HandleFunc("/{name}/ref/{ref}/", gitHandler.Ref)
mux.HandleFunc("/{name}/archive/{file}", gitHandler.Archive)
- mux.HandleFunc("/config", configHandler)
mux.HandleFunc("/about", aboutHandler.About)
mux.HandleFunc("/", gitHandler.List)
return mux.Handler(), nil
diff --git a/pkg/handler/static/handler.go b/pkg/handler/static/handler.go
index 361f690..cdb2ae6 100644
--- a/pkg/handler/static/handler.go
+++ b/pkg/handler/static/handler.go
@@ -16,7 +16,7 @@ func ServeStaticHandler() (ext.ErrorRequestHandler, error) {
return nil, err
}
- return func(w http.ResponseWriter, r *http.Request) error {
+ return func(w http.ResponseWriter, r *ext.Request) error {
var (
f = r.PathValue("file")
e = filepath.Ext(f)
@@ -24,7 +24,7 @@ func ServeStaticHandler() (ext.ErrorRequestHandler, error) {
)
ext.SetMIME(w, m)
w.Header().Add("Cache-Control", "max-age=31536000")
- http.ServeFileFS(w, r, staticFs, f)
+ http.ServeFileFS(w, r.Request, staticFs, f)
return nil
}, nil
}
diff --git a/pkg/humanize/times.go b/pkg/humanize/times.go
new file mode 100644
index 0000000..1bd5166
--- /dev/null
+++ b/pkg/humanize/times.go
@@ -0,0 +1,141 @@
+// This code includes software originally developed by Dustin Sallings.
+//
+// Copyright (c) 2005-2008 Dustin Sallings <dustin@spy.net>
+//
+// Permission is hereby granted, free of charge, to any person obtaining a copy
+// of this software and associated documentation files (the "Software"), to deal
+// in the Software without restriction, including without limitation the rights
+// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+// copies of the Software, and to permit persons to whom the Software is
+// furnished to do so, subject to the following conditions:
+//
+// The above copyright notice and this permission notice shall be included in
+// all copies or substantial portions of the Software.
+//
+// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+// SOFTWARE.
+//
+//<http://www.opensource.org/licenses/mit-license.php>
+
+package humanize
+
+import (
+ "fmt"
+ "math"
+ "sort"
+ "time"
+)
+
+// Seconds-based time units
+const (
+ Day = 24 * time.Hour
+ Week = 7 * Day
+ Month = 30 * Day
+ Year = 12 * Month
+ LongTime = 37 * Year
+)
+
+// Time formats a time into a relative string.
+//
+// Time(someT) -> "3 weeks ago"
+func Time(then time.Time) string {
+ return RelTime(then, time.Now(), "ago", "from now")
+}
+
+// A RelTimeMagnitude struct contains a relative time point at which
+// the relative format of time will switch to a new format string. A
+// slice of these in ascending order by their "D" field is passed to
+// CustomRelTime to format durations.
+//
+// The Format field is a string that may contain a "%s" which will be
+// replaced with the appropriate signed label (e.g. "ago" or "from
+// now") and a "%d" that will be replaced by the quantity.
+//
+// The DivBy field is the amount of time the time difference must be
+// divided by in order to display correctly.
+//
+// e.g. if D is 2*time.Minute and you want to display "%d minutes %s"
+// DivBy should be time.Minute so whatever the duration is will be
+// expressed in minutes.
+type RelTimeMagnitude struct {
+ D time.Duration
+ Format string
+ DivBy time.Duration
+}
+
+var defaultMagnitudes = []RelTimeMagnitude{
+ {time.Second, "now", time.Second},
+ {2 * time.Second, "1 second %s", 1},
+ {time.Minute, "%d seconds %s", time.Second},
+ {2 * time.Minute, "1 minute %s", 1},
+ {time.Hour, "%d minutes %s", time.Minute},
+ {2 * time.Hour, "1 hour %s", 1},
+ {Day, "%d hours %s", time.Hour},
+ {2 * Day, "1 day %s", 1},
+ {Week, "%d days %s", Day},
+ {2 * Week, "1 week %s", 1},
+ {Month, "%d weeks %s", Week},
+ {2 * Month, "1 month %s", 1},
+ {Year, "%d months %s", Month},
+ {18 * Month, "1 year %s", 1},
+ {2 * Year, "2 years %s", 1},
+ {LongTime, "%d years %s", Year},
+ {math.MaxInt64, "a long while %s", 1},
+}
+
+// RelTime formats a time into a relative string.
+//
+// It takes two times and two labels. In addition to the generic time
+// delta string (e.g. 5 minutes), the labels are used applied so that
+// the label corresponding to the smaller time is applied.
+//
+// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier"
+func RelTime(a, b time.Time, albl, blbl string) string {
+ return CustomRelTime(a, b, albl, blbl, defaultMagnitudes)
+}
+
+// CustomRelTime formats a time into a relative string.
+//
+// It takes two times two labels and a table of relative time formats.
+// In addition to the generic time delta string (e.g. 5 minutes), the
+// labels are used applied so that the label corresponding to the
+// smaller time is applied.
+func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string {
+ lbl := albl
+ diff := b.Sub(a)
+
+ if a.After(b) {
+ lbl = blbl
+ diff = a.Sub(b)
+ }
+
+ n := sort.Search(len(magnitudes), func(i int) bool {
+ return magnitudes[i].D > diff
+ })
+
+ if n >= len(magnitudes) {
+ n = len(magnitudes) - 1
+ }
+ mag := magnitudes[n]
+ args := []interface{}{}
+ escaped := false
+ for _, ch := range mag.Format {
+ if escaped {
+ switch ch {
+ case 's':
+ args = append(args, lbl)
+ case 'd':
+ args = append(args, diff/mag.DivBy)
+ }
+ escaped = false
+ } else {
+ escaped = ch == '%'
+ }
+ }
+ return fmt.Sprintf(mag.Format, args...)
+}
diff --git a/pkg/service/auth.go b/pkg/service/auth.go
new file mode 100644
index 0000000..0dbd960
--- /dev/null
+++ b/pkg/service/auth.go
@@ -0,0 +1,123 @@
+package service
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/rand"
+ "encoding/base64"
+ "fmt"
+ "io"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+type (
+ AuthService struct {
+ authRepository authRepository
+ }
+
+ authRepository interface {
+ GetPassphrase() []byte
+ GetBase64AesKey() []byte
+ }
+)
+
+var tokenSeed = []byte("this is a token for cerrado")
+
+func NewAuthService(repostiory authRepository) *AuthService {
+ return &AuthService{
+ authRepository: repostiory,
+ }
+}
+
+func (a *AuthService) CheckAuth(username, password string) bool {
+ passphrase := a.authRepository.GetPassphrase()
+ pass := []byte(fmt.Sprintf("%s:%s", username, password))
+
+ err := bcrypt.CompareHashAndPassword(passphrase, pass)
+
+ return err == nil
+}
+
+func (a *AuthService) IssueToken() ([]byte, error) {
+ // TODO: do this block only once
+ base := a.authRepository.GetBase64AesKey()
+
+ dbuf, err := base64.StdEncoding.DecodeString(string(base))
+ if err != nil {
+ return nil, err
+ }
+
+ block, err := aes.NewCipher(dbuf)
+ if err != nil {
+ return nil, err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return nil, err
+ }
+
+ nonce := make([]byte, gcm.NonceSize())
+ if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
+ return nil, err
+ }
+
+ ciphertext := gcm.Seal(nonce, nonce, tokenSeed, nil)
+
+ return ciphertext, nil
+}
+
+func (a *AuthService) ValidateToken(token []byte) (bool, error) {
+ base := a.authRepository.GetBase64AesKey()
+
+ dbuf, err := base64.StdEncoding.DecodeString(string(base))
+ if err != nil {
+ return false, err
+ }
+
+ block, err := aes.NewCipher(dbuf)
+ if err != nil {
+ return false, err
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return false, err
+ }
+
+ nonceSize := gcm.NonceSize()
+ if len(token) < nonceSize {
+ return false, fmt.Errorf("ciphertext too short")
+ }
+
+ nonce, ciphertext := token[:nonceSize], token[nonceSize:]
+ plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
+ if err != nil {
+ return false, err
+ }
+
+ return bytes.Equal(tokenSeed, plaintext), nil
+}
+
+func GenerateHash(username, password string) (string, error) {
+ passphrase := fmt.Sprintf("%s:%s", username, password)
+ bytes, err := bcrypt.GenerateFromPassword([]byte(passphrase), 14)
+ if err != nil {
+ return "", err
+ }
+
+ return string(bytes), nil
+}
+
+func GenerateAesKey() (string, error) {
+ key := make([]byte, 32)
+
+ _, err := rand.Read(key)
+ if err != nil {
+ return "", err
+ }
+
+ return base64.StdEncoding.EncodeToString(key), nil
+}
diff --git a/pkg/service/auth_test.go b/pkg/service/auth_test.go
new file mode 100644
index 0000000..06bf76f
--- /dev/null
+++ b/pkg/service/auth_test.go
@@ -0,0 +1,119 @@
+// go:build unit
+
+package service
+
+import (
+ "testing"
+)
+
+func TestCheck(t *testing.T) {
+ testCases := []struct {
+ name string
+ passphrase []byte
+ username string
+ password string
+ wantError bool
+ }{
+ {
+ name: "generated",
+ passphrase: nil,
+ username: "gabrielgio",
+ password: "adminadmin",
+ wantError: false,
+ },
+ {
+ name: "static",
+ passphrase: []byte("$2a$14$W2yT0E6Zm8nTecqipHUQGOLC6PvNjIQqpQTW/MZmD5oqDfaBJnBV6"),
+ username: "gabrielgio",
+ password: "adminadmin",
+ wantError: false,
+ },
+ {
+ name: "error",
+ passphrase: []byte("This is not a valid hash"),
+ username: "gabrielgio",
+ password: "adminadmin",
+ wantError: true,
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ mock := &mockAuthRepository{
+ username: tc.username,
+ password: tc.password,
+ passphrase: tc.passphrase,
+ }
+
+ service := AuthService{authRepository: mock}
+
+ if service.CheckAuth(tc.username, tc.password) == tc.wantError {
+ t.Errorf("Invalid result, wanted %t got %t", tc.wantError, !tc.wantError)
+ }
+ })
+ }
+}
+
+func TestValidate(t *testing.T) {
+ testCases := []struct {
+ name string
+ aesKey []byte
+ }{
+ {
+ name: "generated",
+ aesKey: nil,
+ },
+ {
+ name: "static",
+ aesKey: []byte("RTGkmunKmi5agh7jaqENunG2zI/godnkqhHaHyX/AVg="),
+ },
+ }
+
+ for _, tc := range testCases {
+ t.Run(tc.name, func(t *testing.T) {
+ mock := &mockAuthRepository{
+ aesKey: tc.aesKey,
+ }
+
+ service := AuthService{authRepository: mock}
+
+ token, err := service.IssueToken()
+ if err != nil {
+ t.Fatalf("Error issuing token: %s", err.Error())
+ }
+
+ v, err := service.ValidateToken(token)
+ if err != nil {
+ t.Fatalf("Error validating token: %s", err.Error())
+ }
+
+ if !v {
+ t.Error("Invalid token generated")
+ }
+ })
+ }
+}
+
+type mockAuthRepository struct {
+ username string
+ password string
+ passphrase []byte
+
+ aesKey []byte
+}
+
+func (m *mockAuthRepository) GetPassphrase() []byte {
+ if m.passphrase == nil {
+ hash, _ := GenerateHash(m.username, m.password)
+ m.passphrase = []byte(hash)
+ }
+ return m.passphrase
+}
+
+func (m *mockAuthRepository) GetBase64AesKey() []byte {
+ if m.aesKey == nil {
+ key, _ := GenerateAesKey()
+ m.aesKey = []byte(key)
+ }
+ return m.aesKey
+}
diff --git a/pkg/service/git.go b/pkg/service/git.go
index f03ba42..6aa5cd6 100644
--- a/pkg/service/git.go
+++ b/pkg/service/git.go
@@ -2,22 +2,25 @@ package service
import (
"compress/gzip"
+ "context"
"errors"
"io"
"log/slog"
"git.gabrielgio.me/cerrado/pkg/config"
"git.gabrielgio.me/cerrado/pkg/git"
+ gogit "github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
type (
Repository struct {
- Name string
- Description string
- LastCommitDate string
- Ref string
+ Name string
+ Description string
+ Public bool
+ LastCommit *git.CommitReference
+ Ref string
}
GitService struct {
@@ -30,9 +33,7 @@ type (
}
)
-var (
- ErrRepositoryNotFound = errors.New("Repository not found")
-)
+var ErrRepositoryNotFound = errors.New("Repository not found")
// TODO: make it configurable
const timeFormat = "2006.01.02 15:04:05"
@@ -50,6 +51,10 @@ func (g *GitService) ListRepositories() ([]*Repository, error) {
for _, r := range rs {
repo, err := git.OpenRepository(r.Path)
if err != nil {
+ if errors.Is(err, gogit.ErrRepositoryNotExists) {
+ slog.Info("Path does not contain a repository", "path", r.Path)
+ continue
+ }
return nil, err
}
@@ -66,35 +71,36 @@ func (g *GitService) ListRepositories() ([]*Repository, error) {
}
repos = append(repos, &Repository{
- Name: r.Name,
- Description: r.Description,
- LastCommitDate: obj.Author.When.Format(timeFormat),
- Ref: head.Name().Short(),
+ Name: r.Name,
+ Description: r.Description,
+ Public: r.Public,
+ LastCommit: obj,
+ Ref: head.Name().Short(),
})
}
return repos, nil
}
-func (g *GitService) ListCommits(name, ref string, count int) ([]*object.Commit, error) {
+func (g *GitService) ListCommits(name, ref, from string, count int) ([]*git.CommitReference, *object.Commit, error) {
r := g.configRepo.GetByName(name)
if r == nil {
- return nil, ErrRepositoryNotFound
+ return nil, nil, ErrRepositoryNotFound
}
repo, err := git.OpenRepository(r.Path)
if err != nil {
- return nil, err
+ return nil, nil, err
}
err = repo.SetRef(ref)
if err != nil {
- return nil, err
+ return nil, nil, err
}
- return repo.Commits(count)
+ return repo.Commits(count, from)
}
-func (g *GitService) LastCommit(name, ref string) (*object.Commit, error) {
+func (g *GitService) LastCommit(name, ref string) (*git.CommitReference, error) {
r := g.configRepo.GetByName(name)
if r == nil {
return nil, ErrRepositoryNotFound
@@ -236,6 +242,25 @@ func (g *GitService) GetAbout(name string) ([]byte, error) {
return file, nil
}
+func (g *GitService) GetTag(ref, name string) (*object.Commit, *git.TagReference, error) {
+ r := g.configRepo.GetByName(name)
+ if r == nil {
+ return nil, nil, ErrRepositoryNotFound
+ }
+
+ repo, err := git.OpenRepository(r.Path)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ err = repo.SetRef(ref)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return repo.Tag()
+}
+
func (g *GitService) ListTags(name string) ([]*git.TagReference, error) {
r := g.configRepo.GetByName(name)
if r == nil {
@@ -275,3 +300,31 @@ func (g *GitService) GetHead(name string) (*plumbing.Reference, error) {
return repo.Head()
}
+
+func (g *GitService) WriteInfoRefs(ctx context.Context, name string, w io.Writer) error {
+ r := g.configRepo.GetByName(name)
+ if r == nil {
+ return ErrRepositoryNotFound
+ }
+
+ repo, err := git.OpenRepository(r.Path)
+ if err != nil {
+ return err
+ }
+
+ return repo.WriteInfoRefs(ctx, w)
+}
+
+func (g *GitService) WriteUploadPack(ctx context.Context, name string, re io.Reader, w io.Writer) error {
+ r := g.configRepo.GetByName(name)
+ if r == nil {
+ return ErrRepositoryNotFound
+ }
+
+ repo, err := git.OpenRepository(r.Path)
+ if err != nil {
+ return err
+ }
+
+ return repo.WriteUploadPack(ctx, re, w)
+}
diff --git a/pkg/u/list.go b/pkg/u/list.go
index 39d7b11..1cffbd5 100644
--- a/pkg/u/list.go
+++ b/pkg/u/list.go
@@ -1,5 +1,25 @@
package u
+func Filter[T any](v []T, f func(T) bool) []T {
+ var result []T
+
+ for _, s := range v {
+ if f(s) {
+ result = append(result, s)
+ }
+ }
+
+ return result
+}
+
+func Map[T any, V any](a []T, f func(T) V) []V {
+ result := make([]V, len(a))
+ for i, v := range a {
+ result[i] = f(v)
+ }
+ return result
+}
+
func First[T any](v []T) (T, bool) {
if len(v) == 0 {
var zero T
@@ -25,7 +45,7 @@ func LastOrZero[T any](v []T) T {
}
func ChunkBy[T any](items []T, chunkSize int) [][]T {
- var chunks = make([][]T, 0, (len(items)/chunkSize)+1)
+ chunks := make([][]T, 0, (len(items)/chunkSize)+1)
for chunkSize < len(items) {
items, chunks = items[chunkSize:], append(chunks, items[0:chunkSize:chunkSize])
}