af81aece7e65ee6890e2d03698782196abbed62c
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
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'