af81aece7e65ee6890e2d03698782196abbed62c

Author
Ayman Bagabas <ayman.bagabas@gmail.com>
Committer
Ayman Bagabas <ayman.bagabas@gmail.com>
Date

Message

feat: add lfs config and tests

Enable/disable LFS endpoints
Enable/disable SSH LFS transfer

fix: lfs request validation

chore: add jwt, lfs, and http tests

fix: tests race

feat: more tests

fix: return 403 on bad creds

Diff

This diff is truncated to protect this page.

  1diff --git a/cmd/soft/root.go b/cmd/soft/root.go
  2index 9e443b5b39a77ad3028c47ce57c6ddc94146c387..329ec143eab3ce60ac4284920d05e9f301fc7a0d 100644
  3--- a/cmd/soft/root.go
  4+++ b/cmd/soft/root.go
  5@@ -7,13 +7,12 @@ import (
  6 	"io/fs"
  7 	"os"
  8 	"runtime/debug"
  9-	"strings"
 10-	"time"
 11 
 12 	"github.com/charmbracelet/log"
 13 	"github.com/charmbracelet/soft-serve/server/backend"
 14 	"github.com/charmbracelet/soft-serve/server/config"
 15 	"github.com/charmbracelet/soft-serve/server/db"
 16+	logr "github.com/charmbracelet/soft-serve/server/log"
 17 	"github.com/charmbracelet/soft-serve/server/store"
 18 	"github.com/charmbracelet/soft-serve/server/store/database"
 19 	_ "github.com/lib/pq" // postgres driver
 20@@ -78,7 +77,7 @@ func main() {
 21 	}
 22 
 23 	ctx = config.WithContext(ctx, cfg)
 24-	logger, f, err := newDefaultLogger(cfg)
 25+	logger, f, err := logr.NewLogger(cfg)
 26 	if err != nil {
 27 		log.Errorf("failed to create logger: %v", err)
 28 	}
 29@@ -107,44 +106,6 @@ func main() {
 30 	}
 31 }
 32 
 33-// newDefaultLogger returns a new logger with default settings.
 34-func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
 35-	logger := log.NewWithOptions(os.Stderr, log.Options{
 36-		ReportTimestamp: true,
 37-		TimeFormat:      time.DateOnly,
 38-	})
 39-
 40-	switch {
 41-	case config.IsVerbose():
 42-		logger.SetReportCaller(true)
 43-		fallthrough
 44-	case config.IsDebug():
 45-		logger.SetLevel(log.DebugLevel)
 46-	}
 47-
 48-	logger.SetTimeFormat(cfg.Log.TimeFormat)
 49-
 50-	switch strings.ToLower(cfg.Log.Format) {
 51-	case "json":
 52-		logger.SetFormatter(log.JSONFormatter)
 53-	case "logfmt":
 54-		logger.SetFormatter(log.LogfmtFormatter)
 55-	case "text":
 56-		logger.SetFormatter(log.TextFormatter)
 57-	}
 58-
 59-	var f *os.File
 60-	if cfg.Log.Path != "" {
 61-		f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
 62-		if err != nil {
 63-			return nil, nil, err
 64-		}
 65-		logger.SetOutput(f)
 66-	}
 67-
 68-	return logger, f, nil
 69-}
 70-
 71 func initBackendContext(cmd *cobra.Command, _ []string) error {
 72 	ctx := cmd.Context()
 73 	cfg := config.FromContext(ctx)
 74diff --git a/server/backend/auth_test.go b/server/backend/auth_test.go
 75new file mode 100644
 76index 0000000000000000000000000000000000000000..db40db3b967952347f00763cdba2fbba6c265606
 77--- /dev/null
 78+++ b/server/backend/auth_test.go
 79@@ -0,0 +1,38 @@
 80+package backend
 81+
 82+import "testing"
 83+
 84+func TestHashPassword(t *testing.T) {
 85+	hash, err := HashPassword("password")
 86+	if err != nil {
 87+		t.Fatal(err)
 88+	}
 89+	if hash == "" {
 90+		t.Fatal("hash is empty")
 91+	}
 92+}
 93+
 94+func TestVerifyPassword(t *testing.T) {
 95+	hash, err := HashPassword("password")
 96+	if err != nil {
 97+		t.Fatal(err)
 98+	}
 99+	if !VerifyPassword("password", hash) {
100+		t.Fatal("password did not verify")
101+	}
102+}
103+
104+func TestGenerateToken(t *testing.T) {
105+	token := GenerateToken()
106+	if token == "" {
107+		t.Fatal("token is empty")
108+	}
109+}
110+
111+func TestHashToken(t *testing.T) {
112+	token := GenerateToken()
113+	hash := HashToken(token)
114+	if hash == "" {
115+		t.Fatal("hash is empty")
116+	}
117+}
118diff --git a/server/config/config.go b/server/config/config.go
119index c269c63355ab32780e9a57d8eb46c006d4ba984b..aba8f508e3189c7058d2678018d42852f2177248 100644
120--- a/server/config/config.go
121+++ b/server/config/config.go
122@@ -95,6 +95,16 @@ type DBConfig struct {
123 	DataSource string `env:"DATA_SOURCE" yaml:"data_source"`
124 }
125 
126+// LFSConfig is the configuration for Git LFS.
127+type LFSConfig struct {
128+	// Enabled is whether or not Git LFS is enabled.
129+	Enabled bool `env:"ENABLED" yaml:"enabled"`
130+
131+	// SSHEnabled is whether or not Git LFS over SSH is enabled.
132+	// This is only used if LFS is enabled.
133+	SSHEnabled bool `env:"SSH_ENABLED" yaml:"ssh_enabled"`
134+}
135+
136 // Config is the configuration for Soft Serve.
137 type Config struct {
138 	// Name is the name of the server.
139@@ -118,6 +128,9 @@ type Config struct {
140 	// DB is the database configuration.
141 	DB DBConfig `envPrefix:"DB_" yaml:"db"`
142 
143+	// LFS is the configuration for Git LFS.
144+	LFS LFSConfig `envPrefix:"LFS_" yaml:"lfs"`
145+
146 	// InitialAdminKeys is a list of public keys that will be added to the list of admins.
147 	InitialAdminKeys []string `env:"INITIAL_ADMIN_KEYS" envSeparator:"\n" yaml:"initial_admin_keys"`
148 
149@@ -156,6 +169,8 @@ func (c *Config) Environ() []string {
150 		fmt.Sprintf("SOFT_SERVE_LOG_TIME_FORMAT=%s", c.Log.TimeFormat),
151 		fmt.Sprintf("SOFT_SERVE_DB_DRIVER=%s", c.DB.Driver),
152 		fmt.Sprintf("SOFT_SERVE_DB_DATA_SOURCE=%s", c.DB.DataSource),
153+		fmt.Sprintf("SOFT_SERVE_LFS_ENABLED=%t", c.LFS.Enabled),
154+		fmt.Sprintf("SOFT_SERVE_LFS_SSH_ENABLED=%t", c.LFS.SSHEnabled),
155 	}...)
156 
157 	return envs
158@@ -309,6 +324,10 @@ func DefaultConfig() *Config {
159 			DataSource: "soft-serve.db" +
160 				"?_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)",
161 		},
162+		LFS: LFSConfig{
163+			Enabled:    true,
164+			SSHEnabled: true,
165+		},
166 	}
167 }
168 
169diff --git a/server/config/context.go b/server/config/context.go
170index 2a9c47bcf3433870310ce950e0fd0743e8f84b4c..e364fefbabd983ea31bc1e307a27d739ca43d92f 100644
171--- a/server/config/context.go
172+++ b/server/config/context.go
173@@ -16,5 +16,5 @@ func FromContext(ctx context.Context) *Config {
174 		return c
175 	}
176 
177-	return DefaultConfig()
178+	return nil
179 }
180diff --git a/server/config/file.go b/server/config/file.go
181index a3b78c0160d57909f9f120489ed87a41daf489fc..a4e39eb7cfb063b7b7db7974ff692701a9d72e63 100644
182--- a/server/config/file.go
183+++ b/server/config/file.go
184@@ -90,6 +90,13 @@ db:
185   # This is driver specific and can be a file path or connection string.
186   data_source: "{{ .DB.DataSource }}"
187 
188+# Git LFS configuration.
189+lfs:
190+  # Enable Git LFS.
191+  enabled: {{ .LFS.Enabled }}
192+  # Enable Git SSH transfer.
193+  ssh_enabled: {{ .LFS.SSHEnabled }}
194+
195 # Additional admin keys.
196 #initial_admin_keys:
197 #  - "ssh-rsa AAAAB3NzaC1yc2..."
198diff --git a/server/git/git_test.go b/server/git/git_test.go
199new file mode 100644
200index 0000000000000000000000000000000000000000..d95cb6497daed360740ccfb50a5a79c2d2944712
201--- /dev/null
202+++ b/server/git/git_test.go
203@@ -0,0 +1,56 @@
204+package git
205+
206+import (
207+	"bytes"
208+	"fmt"
209+	"testing"
210+)
211+
212+func TestPktline(t *testing.T) {
213+	cases := []struct {
214+		name string
215+		in   []byte
216+		err  error
217+		out  []byte
218+	}{
219+		{
220+			name: "empty",
221+			in:   []byte{},
222+			out:  []byte("0005\n0000"),
223+		},
224+		{
225+			name: "simple",
226+			in:   []byte("hello"),
227+			out:  []byte("000ahello\n0000"),
228+		},
229+		{
230+			name: "newline",
231+			in:   []byte("hello\n"),
232+			out:  []byte("000bhello\n\n0000"),
233+		},
234+		{
235+			name: "error",
236+			err:  fmt.Errorf("foobar"),
237+			out:  []byte("000fERR foobar\n0000"),
238+		},
239+	}
240+
241+	for _, c := range cases {
242+		t.Run(c.name, func(t *testing.T) {
243+			var out bytes.Buffer
244+			if c.err == nil {
245+				if err := WritePktline(&out, string(c.in)); err != nil {
246+					t.Fatal(err)
247+				}
248+			} else {
249+				if err := WritePktlineErr(&out, c.err); err != nil {
250+					t.Fatal(err)
251+				}
252+			}
253+
254+			if !bytes.Equal(out.Bytes(), c.out) {
255+				t.Errorf("expected %q, got %q", c.out, out.Bytes())
256+			}
257+		})
258+	}
259+}
260diff --git a/server/lfs/pointer_test.go b/server/lfs/pointer_test.go
261new file mode 100644
262index 0000000000000000000000000000000000000000..df2f2daa2fb5e62d8cee71226d894a23676787c4
263--- /dev/null
264+++ b/server/lfs/pointer_test.go
265@@ -0,0 +1,95 @@
266+package lfs
267+
268+import (
269+	"errors"
270+	"strconv"
271+	"strings"
272+	"testing"
273+)
274+
275+func TestReadPointer(t *testing.T) {
276+	cases := []struct {
277+		name     string
278+		content  string
279+		want     Pointer
280+		wantErr  error
281+		wantErrp interface{}
282+	}{
283+		{
284+			name: "valid pointer",
285+			content: `version https://git-lfs.github.com/spec/v1
286+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
287+size 1234
288+`,
289+			want: Pointer{
290+				Oid:  "1234567890123456789012345678901234567890123456789012345678901234",
291+				Size: 1234,
292+			},
293+		},
294+		{
295+			name: "invalid prefix",
296+			content: `version https://foobar/spec/v2
297+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
298+size 1234
299+`,
300+			wantErr: ErrMissingPrefix,
301+		},
302+		{
303+			name: "invalid oid",
304+			content: `version https://git-lfs.github.com/spec/v1
305+oid sha256:&2345a78$012345678901234567890123456789012345678901234567890123
306+size 1234
307+`,
308+			wantErr: ErrInvalidOIDFormat,
309+		},
310+		{
311+			name: "invalid size",
312+			content: `version https://git-lfs.github.com/spec/v1
313+oid sha256:1234567890123456789012345678901234567890123456789012345678901234
314+size abc
315+`,
316+			wantErrp: &strconv.NumError{},
317+		},
318+		{
319+			name: "invalid structure",
320+			content: `version https://git-lfs.github.com/spec/v1
321+`,
322+			wantErr: ErrInvalidStructure,
323+		},
324+		{
325+			name:    "empty pointer",
326+			wantErr: ErrMissingPrefix,
327+		},
328+	}
329+
330+	for _, tc := range cases {
331+		t.Run(tc.name, func(t *testing.T) {
332+			p, err := ReadPointerFromBuffer([]byte(tc.content))
333+			if err != tc.wantErr && !errors.As(err, &tc.wantErrp) {
334+				t.Errorf("ReadPointerFromBuffer() error = %v(%T), wantErr %v(%T)", err, err, tc.wantErr, tc.wantErr)
335+				return
336+			}
337+			if err != nil {
338+				return
339+			}
340+
341+			if err == nil {
342+				if !p.IsValid() {
343+					t.Errorf("Expected a valid pointer")
344+					return
345+				}
346+				if p.Oid != strings.ReplaceAll(p.RelativePath(), "/", "") {
347+					t.Errorf("Expected oid to be the relative path without slashes")
348+					return
349+				}
350+			}
351+
352+			if p.Oid != tc.want.Oid {
353+				t.Errorf("ReadPointerFromBuffer() oid = %v, want %v", p.Oid, tc.want.Oid)
354+			}
355+			if p.Size != tc.want.Size {
356+				t.Errorf("ReadPointerFromBuffer() size = %v, want %v", p.Size, tc.want.Size)
357+			}
358+		})
359+	}
360+}
361diff --git a/server/log/log.go b/server/log/log.go
362new file mode 100644
363index 0000000000000000000000000000000000000000..3162d87689e8652c2a107ec0840ecd415fd4986f
364--- /dev/null
365+++ b/server/log/log.go
366@@ -0,0 +1,48 @@
367+package log
368+
369+import (
370+	"os"
371+	"strings"
372+	"time"
373+
374+	"github.com/charmbracelet/log"
375+	"github.com/charmbracelet/soft-serve/server/config"
376+)
377+
378+// NewLogger returns a new logger with default settings.
379+func NewLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
380+	logger := log.NewWithOptions(os.Stderr, log.Options{
381+		ReportTimestamp: true,
382+		TimeFormat:      time.DateOnly,
383+	})
384+
385+	switch {
386+	case config.IsVerbose():
387+		logger.SetReportCaller(true)
388+		fallthrough
389+	case config.IsDebug():
390+		logger.SetLevel(log.DebugLevel)
391+	}
392+
393+	logger.SetTimeFormat(cfg.Log.TimeFormat)
394+
395+	switch strings.ToLower(cfg.Log.Format) {
396+	case "json":
397+		logger.SetFormatter(log.JSONFormatter)
398+	case "logfmt":
399+		logger.SetFormatter(log.LogfmtFormatter)
400+	case "text":
401+		logger.SetFormatter(log.TextFormatter)
402+	}
403+
404+	var f *os.File
405+	if cfg.Log.Path != "" {
406+		f, err := os.OpenFile(cfg.Log.Path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
407+		if err != nil {
408+			return nil, nil, err
409+		}
410+		logger.SetOutput(f)
411+	}
412+
413+	return logger, f, nil
414+}
415diff --git a/server/ssh/git.go b/server/ssh/git.go
416index 52320ed6b1b502a84de99eccc72aeb86efe0470f..eac3575913dcfc614142b7e6bed5d0e423d1c87e 100644
417--- a/server/ssh/git.go
418+++ b/server/ssh/git.go
419@@ -132,6 +132,14 @@ func handleGit(s ssh.Session) {
420 
421 		return
422 	case git.LFSTransferService, git.LFSAuthenticateService:
423+		if !cfg.LFS.Enabled {
424+			return
425+		}
426+
427+		if service == git.LFSTransferService && !cfg.LFS.SSHEnabled {
428+			return
429+		}
430+
431 		if accessLevel < access.ReadWriteAccess {
432 			sshFatal(s, git.ErrNotAuthed)
433 			return
434diff --git a/server/web/auth.go b/server/web/auth.go
435index 2a42daa1686598a5273d1cd41bd5a25268397d96..fb090fa6311e73cc816e7c3bd024834b9be431f6 100644
436--- a/server/web/auth.go
437+++ b/server/web/auth.go
438@@ -19,6 +19,9 @@ func authenticate(r *http.Request) (proto.User, error) {
439 	// Prefer the Authorization header
440 	user, err := parseAuthHdr(r)
441 	if err != nil || user == nil {
442+		if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
443+			return nil, err
444+		}
445 		return nil, proto.ErrUserNotFound
446 	}
447 
448diff --git a/server/web/context.go b/server/web/context.go
449index a527b9da0a68f65b9daa8240866976b89775dfc3..b0a2480e787d10bbeb6a10cb919dd3abbc070539 100644
450--- a/server/web/context.go
451+++ b/server/web/context.go
452@@ -11,12 +11,13 @@ import (
453 	"github.com/charmbracelet/soft-serve/server/store"
454 )
455 
456-// NewContextMiddleware returns a new context middleware.
457+// NewContextHandler returns a new context middleware.
458 // This middleware adds the config, backend, and logger to the request context.
459-func NewContextMiddleware(ctx context.Context) func(http.Handler) http.Handler {
460+func NewContextHandler(ctx context.Context) func(http.Handler) http.Handler {
461 	cfg := config.FromContext(ctx)
462 	be := backend.FromContext(ctx)
463 	logger := log.FromContext(ctx).WithPrefix("http")
464+	logger.Infof("data path %s", cfg.DataPath)
465 	dbx := db.FromContext(ctx)
466 	datastore := store.FromContext(ctx)
467 	return func(next http.Handler) http.Handler {
468diff --git a/server/web/git.go b/server/web/git.go
469index 8ee678dbd908d8f8a7d3d1b8bf492fec06e11593..a2158f434edf64031ff5b1aaddb9c7deaf16db4a 100644
470--- a/server/web/git.go
471+++ b/server/web/git.go
472@@ -47,6 +47,8 @@ func (g GitRoute) Match(r *http.Request) *http.Request {
473 		// This finds the Git objects & packs filenames in the URL.
474 		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
475 		repo := utils.SanitizeRepo(m[1])
476+		// Add repo suffix (.git)
477+		r.URL.Path = fmt.Sprintf("%s.git/%s", repo, file)
478 
479 		var service git.Service
480 		var oid string    // LFS object ID
481@@ -218,6 +220,7 @@ func askCredentials(w http.ResponseWriter, _ *http.Request) {
482 func withAccess(next http.Handler) http.HandlerFunc {
483 	return func(w http.ResponseWriter, r *http.Request) {
484 		ctx := r.Context()
485+		cfg := config.FromContext(ctx)
486 		logger := log.FromContext(ctx)
487 		be := backend.FromContext(ctx)
488 
489@@ -288,8 +291,17 @@ func withAccess(next http.Handler) http.HandlerFunc {
490 					renderInternalServerError(w)
491 					return
492 				}
493+
494+				ctx = proto.WithRepositoryContext(ctx, repo)
495+				r = r.WithContext(ctx)
496 			}
497 		case gitLfsService:
498+			if !cfg.LFS.Enabled {
499+				logger.Debug("LFS is not enabled, skipping")
500+				renderNotFound(w)
501+				return
502+			}
503+
504 			switch {
505 			case strings.HasPrefix(file, "info/lfs/locks"):
506 				switch {
507@@ -323,10 +335,20 @@ func withAccess(next http.Handler) http.HandlerFunc {
508 				}
509 			}
510 			if accessLevel < access.ReadOnlyAccess {
511-				askCredentials(w, r)
512-				renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
513-					Message: "credentials needed",
514-				})
515+				if repo == nil {
516+					renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
517+						Message: "repository not found",
518+					})
519+				} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
520+					renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
521+						Message: "bad credentials",
522+					})
523+				} else {
524+					askCredentials(w, r)
525+					renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
526+						Message: "credentials needed",
527+					})
528+				}
529 				return
530 			}
531 		default:
532@@ -334,13 +356,15 @@ func withAccess(next http.Handler) http.HandlerFunc {
533 			return
534 		}
535 
536-		// If the repo doesn't exist, return 404
537 		if repo == nil {
538+			// If the repo doesn't exist, return 404
539 			renderNotFound(w)
540 			return
541-		}
542-
543-		if accessLevel < access.ReadOnlyAccess {
544+		} else if errors.Is(err, ErrInvalidToken) || errors.Is(err, ErrInvalidPassword) {
545+			// return 403 when bad credentials are provided
546+			renderForbidden(w)
547+			return
548+		} else if accessLevel < access.ReadOnlyAccess {
549 			askCredentials(w, r)
550 			renderUnauthorized(w)
551 			return
552diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go
553index f243ab973e2002b41b0cc97022e645aeb55dfe06..e49ca18abc99a92f255bd19a7dd87661f0874f39 100644
554--- a/server/web/git_lfs.go
555+++ b/server/web/git_lfs.go
556@@ -14,6 +14,7 @@ import (
557 	"strings"
558 
559 	"github.com/charmbracelet/log"
560+	"github.com/charmbracelet/soft-serve/server/access"
561 	"github.com/charmbracelet/soft-serve/server/backend"
562 	"github.com/charmbracelet/soft-serve/server/config"
563 	"github.com/charmbracelet/soft-serve/server/db"
564@@ -47,6 +48,9 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
565 	defer r.Body.Close() // nolint: errcheck
566 	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
567 		logger.Errorf("error decoding json: %s", err)
568+		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
569+			Message: "validation error in request: " + err.Error(),
570+		})
571 		return
572 	}
573 
574@@ -69,6 +73,13 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
575 		}
576 	}
577 
578+	if len(batchRequest.Objects) == 0 {
579+		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
580+			Message: "no objects found",
581+		})
582+		return
583+	}
584+
585 	name := pat.Param(r, "repo")
586 	repo := proto.RepositoryFromContext(ctx)
587 	if repo == nil {
588@@ -169,6 +180,16 @@ func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
589 			}
590 		}
591 	case lfs.OperationUpload:
592+		// Check authorization
593+		accessLevel := access.FromContext(ctx)
594+		if accessLevel < access.ReadWriteAccess {
595+			askCredentials(w, r)
596+			renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
597+				Message: "credentials needed",
598+			})
599+			return
600+		}
601+
602 		// Object upload logic happens in the "basic" API route
603 		for _, o := range batchRequest.Objects {
604 			if !o.IsValid() {
605@@ -368,7 +389,7 @@ func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
606 	if err := json.NewDecoder(r.Body).Decode(&pointer); err != nil {
607 		logger.Error("error decoding json", "err", err)
608 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
609-			Message: "invalid json",
610+			Message: "invalid request: " + err.Error(),
611 		})
612 		return
613 	}
614@@ -446,7 +467,7 @@ func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
615 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
616 		logger.Error("error decoding json", "err", err)
617 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
618-			Message: "invalid request",
619+			Message: "invalid request: " + err.Error(),
620 		})
621 		return
622 	}
623@@ -731,7 +752,7 @@ func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
624 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
625 		logger.Error("error decoding request", "err", err)
626 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
627-			Message: "invalid request",
628+			Message: "invalid request: " + err.Error(),
629 		})
630 		return
631 	}
632@@ -837,7 +858,7 @@ func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
633 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
634 		logger.Error("error decoding request", "err", err)
635 		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
636-			Message: "invalid request",
637+			Message: "invalid request: " + err.Error(),
638 		})
639 		return
640 	}
641diff --git a/server/web/goget.go b/server/web/goget.go
642index 8ee4609f1ca106a151aa67deb48b0406f0693681..7d9ec1466606b3a8e76d7f37dc9633411e6814d3 100644
643--- a/server/web/goget.go
644+++ b/server/web/goget.go
645@@ -31,7 +31,8 @@ var repoIndexHTMLTpl = template.Must(template.New("index").Parse(`<!DOCTYPE html
646 <body>
647 Redirecting to docs at <a href="https://godoc.org/{{ .ImportRoot }}/{{ .Repo }}">godoc.org/{{ .ImportRoot }}/{{ .Repo }}</a>...
648 </body>
649-</html>`))
650+</html>
651+`))
652 
653 // GoGetHandler handles go get requests.
654 type GoGetHandler struct{}
655diff --git a/server/web/server.go b/server/web/server.go
656index cdb854726943a823009720f43a24dcee4124fef2..af344e7a1bd6bfe67178881a78061b7783d5d9dc 100644
657--- a/server/web/server.go
658+++ b/server/web/server.go
659@@ -16,13 +16,9 @@ type Route interface {
660 
661 // NewRouter returns a new HTTP router.
662 // TODO: use gorilla/mux and friends
663-func NewRouter(ctx context.Context) *goji.Mux {
664+func NewRouter(ctx context.Context) http.Handler {
665 	mux := goji.NewMux()
666 
667-	// Middlewares
668-	mux.Use(NewContextMiddleware(ctx))
669-	mux.Use(NewLoggingMiddleware)
670-
671 	// Git routes
672 	for _, service := range gitRoutes {
673 		mux.Handle(service, withAccess(service))
674@@ -31,5 +27,12 @@ func NewRouter(ctx context.Context) *goji.Mux {
675 	// go-get handler
676 	mux.Handle(pat.Get("/*"), GoGetHandler{})
677 
678-	return mux
679+	// Middlewares
680+	mux.Use(NewLoggingMiddleware)
681+
682+	// Context handler
683+	// Adds context to the request
684+	ctxHandler := NewContextHandler(ctx)
685+
686+	return ctxHandler(mux)
687 }
688diff --git a/testscript/script_test.go b/testscript/script_test.go
689index d16c19f3fe304bc64febe5c42fe8008ef7ce8d0a..8c9e3ceae7c4f393c71466a9319a252827e32542 100644
690--- a/testscript/script_test.go
691+++ b/testscript/script_test.go
692@@ -14,11 +14,13 @@ import (
693 	"time"
694 
695 	"github.com/charmbracelet/keygen"
696+	"github.com/charmbracelet/log"
697 	"github.com/charmbracelet/soft-serve/server"
698 	"github.com/charmbracelet/soft-serve/server/backend"
699 	"github.com/charmbracelet/soft-serve/server/config"
700 	"github.com/charmbracelet/soft-serve/server/db"
701 	"github.com/charmbracelet/soft-serve/server/db/migrate"
702+	logr "github.com/charmbracelet/soft-serve/server/log"
703 	"github.com/charmbracelet/soft-serve/server/store"
704 	"github.com/charmbracelet/soft-serve/server/store/database"
705 	"github.com/charmbracelet/soft-serve/server/test"
706@@ -54,6 +56,7 @@ func TestScript(t *testing.T) {
707 			"usoft":    cmdSoft(user1.Signer()),
708 			"git":      cmdGit(key),
709 			"mkfile":   cmdMkfile,
710+			"envfile":  cmdEnvfile,
711 			"readfile": cmdReadfile,
712 			"dos2unix": cmdDos2Unix,
713 		},
714@@ -72,6 +75,7 @@ func TestScript(t *testing.T) {
715 
716 			e.Setenv("DATA_PATH", data)
717 			e.Setenv("SSH_PORT", fmt.Sprintf("%d", sshPort))
718+			e.Setenv("HTTP_PORT", fmt.Sprintf("%d", httpPort))
719 			e.Setenv("ADMIN1_AUTHORIZED_KEY", admin1.AuthorizedKey())
720 			e.Setenv("ADMIN2_AUTHORIZED_KEY", admin2.AuthorizedKey())
721 			e.Setenv("USER1_AUTHORIZED_KEY", user1.AuthorizedKey())
722@@ -89,6 +93,9 @@ func TestScript(t *testing.T) {
723 			cfg.HTTP.PublicURL = "http://" + httpListen
724 			cfg.Stats.ListenAddr = statsListen
725 			cfg.DB.Driver = "sqlite"
726+			cfg.LFS.Enabled = true
727+			// TODO: run tests with both SSH enabled/disabled
728+			cfg.LFS.SSHEnabled = false
729 
730 			if err := cfg.Validate(); err != nil {
731 				return err
732@@ -96,6 +103,16 @@ func TestScript(t *testing.T) {
733 
734 			ctx := config.WithContext(context.Background(), cfg)
735 
736+			logger, f, err := logr.NewLogger(cfg)
737+			if err != nil {
738+				log.Errorf("failed to create logger: %v", err)
739+			}
740+
741+			ctx = log.WithContext(ctx, logger)
742+			if f != nil {
743+				defer f.Close() // nolint: errcheck
744+			}
745+
746 			// TODO: test postgres
747 			dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
748 			if err != nil {
749@@ -229,6 +246,8 @@ func cmdGit(key string) func(ts *testscript.TestScript, neg bool, args []string)
750 			"GIT_SSH_COMMAND",
751 			strings.Join(append([]string{"ssh"}, sshArgs...), " "),
752 		)
753+		// Disable git prompting for credentials.
754+		ts.Setenv("GIT_TERMINAL_PROMPT", "0")
755 		args = append([]string{
756 			"-c", "user.email=john@example.com",
757 			"-c", "user.name=John Doe",
758@@ -260,3 +279,19 @@ func check(ts *testscript.TestScript, err error, neg bool) {
759 func cmdReadfile(ts *testscript.TestScript, neg bool, args []string) {
760 	ts.Stdout().Write([]byte(ts.ReadFile(args[0])))
761 }
762+
763+func cmdEnvfile(ts *testscript.TestScript, neg bool, args []string) {
764+	if len(args) < 1 {
765+		ts.Fatalf("usage: envfile key=file...")
766+	}
767+
768+	for _, arg := range args {
769+		parts := strings.SplitN(arg, "=", 2)
770+		if len(parts) != 2 {
771+			ts.Fatalf("usage: envfile key=file...")
772+		}
773+		key := parts[0]
774+		file := parts[1]
775+		ts.Setenv(key, strings.TrimSpace(ts.ReadFile(file)))
776+	}
777+}
778diff --git a/testscript/testdata/http.txtar b/testscript/testdata/http.txtar
779new file mode 100644
780index 0000000000000000000000000000000000000000..e0710514607f58601415fb106aab459a6925d395
781--- /dev/null
782+++ b/testscript/testdata/http.txtar
783@@ -0,0 +1,119 @@
784+# vi: set ft=conf
785+
786+# convert crlf to lf on windows
787+[windows] dos2unix http1.txt http2.txt http3.txt goget.txt gitclone.txt
788+
789+# create user
790+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
791+
792+# create access token
793+soft token create --expires-in '1h' 'repo2'
794+stdout 'ss_*'
795+cp stdout tokenfile
796+envfile TOKEN=tokenfile
797+soft token create --expires-in '1ns' 'repo2'
798+stdout 'ss_*'
799+cp stdout etokenfile
800+envfile ETOKEN=etokenfile
801+usoft token create 'repo2'
802+stdout 'ss_*'
803+cp stdout utokenfile
804+envfile UTOKEN=utokenfile
805+
806+# push & create repo with some files, commits, tags...
807+mkdir ./repo2
808+git -c init.defaultBranch=master -C repo2 init
809+mkfile ./repo2/README.md '# Project\nfoo'
810+mkfile ./repo2/foo.png 'foo'
811+mkfile ./repo2/bar.png 'bar'
812+git -C repo2 remote add origin http://$TOKEN@localhost:$HTTP_PORT/repo2
813+git -C repo2 lfs install --local
814+git -C repo2 lfs track '*.png'
815+git -C repo2 add -A
816+git -C repo2 commit -m 'first'
817+git -C repo2 tag v0.1.0
818+git -C repo2 push origin HEAD
819+git -C repo2 push origin HEAD --tags
820+
821+# http errors
822+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111foobar.git/foo/bar
823+stdout '404.*'
824+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2111/foobar.git/foo/bar
825+stdout '404.*'
826+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/foo/bar
827+stdout '404.*'
828+exec curl -s -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/foo
829+stdout '404.*'
830+exec curl -s -XGET http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
831+stdout '.*Method Not Allowed.*'
832+exec curl -s -XPOST http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
833+stdout '.*Not Acceptable.*'
834+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
835+stdout '.*validation error.*'
836+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
837+stdout '.*no objects found.*'
838+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
839+stdout '.*unsupported transfer.*'
840+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
841+stdout '.*unsupported operation.*'
842+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
843+cmp stdout http1.txt
844+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
845+stdout '.*credentials needed.*'
846+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
847+cmp stdout http1.txt
848+
849+# set private
850+soft repo private repo2 true
851+
852+# allow access private
853+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
854+cmp stdout http2.txt
855+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
856+cmp stdout http3.txt
857+
858+# deny access private
859+exec curl -s http://localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
860+stdout '.*credentials needed.*'
861+exec curl -s http://$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
862+stdout '.*credentials needed.*'
863+exec curl -s http://0$UTOKEN@localhost:$HTTP_PORT/repo2.git/info/lfs/objects/batch
864+cmp stdout http3.txt
865+
866+# deny access ask for credentials
867+# this means the server responded with a 401 and prompted for credentials
868+# but we disable git terminal prompting to we get a fatal instead of a 401 "Unauthorized"
869+! git clone http://localhost:$HTTP_PORT/repo2 repo2_clone
870+cmpenv stderr gitclone.txt
871+! git clone http://someuser:somepassword@localhost:$HTTP_PORT/repo2 repo2_clone
872+stderr '.*Forbidden.*'
873+
874+# go-get endpoints not found
875+exec curl -s http://localhost:$HTTP_PORT/repo2.git
876+stdout '404.*'
877+
878+# go-get endpoints
879+exec curl -s http://localhost:$HTTP_PORT/repo2.git?go-get=1
880+cmpenv stdout goget.txt
881+
882+-- http1.txt --
883diff --git a/testscript/testdata/jwt.txtar b/testscript/testdata/jwt.txtar
884new file mode 100644
885index 0000000000000000000000000000000000000000..07c605e948938d4f293d13c875f274d433887016
886--- /dev/null
887+++ b/testscript/testdata/jwt.txtar
888@@ -0,0 +1,14 @@
889+# vi: set ft=conf
890+
891+# create user
892+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
893+
894+# generate jwt token
895+soft jwt
896+stdout '.*\..*\..*'
897+soft jwt repo
898+stdout '.*\..*\..*'
899+usoft jwt
900+stdout '.*\..*\..*'
901+usoft jwt repo
902+stdout '.*\..*\..*'
903diff --git a/testscript/testdata/repo-create.txtar b/testscript/testdata/repo-create.txtar
904index 31c7028b8faaea888e34fad9f2b6f2211b08dfbf..d81f6b976151dd058c3325b50cb569422d7f81fe 100644
905--- a/testscript/testdata/repo-create.txtar
906+++ b/testscript/testdata/repo-create.txtar
907@@ -27,6 +27,15 @@ git -C repo1 tag v0.1.0
908 git -C repo1 push origin HEAD
909 git -C repo1 push origin HEAD --tags
910 
911+# create lfs files, use ssh git-lfs-transfer
912+git -C repo1 lfs install --local
913+git -C repo1 lfs track '*.png'
914+mkfile ./repo1/foo.png 'foo'
915+mkfile ./repo1/bar.png 'bar'
916+git -C repo1 add -A
917+git -C repo1 commit -m 'lfs'
918+git -C repo1 push origin HEAD
919+
920 # info
921 soft repo info repo1
922 cmp stdout info.txt
923@@ -77,7 +86,10 @@ soft repo branch list repo1
924 stdout branch1
925 
926 -- tree.txt --
927+-rw-r--r--	42 B	 .gitattributes
928 -rw-r--r--	14 B	 README.md
929+-rw-r--r--	126 B	 bar.png
930+-rw-r--r--	126 B	 foo.png
931 -- readme.md --
932 # Project\nfoo
933 -- branch_list.1.txt --
934diff --git a/testscript/testdata/token.txtar b/testscript/testdata/token.txtar
935new file mode 100644
936index 0000000000000000000000000000000000000000..d5010c74f3881add8789a4d11e1613c525057dee
937--- /dev/null
938+++ b/testscript/testdata/token.txtar
939@@ -0,0 +1,28 @@
940+# vi: set ft=conf
941+
942+# create user
943+soft user create user1 --key "$USER1_AUTHORIZED_KEY"
944+
945+# generate jwt token
946+usoft token create 'test1'
947+stdout 'ss_.*'
948+stderr 'Access token created'
949+usoft token create --expires-in 1y 'test2'
950+stdout 'ss_.*'
951+stderr 'Access token created'
952+usoft token create --expires-in 1ns 'test3'
953+stdout 'ss_.*'
954+stderr 'Access token created'
955+
956+# list tokens
957+usoft token list
958+cp stdout tokens.txt
959+grep '1         test1.*       -' tokens.txt
960+grep '2         test2.*       1 year from now' tokens.txt
961+grep '3         test3.*       expired' tokens.txt
962+
963+# delete token
964+usoft token delete 1
965+stderr 'Access token deleted'
966+! usoft token delete 1
967+stderr 'token not found'