Detailed changes
@@ -7,13 +7,12 @@ import (
"io/fs"
"os"
"runtime/debug"
- "strings"
- "time"
"github.com/charmbracelet/log"
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/server/db"
+ logr "github.com/charmbracelet/soft-serve/server/log"
"github.com/charmbracelet/soft-serve/server/store"
"github.com/charmbracelet/soft-serve/server/store/database"
_ "github.com/lib/pq" // postgres driver
@@ -78,7 +77,7 @@ func main() {
}
ctx = config.WithContext(ctx, cfg)
- logger, f, err := newDefaultLogger(cfg)
+ logger, f, err := logr.NewLogger(cfg)
if err != nil {
log.Errorf("failed to create logger: %v", err)
}
@@ -107,44 +106,6 @@ func main() {
}
}
-// newDefaultLogger returns a new logger with default settings.
-func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
- logger := log.NewWithOptions(os.Stderr, log.Options{
- ReportTimestamp: true,
- TimeFormat: time.DateOnly,
- })
-
- switch {
- case config.IsVerbose():
- logger.SetReportCaller(true)
- fallthrough
- case config.IsDebug():
- logger.SetLevel(log.DebugLevel)
- }
-
- logger.SetTimeFormat(cfg.Log.TimeFormat)
-
- switch strings.ToLower(cfg.Log.Format) {
- case "json":
- logger.SetFormatter(log.JSONFormatter)
- case "logfmt":
- logger.SetFormatter(log.LogfmtFormatter)
- case "text":
- logger.SetFormatter(log.TextFormatter)
- }
-
- var f *os.File
- if cfg.Log.Path != "" {
- f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
- if err != nil {
- return nil, nil, err
- }
- logger.SetOutput(f)
- }
-
- return logger, f, nil
-}
-
func initBackendContext(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
cfg := config.FromContext(ctx)
@@ -0,0 +1,38 @@
+package backend
+
+import "testing"
+
+func TestHashPassword(t *testing.T) {
+ hash, err := HashPassword("password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if hash == "" {
+ t.Fatal("hash is empty")
+ }
+}
+
+func TestVerifyPassword(t *testing.T) {
+ hash, err := HashPassword("password")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !VerifyPassword("password", hash) {
+ t.Fatal("password did not verify")
+ }
+}
+
+func TestGenerateToken(t *testing.T) {
+ token := GenerateToken()
+ if token == "" {
+ t.Fatal("token is empty")
+ }
+}
+
+func TestHashToken(t *testing.T) {
+ token := GenerateToken()
+ hash := HashToken(token)
+ if hash == "" {
+ t.Fatal("hash is empty")
+ }
+}
@@ -95,6 +95,16 @@ type DBConfig struct {
DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
}
+// LFSConfig is the configuration for Git LFS.
+type LFSConfig struct {
+ // Enabled is whether or not Git LFS is enabled.
+ Enabled bool `env:"ENABLED" yaml:"enabled"`
+
+ // SSHEnabled is whether or not Git LFS over SSH is enabled.
+ // This is only used if LFS is enabled.
+ SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`
+}
+
// Config is the configuration for Soft Serve.
type Config struct {
// Name is the name of the server.
@@ -118,6 +128,9 @@ type Config struct {
// DB is the database configuration.
DB DBConfig `envPrefix:"DB_" yaml:"db"`
+ // LFS is the configuration for Git LFS.
+ LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`
+
// InitialAdminKeys is a list of public keys that will be added to the list of admins.
InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
@@ -156,6 +169,8 @@ func (c *Config) Environ() []string {
fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
+ fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),
+ fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),
}...)
return envs
@@ -309,6 +324,10 @@ func DefaultConfig() *Config {
DataSource: "soft-serve.db" +
"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
},
+ LFS: LFSConfig{
+ Enabled: true,
+ SSHEnabled: true,
+ },
}
}
@@ -16,5 +16,5 @@ func FromContext(ctx context.Context) *Config {
return c
}
- return DefaultConfig()
+ return nil
}
@@ -90,6 +90,13 @@ db:
# This is driver specific and can be a file path or connection string.
data_source: "{{ .DB.DataSource }}"
+# Git LFS configuration.
+lfs:
+ # Enable Git LFS.
+ enabled: {{ .LFS.Enabled }}
+ # Enable Git SSH transfer.
+ ssh_enabled: {{ .LFS.SSHEnabled }}
+
# Additional admin keys.
#initial_admin_keys:
# - "ssh-rsa AAAAB3NzaC1yc2..."
@@ -0,0 +1,56 @@
+package git
+
+import (
+ "bytes"
+ "fmt"
+ "testing"
+)
+
+func TestPktline(t *testing.T) {
+ cases := []struct {
+ name string
+ in []byte
+ err error
+ out []byte
+ }{
+ {
+ name: "empty",
+ in: []byte{},
+ out: []byte("0005\n0000"),
+ },
+ {
+ name: "simple",
+ in: []byte("hello"),
+ out: []byte("000ahello\n0000"),
+ },
+ {
+ name: "newline",
+ in: []byte("hello\n"),
+ out: []byte("000bhello\n\n0000"),
+ },
+ {
+ name: "error",
+ err: fmt.Errorf("foobar"),
+ out: []byte("000fERR foobar\n0000"),
+ },
+ }
+
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ var out bytes.Buffer
+ if c.err == nil {
+ if err := WritePktline(&out, string(c.in)); err != nil {
+ t.Fatal(err)
+ }
+ } else {
+ if err := WritePktlineErr(&out, c.err); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ if !bytes.Equal(out.Bytes(), c.out) {
+ t.Errorf("expected %q, got %q", c.out, out.Bytes())
+ }
+ })
+ }
+}
@@ -0,0 +1,95 @@
+package lfs
+
+import (
+ "errors"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestReadPointer(t *testing.T) {
+ cases := []struct {
+ name string
+ content string
+ want Pointer
+ wantErr error
+ wantErrp interface{}
+ }{
+ {
+ name: "valid pointer",
+ content: `version https://git-lfs.github.com/spec/v1
+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
+size 1234
+`,
+ want: Pointer{
+ Oid: "1234567890123456789012345678901234567890123456789012345678901234",
+ Size: 1234,
+ },
+ },
+ {
+ name: "invalid prefix",
+ content: `version https://foobar/spec/v2
+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
+size 1234
+`,
+ wantErr: ErrMissingPrefix,
+ },
+ {
+ name: "invalid oid",
+ content: `version https://git-lfs.github.com/spec/v1
+oid sha256:&2345a78$012345678901234567890123456789012345678901234567890123
+size 1234
+`,
+ wantErr: ErrInvalidOIDFormat,
+ },
+ {
+ name: "invalid size",
+ content: `version https://git-lfs.github.com/spec/v1
+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
+size abc
+`,
+ wantErrp: &strconv.NumError{},
+ },
+ {
+ name: "invalid structure",
+ content: `version https://git-lfs.github.com/spec/v1
+`,
+ wantErr: ErrInvalidStructure,
+ },
+ {
+ name: "empty pointer",
+ wantErr: ErrMissingPrefix,
+ },
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ p, err := ReadPointerFromBuffer([]byte(tc.content))
+ if err != tc.wantErr && !errors.As(err, &tc.wantErrp) {
+ t.Errorf("ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)", err, err, tc.wantErr, tc.wantErr)
+ return
+ }
+ if err != nil {
+ return
+ }
+
+ if err == nil {
+ if !p.IsValid() {
+ t.Errorf("Expected a valid pointer")
+ return
+ }
+ if p.Oid != strings.ReplaceAll(p.RelativePath(), "/", "") {
+ t.Errorf("Expected oid to be the relative path without slashes")
+ return
+ }
+ }
+
+ if p.Oid != tc.want.Oid {
+ t.Errorf("ReadPointerFromBuffer() oid = %v, want %v", p.Oid, tc.want.Oid)
+ }
+ if p.Size != tc.want.Size {
+ t.Errorf("ReadPointerFromBuffer() size = %v, want %v", p.Size, tc.want.Size)
+ }
+ })
+ }
+}
@@ -0,0 +1,48 @@
+package log
+
+import (
+ "os"
+ "strings"
+ "time"
+
+ "github.com/charmbracelet/log"
+ "github.com/charmbracelet/soft-serve/server/config"
+)
+
+// NewLogger returns a new logger with default settings.
+func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
+ logger := log.NewWithOptions(os.Stderr, log.Options{
+ ReportTimestamp: true,
+ TimeFormat: time.DateOnly,
+ })
+
+ switch {
+ case config.IsVerbose():
+ logger.SetReportCaller(true)
+ fallthrough
+ case config.IsDebug():
+ logger.SetLevel(log.DebugLevel)
+ }
+
+ logger.SetTimeFormat(cfg.Log.TimeFormat)
+
+ switch strings.ToLower(cfg.Log.Format) {
+ case "json":
+ logger.SetFormatter(log.JSONFormatter)
+ case "logfmt":
+ logger.SetFormatter(log.LogfmtFormatter)
+ case "text":
+ logger.SetFormatter(log.TextFormatter)
+ }
+
+ var f *os.File
+ if cfg.Log.Path != "" {
+ f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
+ if err != nil {
+ return nil, nil, err
+ }
+ logger.SetOutput(f)
+ }
+
+ return logger, f, nil
+}
@@ -132,6 +132,14 @@ func handleGit(s ssh.Session) {
return
case git.LFSTransferService, git.LFSAuthenticateService:
+ if !cfg.LFS.Enabled {
+ return
+ }
+
+ if service == git.LFSTransferService && !cfg.LFS.SSHEnabled {
+ return
+ }
+
if accessLevel < access.ReadWriteAccess {
sshFatal(s, git.ErrNotAuthed)
return
@@ -19,6 +19,9 @@ func authenticate(r *http.Request) (proto.User, error) {
// Prefer the Authorization header
user, err := parseAuthHdr(r)
if err != nil || user == nil {
+ if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+ return nil, err
+ }
return nil, proto.ErrUserNotFound
}
@@ -11,12 +11,13 @@ import (
"github.com/charmbracelet/soft-serve/server/store"
)
-// NewContextMiddleware returns a new context middleware.
+// NewContextHandler returns a new context middleware.
// This middleware adds the config, backend, and logger to the request context.
-func NewContextMiddleware(ctx context.Context) func(http.Handler) http.Handler {
+func NewContextHandler(ctx context.Context) func(http.Handler) http.Handler {
cfg := config.FromContext(ctx)
be := backend.FromContext(ctx)
logger := log.FromContext(ctx).WithPrefix("http")
+ logger.Infof("data path %s", cfg.DataPath)
dbx := db.FromContext(ctx)
datastore := store.FromContext(ctx)
return func(next http.Handler) http.Handler {
@@ -47,6 +47,8 @@ func (g GitRoute) Match(r *http.Request) *http.Request {
// This finds the Git objects & packs filenames in the URL.
file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
repo := utils.SanitizeRepo(m[1])
+ // Add repo suffix (.git)
+ r.URL.Path = fmt.Sprintf("%s.git/%s", repo, file)
var service git.Service
var oid string // LFS object ID
@@ -218,6 +220,7 @@ func askCredentials(w http.ResponseWriter, _ *http.Request) {
func withAccess(next http.Handler) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
+ cfg := config.FromContext(ctx)
logger := log.FromContext(ctx)
be := backend.FromContext(ctx)
@@ -288,8 +291,17 @@ func withAccess(next http.Handler) http.HandlerFunc {
renderInternalServerError(w)
return
}
+
+ ctx = proto.WithRepositoryContext(ctx, repo)
+ r = r.WithContext(ctx)
}
case gitLfsService:
+ if !cfg.LFS.Enabled {
+ logger.Debug("LFS is not enabled, skipping")
+ renderNotFound(w)
+ return
+ }
+
switch {
case strings.HasPrefix(file, "info/lfs/locks"):
switch {
@@ -323,10 +335,20 @@ func withAccess(next http.Handler) http.HandlerFunc {
}
}
if accessLevel < access.ReadOnlyAccess {
- askCredentials(w, r)
- renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
- Message: "credentials needed",
- })
+ if repo == nil {
+ renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+ Message: "repository not found",
+ })
+ } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+ renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
+ Message: "bad credentials",
+ })
+ } else {
+ askCredentials(w, r)
+ renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
+ Message: "credentials needed",
+ })
+ }
return
}
default:
@@ -334,13 +356,15 @@ func withAccess(next http.Handler) http.HandlerFunc {
return
}
- // If the repo doesn't exist, return 404
if repo == nil {
+ // If the repo doesn't exist, return 404
renderNotFound(w)
return
- }
-
- if accessLevel < access.ReadOnlyAccess {
+ } else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
+ // return 403 when bad credentials are provided
+ renderForbidden(w)
+ return
+ } else if accessLevel < access.ReadOnlyAccess {
askCredentials(w, r)
renderUnauthorized(w)
return
@@ -14,6 +14,7 @@ import (
"strings"
"github.com/charmbracelet/log"
+ "github.com/charmbracelet/soft-serve/server/access"
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/server/db"
@@ -47,6 +48,9 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close() // nolint: errcheck
if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
logger.Errorf("error decoding json: %s", err)
+ renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
+ Message: "validation error in request: " + err.Error(),
+ })
return
}
@@ -69,6 +73,13 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
}
}
+ if len(batchRequest.Objects) == 0 {
+ renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
+ Message: "no objects found",
+ })
+ return
+ }
+
name := pat.Param(r, "repo")
repo := proto.RepositoryFromContext(ctx)
if repo == nil {
@@ -169,6 +180,16 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
}
}
case lfs.OperationUpload:
+ // Check authorization
+ accessLevel := access.FromContext(ctx)
+ if accessLevel < access.ReadWriteAccess {
+ askCredentials(w, r)
+ renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
+ Message: "credentials needed",
+ })
+ return
+ }
+
// Object upload logic happens in the "basic" API route
for _, o := range batchRequest.Objects {
if !o.IsValid() {
@@ -368,7 +389,7 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
logger.Error("error decoding json", "err", err)
renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
- Message: "invalid json",
+ Message: "invalid request: " + err.Error(),
})
return
}
@@ -446,7 +467,7 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Error("error decoding json", "err", err)
renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
- Message: "invalid request",
+ Message: "invalid request: " + err.Error(),
})
return
}
@@ -731,7 +752,7 @@ func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Error("error decoding request", "err", err)
renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
- Message: "invalid request",
+ Message: "invalid request: " + err.Error(),
})
return
}
@@ -837,7 +858,7 @@ func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
logger.Error("error decoding request", "err", err)
renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
- Message: "invalid request",
+ Message: "invalid request: " + err.Error(),
})
return
}
@@ -31,7 +31,8 @@ var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html
<body>
Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
</body>
-</html>`))
+</html>
+`))
// GoGetHandler handles go get requests.
type GoGetHandler struct{}
@@ -16,13 +16,9 @@ type Route interface {
// NewRouter returns a new HTTP router.
// TODO: use gorilla/mux and friends
-func NewRouter(ctx context.Context) *goji.Mux {
+func NewRouter(ctx context.Context) http.Handler {
mux := goji.NewMux()
- // Middlewares
- mux.Use(NewContextMiddleware(ctx))
- mux.Use(NewLoggingMiddleware)
-
// Git routes
for _, service := range gitRoutes {
mux.Handle(service, withAccess(service))
@@ -31,5 +27,12 @@ func NewRouter(ctx context.Context) *goji.Mux {
// go-get handler
mux.Handle(pat.Get("/*"), GoGetHandler{})
- return mux
+ // Middlewares
+ mux.Use(NewLoggingMiddleware)
+
+ // Context handler
+ // Adds context to the request
+ ctxHandler := NewContextHandler(ctx)
+
+ return ctxHandler(mux)
}
@@ -14,11 +14,13 @@ import (
"time"
"github.com/charmbracelet/keygen"
+ "github.com/charmbracelet/log"
"github.com/charmbracelet/soft-serve/server"
"github.com/charmbracelet/soft-serve/server/backend"
"github.com/charmbracelet/soft-serve/server/config"
"github.com/charmbracelet/soft-serve/server/db"
"github.com/charmbracelet/soft-serve/server/db/migrate"
+ logr "github.com/charmbracelet/soft-serve/server/log"
"github.com/charmbracelet/soft-serve/server/store"
"github.com/charmbracelet/soft-serve/server/store/database"
"github.com/charmbracelet/soft-serve/server/test"
@@ -54,6 +56,7 @@ func TestScript(t *testing.T) {
"usoft": cmdSoft(user1.Signer()),
"git": cmdGit(key),
"mkfile": cmdMkfile,
+ "envfile": cmdEnvfile,
"readfile": cmdReadfile,
"dos2unix": cmdDos2Unix,
},
@@ -72,6 +75,7 @@ func TestScript(t *testing.T) {
e.Setenv("DATA_PATH", data)
e.Setenv("SSH_PORT", fmt.Sprintf("%d", sshPort))
+ e.Setenv("HTTP_PORT", fmt.Sprintf("%d", httpPort))
e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
@@ -89,6 +93,9 @@ func TestScript(t *testing.T) {
cfg.HTTP.PublicURL = "http://" + httpListen
cfg.Stats.ListenAddr = statsListen
cfg.DB.Driver = "sqlite"
+ cfg.LFS.Enabled = true
+ // TODO: run tests with both SSH enabled/disabled
+ cfg.LFS.SSHEnabled = false
if err := cfg.Validate(); err != nil {
return err
@@ -96,6 +103,16 @@ func TestScript(t *testing.T) {
ctx := config.WithContext(context.Background(), cfg)
+ logger, f, err := logr.NewLogger(cfg)
+ if err != nil {
+ log.Errorf("failed to create logger: %v", err)
+ }
+
+ ctx = log.WithContext(ctx, logger)
+ if f != nil {
+ defer f.Close() // nolint: errcheck
+ }
+
// TODO: test postgres
dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
if err != nil {
@@ -229,6 +246,8 @@ func cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string)
"GIT_SSH_COMMAND",
strings.Join(append([]string{"ssh"}, sshArgs...), " "),
)
+ // Disable git prompting for credentials.
+ ts.Setenv("GIT_TERMINAL_PROMPT", "0")
args = append([]string{
"-c", "user.email=john@example.com",
"-c", "user.name=John Doe",
@@ -260,3 +279,19 @@ func check(ts *testscript.TestScript, err error, neg bool) {
func cmdReadfile(ts *testscript.TestScript, neg bool, args []string) {
ts.Stdout().Write([]byte(ts.ReadFile(args[0])))
}
+
+func cmdEnvfile(ts *testscript.TestScript, neg bool, args []string) {
+ if len(args) < 1 {
+ ts.Fatalf("usage: envfile key=file...")
+ }
+
+ for _, arg := range args {
+ parts := strings.SplitN(arg, "=", 2)
+ if len(parts) != 2 {
+ ts.Fatalf("usage: envfile key=file...")
+ }
+ key := parts[0]
+ file := parts[1]
+ ts.Setenv(key, strings.TrimSpace(ts.ReadFile(file)))
+ }
+}
@@ -0,0 +1,119 @@
+# vi: set ft=conf
+
+# convert crlf to lf on windows
+[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# create access token
+soft token create --expires-in '1h' 'repo2'
+stdout 'ss_*'
+cp stdout tokenfile
+envfile TOKEN=tokenfile
+soft token create --expires-in '1ns' 'repo2'
+stdout 'ss_*'
+cp stdout etokenfile
+envfile ETOKEN=etokenfile
+usoft token create 'repo2'
+stdout 'ss_*'
+cp stdout utokenfile
+envfile UTOKEN=utokenfile
+
+# push & create repo with some files, commits, tags...
+mkdir ./repo2
+git -c init.defaultBranch=master -C repo2 init
+mkfile ./repo2/README.md '# Project\nfoo'
+mkfile ./repo2/foo.png 'foo'
+mkfile ./repo2/bar.png 'bar'
+git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2
+git -C repo2 lfs install --local
+git -C repo2 lfs track '*.png'
+git -C repo2 add -A
+git -C repo2 commit -m 'first'
+git -C repo2 tag v0.1.0
+git -C repo2 push origin HEAD
+git -C repo2 push origin HEAD --tags
+
+# http errors
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111foobar.git/foo/bar
+stdout '404.*'
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111/foobar.git/foo/bar
+stdout '404.*'
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/foo/bar
+stdout '404.*'
+exec curl -s -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/foo
+stdout '404.*'
+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*Method Not Allowed.*'
+exec curl -s -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*Not Acceptable.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*validation error.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*no objects found.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","transfers":["foo"]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*unsupported transfer.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"bar","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*unsupported operation.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"download","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http1.txt
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*credentials needed.*'
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' -d '{"operation":"upload","objects":[{}]}' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http1.txt
+
+# set private
+soft repo private repo2 true
+
+# allow access private
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$TOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http2.txt
+exec curl -s -XPOST -H 'Accept: application/vnd.git-lfs+json' -H 'Content-Type: application/vnd.git-lfs+json' http://$ETOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http3.txt
+
+# deny access private
+exec curl -s http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*credentials needed.*'
+exec curl -s http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+stdout '.*credentials needed.*'
+exec curl -s http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
+cmp stdout http3.txt
+
+# deny access ask for credentials
+# this means the server responded with a 401 and prompted for credentials
+# but we disable git terminal prompting to we get a fatal instead of a 401 "Unauthorized"
+! git clone http://localhost:$HTTP_PORT/repo2 repo2_clone
+cmpenv stderr gitclone.txt
+! git clone http://someuser:somepassword@localhost:$HTTP_PORT/repo2 repo2_clone
+stderr '.*Forbidden.*'
+
+# go-get endpoints not found
+exec curl -s http://localhost:$HTTP_PORT/repo2.git
+stdout '404.*'
+
+# go-get endpoints
+exec curl -s http://localhost:$HTTP_PORT/repo2.git?go-get=1
+cmpenv stdout goget.txt
+
+-- http1.txt --
+{"transfer":"basic","objects":[{"oid":"","size":0,"error":{"code":422,"message":"invalid object"}}],"hash_algo":"sha256"}
+-- http2.txt --
+{"message":"validation error in request: EOF"}
+-- http3.txt --
+{"message":"bad credentials"}
+-- goget.txt --
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
+ <meta http-equiv="refresh" content="0; url=https://godoc.org/localhost:$HTTP_PORT/repo2">
+ <meta name="go-import" content="localhost:$HTTP_PORT/repo2 git http://localhost:$HTTP_PORT/repo2">
+</head>
+<body>
+Redirecting to docs at <a href="https://godoc.org/localhost:$HTTP_PORT/repo2">godoc.org/localhost:$HTTP_PORT/repo2</a>...
+</body>
+</html>
+-- gitclone.txt --
+Cloning into 'repo2_clone'...
+fatal: could not read Username for 'http://localhost:$HTTP_PORT': terminal prompts disabled
@@ -0,0 +1,14 @@
+# vi: set ft=conf
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# generate jwt token
+soft jwt
+stdout '.*\..*\..*'
+soft jwt repo
+stdout '.*\..*\..*'
+usoft jwt
+stdout '.*\..*\..*'
+usoft jwt repo
+stdout '.*\..*\..*'
@@ -27,6 +27,15 @@ git -C repo1 tag v0.1.0
git -C repo1 push origin HEAD
git -C repo1 push origin HEAD --tags
+# create lfs files, use ssh git-lfs-transfer
+git -C repo1 lfs install --local
+git -C repo1 lfs track '*.png'
+mkfile ./repo1/foo.png 'foo'
+mkfile ./repo1/bar.png 'bar'
+git -C repo1 add -A
+git -C repo1 commit -m 'lfs'
+git -C repo1 push origin HEAD
+
# info
soft repo info repo1
cmp stdout info.txt
@@ -77,7 +86,10 @@ soft repo branch list repo1
stdout branch1
-- tree.txt --
+-rw-r--r-- 42 B .gitattributes
-rw-r--r-- 14 B README.md
+-rw-r--r-- 126 B bar.png
+-rw-r--r-- 126 B foo.png
-- readme.md --
# Project\nfoo
-- branch_list.1.txt --
@@ -0,0 +1,28 @@
+# vi: set ft=conf
+
+# create user
+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
+
+# generate jwt token
+usoft token create 'test1'
+stdout 'ss_.*'
+stderr 'Access token created'
+usoft token create --expires-in 1y 'test2'
+stdout 'ss_.*'
+stderr 'Access token created'
+usoft token create --expires-in 1ns 'test3'
+stdout 'ss_.*'
+stderr 'Access token created'
+
+# list tokens
+usoft token list
+cp stdout tokens.txt
+grep '1 test1.* -' tokens.txt
+grep '2 test2.* 1 year from now' tokens.txt
+grep '3 test3.* expired' tokens.txt
+
+# delete token
+usoft token delete 1
+stderr 'Access token deleted'
+! usoft token delete 1
+stderr 'token not found'