diff options
Diffstat (limited to 'pkg')
-rw-r--r-- | pkg/config/config.go | 219 | ||||
-rw-r--r-- | pkg/config/config_test.go | 53 | ||||
-rw-r--r-- | pkg/ext/auth.go | 85 | ||||
-rw-r--r-- | pkg/ext/compression.go | 31 | ||||
-rw-r--r-- | pkg/ext/log.go | 4 | ||||
-rw-r--r-- | pkg/ext/request.go | 14 | ||||
-rw-r--r-- | pkg/ext/router.go | 42 | ||||
-rw-r--r-- | pkg/git/git.go | 265 | ||||
-rw-r--r-- | pkg/handler/about/handler.go | 5 | ||||
-rw-r--r-- | pkg/handler/auth/login.go | 97 | ||||
-rw-r--r-- | pkg/handler/config/handler.go | 63 | ||||
-rw-r--r-- | pkg/handler/git/handler.go | 193 | ||||
-rw-r--r-- | pkg/handler/router.go | 23 | ||||
-rw-r--r-- | pkg/handler/static/handler.go | 4 | ||||
-rw-r--r-- | pkg/humanize/times.go | 141 | ||||
-rw-r--r-- | pkg/service/auth.go | 123 | ||||
-rw-r--r-- | pkg/service/auth_test.go | 119 | ||||
-rw-r--r-- | pkg/service/git.go | 87 | ||||
-rw-r--r-- | pkg/u/list.go | 22 |
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]) } |