feat(web): implement git auth and lfs

Ayman Bagabas created

Generate jwt tokens
Implement git-lfs-authenticate to generate tokens through ssh
Authenticate user using HTTP

fix: git lfs endpoint auth

feat: git lfs locks

Implement git lfs locks endpoints

fix: tests

fix: access tokens migration

add expires_at

fix: lint errors

fix: cleanup

Revert "fix: cleanup"

This reverts commit 728173fbb9594d8ba32e0ca6223be08646e0136a.

fix(db): don't drop tables

Change summary

cmd/soft/hook.go                                           |  33 
cmd/soft/root.go                                           |   7 
go.mod                                                     |  18 
go.sum                                                     | 113 
server/access/context.go                                   |  20 
server/backend/auth.go                                     |  38 
server/backend/collab.go                                   |   4 
server/backend/hooks.go                                    |   2 
server/backend/lfs.go                                      |   3 
server/backend/repo.go                                     |  12 
server/backend/user.go                                     |  41 
server/config/ssh.go                                       |   8 
server/db/errors.go                                        |   2 
server/db/migrate/0001_create_tables_postgres.down.sql     |   5 
server/db/migrate/0001_create_tables_sqlite.down.sql       |   5 
server/db/migrate/0002_create_lfs_tables_postgres.down.sql |   2 
server/db/migrate/0002_create_lfs_tables_postgres.up.sql   |  12 
server/db/migrate/0002_create_lfs_tables_sqlite.down.sql   |   2 
server/db/migrate/0002_create_lfs_tables_sqlite.up.sql     |   4 
server/db/migrate/0003_password_tokens.go                  |  23 
server/db/migrate/0003_password_tokens_postgres.down.sql   |   2 
server/db/migrate/0003_password_tokens_postgres.up.sql     |  15 
server/db/migrate/0003_password_tokens_sqlite.down.sql     |   1 
server/db/migrate/0003_password_tokens_sqlite.up.sql       |  15 
server/db/migrate/migrate.go                               |   2 
server/db/migrate/migrations.go                            |   1 
server/db/models/access_token.go                           |  17 
server/db/models/user.go                                   |  16 
server/git/lfs.go                                          | 148 
server/git/lfs_auth.go                                     |  85 
server/git/service.go                                      |   5 
server/jwk/jwk.go                                          |  49 
server/lfs/common.go                                       |  77 
server/proto/context.go                                    |  35 
server/proto/errors.go                                     |  10 
server/proto/repo.go                                       |   3 
server/proto/user.go                                       |   3 
server/ssh/cmd/cmd.go                                      |  15 
server/ssh/cmd/jwt.go                                      |  56 
server/ssh/git.go                                          |  40 
server/ssh/ssh.go                                          |  11 
server/storage/local.go                                    |   9 
server/storage/storage.go                                  |   2 
server/store/access_token.go                               |   1 
server/store/collab.go                                     |  17 
server/store/database/lfs.go                               |  80 
server/store/database/user.go                              |  19 
server/store/lfs.go                                        |  12 
server/store/repo.go                                       |  27 
server/store/settings.go                                   |  16 
server/store/store.go                                      |  61 
server/store/user.go                                       |  27 
server/web/auth.go                                         | 109 
server/web/context.go                                      |   6 
server/web/git.go                                          | 353 ++
server/web/git_lfs.go                                      | 954 ++++++++
server/web/http.go                                         |   3 
server/web/server.go                                       |   4 
server/web/util.go                                         |  10 
testscript/testdata/help.txtar                             |   1 
testscript/testdata/repo-perms.txtar                       |  28 
testscript/testdata/repo-tree.txtar                        |   2 
62 files changed, 2,333 insertions(+), 368 deletions(-)

Detailed changes

cmd/soft/hook.go 🔗

@@ -4,6 +4,7 @@ import (
 	"bufio"
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -11,6 +12,7 @@ import (
 	"path/filepath"
 	"strings"
 
+	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/hooks"
@@ -18,16 +20,35 @@ import (
 )
 
 var (
+	// ErrInternalServerError indicates that an internal server error occurred.
+	ErrInternalServerError = errors.New("internal server error")
+
 	// Deprecated: this flag is ignored.
 	configPath string
 
 	hookCmd = &cobra.Command{
-		Use:                "hook",
-		Short:              "Run git server hooks",
-		Long:               "Handles Soft Serve git server hooks.",
-		Hidden:             true,
-		PersistentPreRunE:  initBackendContext,
-		PersistentPostRunE: closeDBContext,
+		Use:    "hook",
+		Short:  "Run git server hooks",
+		Long:   "Handles Soft Serve git server hooks.",
+		Hidden: true,
+		PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
+			logger := log.FromContext(cmd.Context())
+			if err := initBackendContext(cmd, args); err != nil {
+				logger.Error("failed to initialize backend context", "err", err)
+				return ErrInternalServerError
+			}
+
+			return nil
+		},
+		PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
+			logger := log.FromContext(cmd.Context())
+			if err := closeDBContext(cmd, args); err != nil {
+				logger.Error("failed to close backend", "err", err)
+				return ErrInternalServerError
+			}
+
+			return nil
+		},
 	}
 
 	// Git hooks read the config from the environment, based on

cmd/soft/root.go 🔗

@@ -2,7 +2,9 @@ package main
 
 import (
 	"context"
+	"errors"
 	"fmt"
+	"io/fs"
 	"os"
 	"runtime/debug"
 	"strings"
@@ -146,6 +148,11 @@ func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
 func initBackendContext(cmd *cobra.Command, _ []string) error {
 	ctx := cmd.Context()
 	cfg := config.FromContext(ctx)
+	if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) {
+		if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil {
+			return fmt.Errorf("create data directory: %w", err)
+		}
+	}
 	dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
 	if err != nil {
 		return fmt.Errorf("open database: %w", err)

go.mod 🔗

@@ -19,12 +19,14 @@ require (
 
 require (
 	github.com/caarlos0/env/v8 v8.0.0
-	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1
+	github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245
 	github.com/charmbracelet/keygen v0.4.3
-	github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35
+	github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2
 	github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155
+	github.com/go-jose/go-jose/v3 v3.0.0
 	github.com/gobwas/glob v0.2.3
 	github.com/gogs/git-module v1.8.2
+	github.com/golang-jwt/jwt/v5 v5.0.0
 	github.com/hashicorp/golang-lru/v2 v2.0.4
 	github.com/jmoiron/sqlx v1.3.5
 	github.com/lib/pq v1.10.9
@@ -47,6 +49,7 @@ require (
 require (
 	github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
 	github.com/atotto/clipboard v0.1.4 // indirect
+	github.com/avast/retry-go v3.0.0+incompatible // indirect
 	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 	github.com/aymerick/douceur v0.2.0 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
@@ -54,7 +57,10 @@ require (
 	github.com/cespare/xxhash/v2 v2.2.0 // indirect
 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
 	github.com/dlclark/regexp2 v1.4.0 // indirect
+	github.com/git-lfs/git-lfs/v3 v3.3.0 // indirect
+	github.com/git-lfs/gitobj/v2 v2.1.1 // indirect
 	github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
+	github.com/git-lfs/wildmatch/v2 v2.0.1 // indirect
 	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 	github.com/go-logfmt/logfmt v0.6.0 // indirect
 	github.com/golang/protobuf v1.5.3 // indirect
@@ -62,6 +68,7 @@ require (
 	github.com/gorilla/css v1.0.0 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
+	github.com/leonelquinteros/gotext v1.5.2 // indirect
 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 	github.com/mattn/go-isatty v0.0.18 // indirect
 	github.com/mattn/go-localereader v0.0.1 // indirect
@@ -74,21 +81,22 @@ require (
 	github.com/muesli/mango v0.1.0 // indirect
 	github.com/muesli/mango-pflag v0.1.0 // indirect
 	github.com/olekukonko/tablewriter v0.0.5 // indirect
+	github.com/pkg/errors v0.9.1 // indirect
 	github.com/prometheus/client_model v0.3.0 // indirect
 	github.com/prometheus/common v0.42.0 // indirect
 	github.com/prometheus/procfs v0.10.1 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
-	github.com/rivo/uniseg v0.2.0 // indirect
+	github.com/rivo/uniseg v0.4.3 // indirect
 	github.com/sahilm/fuzzy v0.1.0 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/yuin/goldmark v1.5.2 // indirect
 	github.com/yuin/goldmark-emoji v1.0.1 // indirect
-	golang.org/x/mod v0.9.0 // indirect
+	golang.org/x/mod v0.10.0 // indirect
 	golang.org/x/net v0.12.0 // indirect
 	golang.org/x/sys v0.10.0 // indirect
 	golang.org/x/term v0.10.0 // indirect
 	golang.org/x/text v0.11.0 // indirect
-	golang.org/x/tools v0.6.0 // indirect
+	golang.org/x/tools v0.9.1 // indirect
 	google.golang.org/protobuf v1.30.0 // indirect
 	gopkg.in/warnings.v0 v0.1.2 // indirect
 	lukechampine.com/uint128 v1.2.0 // indirect

go.sum 🔗

@@ -1,9 +1,16 @@
+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/avast/retry-go v2.4.2+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
+github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
+github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
@@ -21,8 +28,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5
 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
 github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
-github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1 h1:/QzZzTDdlDYGZeC2O2y/Qw+AiHqh3vCsO4yrKDWXtqs=
-github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245 h1:PeGKqKX84IAFhFSWjTyPGiLzzEPcv94C9qKsYBk2nbQ=
+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
 github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
 github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
 github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgyyO1Mg=
@@ -31,6 +38,8 @@ github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZ
 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
+github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 h1:0O3FNIElGsbl/nnUpeUVHqET7ZETJz6cUQocn/CKhoU=
+github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
 github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4=
@@ -43,14 +52,28 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
+github.com/dpotapov/go-spnego v0.0.0-20210315154721-298b63a54430/go.mod h1:AVSs/gZKt1bOd2AhkhbS7Qh56Hv7klde22yXVbwYJhc=
 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
+github.com/git-lfs/git-lfs/v3 v3.3.0 h1:cbRy9akD9/hDD7BaVifyNkWkURwC8RSPLzX9+siS+OE=
+github.com/git-lfs/git-lfs/v3 v3.3.0/go.mod h1:5y2vfVQpxUmceMlraOmmaQ83pYptQYCvPl32ybO2IVw=
+github.com/git-lfs/gitobj/v2 v2.1.1 h1:tf/VU6zL1kxa3he+nf6FO/syX+LGkm6WGDsMpfuXV7Q=
+github.com/git-lfs/gitobj/v2 v2.1.1/go.mod h1:q6aqxl6Uu3gWsip5GEKpw+7459F97er8COmU45ncAxw=
+github.com/git-lfs/go-netrc v0.0.0-20210914205454-f0c862dd687a/go.mod h1:70O4NAtvWn1jW8V8V+OKrJJYcxDLTmIozfi2fmSz5SI=
+github.com/git-lfs/pktline v0.0.0-20210330133718-06e9096e2825/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
+github.com/git-lfs/wildmatch/v2 v2.0.1 h1:Ds+aobrV5bK0wStILUOn9irllPyf9qrFETbKzwzoER8=
+github.com/git-lfs/wildmatch/v2 v2.0.1/go.mod h1:EVqonpk9mXbREP3N8UkwoWdrF249uHpCUo5CPXY81gw=
+github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
+github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
+github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
@@ -59,32 +82,56 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
 github.com/gogs/git-module v1.8.2 h1:fwvSMjc51d5bBG3Q2OyFF8HTZFDVbETQ+mSAvfXebQw=
 github.com/gogs/git-module v1.8.2/go.mod h1:GUSSUH+RM7fZOtjhS6Obh4B9aAvs3EeROpazfMNMF8g=
+github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
+github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
 github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0=
 github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
+github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
+github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4=
 github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
 github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
+github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
+github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
 github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
+github.com/leonelquinteros/gotext v1.5.0/go.mod h1:OCiUVHuhP9LGFBQ1oAmdtNCHJCiHiQA8lf4nAifHkr0=
+github.com/leonelquinteros/gotext v1.5.2 h1:T2y6ebHli+rMBCjcJlHTXyUrgXqsKBhl/ormgvt7lPo=
+github.com/leonelquinteros/gotext v1.5.2/go.mod h1:AT4NpQrOmyj1L/+hLja6aR0lk81yYYL4ePnj2kp7d6M=
 github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
 github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
@@ -94,6 +141,7 @@ github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69
 github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
 github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
 github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
+github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
 github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
@@ -111,6 +159,8 @@ github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75 h1:Pijfgr7ZuvX
 github.com/mcuadros/go-version v0.0.0-20190308113854-92cdf37c5b75/go.mod h1:76rfSfYPWj01Z85hUf/ituArm797mNKcvINh1OlsZKo=
 github.com/microcosm-cc/bluemonday v1.0.21 h1:dNH3e4PSyE4vNX+KlRGHT5KrSvjeUkoNPwEORjffHJg=
 github.com/microcosm-cc/bluemonday v1.0.21/go.mod h1:ytNkv4RrDrLJ2pqlsSI46O6IVXmZOBBD4SaJyDwwTkM=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70 h1:kMlmsLSbjkikxQJ1IPwaM+7LJ9ltFu/fi8CRzvSnQmA=
 github.com/muesli/ansi v0.0.0-20211031195517-c9f0611b6c70/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
@@ -128,8 +178,12 @@ github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB
 github.com/muesli/termenv v0.13.0/go.mod h1:sP1+uffeLaEYpyOTb8pLCUctGcGLnoFjSn4YJK5e2bc=
 github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
 github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
+github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
+github.com/olekukonko/ts v0.0.0-20171002115256-78ecb04241c0/go.mod h1:F/7q8/HZz+TXjlsoZQQKVYvXTZaFH4QRa3y+j1p7MS0=
+github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
+github.com/pkg/errors v0.0.0-20170505043639-c605e284fe17/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -147,8 +201,9 @@ github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qq
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
+github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
@@ -160,21 +215,30 @@ github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
 github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0=
 github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
+github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
 github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
+github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/ssgelm/cookiejarparser v1.0.1/go.mod h1:DUfC0mpjIzlDN7DzKjXpHj0qMI5m9VrZuz3wSlI+OEI=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
+github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
+github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
+github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
+github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
+github.com/xeipuuv/gojsonschema v0.0.0-20170210233622-6b67b3fab74d/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark v1.5.2 h1:ALmeCk/px5FSm1MAcFBAsVKZjDuMVj8Tm7FFIlMJnqU=
 github.com/yuin/goldmark v1.5.2/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
 github.com/yuin/goldmark-emoji v1.0.1 h1:ctuWEyzGBwiucEqxzwe0SOYDXPAucOrE9NQC18Wa1os=
@@ -183,22 +247,46 @@ go.uber.org/automaxprocs v1.5.3 h1:kWazyxZUrS3Gs4qUpbwo5kEIMGe/DAvi5Z4tl2NW4j8=
 go.uber.org/automaxprocs v1.5.3/go.mod h1:eRbA25aqJrxAbsLO0xy5jVwPt7FQnRgjW+efnwa1WM0=
 goji.io v2.0.2+incompatible h1:uIssv/elbKRLznFUy3Xj4+2Mz/qKhek/9aZQDUMae7c=
 goji.io v2.0.2+incompatible/go.mod h1:sbqFwrtqZACxLBTQcdgVjFh54yGVCvwq8+w49MVMMIk=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20201112155050-0c6587e931a9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.0.0-20220826181053-bd7e27e6170d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA=
 golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio=
-golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
-golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
+golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
 golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
 golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
 golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -211,14 +299,23 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX
 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
 golang.org/x/term v0.10.0 h1:3R7pNqamzBraeqj/Tj8qt1aQ2HpmlC+Cx/qL/7hn4/c=
 golang.org/x/term v0.10.0/go.mod h1:lpqdcUyK/oCiQxvxVrppt5ggO2KCZ5QblwqPnfZ6d5o=
+golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
 golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
 golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
-golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200221224223-e1da425f72fd/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.9.1 h1:8WMNJAz3zrtPmnYC7ISf5dEn3MT0gY7jBJfw27yrrLo=
+golang.org/x/tools v0.9.1/go.mod h1:owI94Op576fPu3cIGQeHs3joujW/2Oc6MtlxbF5dfNc=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng=
@@ -226,10 +323,12 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
 gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

server/access/context.go 🔗

@@ -0,0 +1,20 @@
+package access
+
+import "context"
+
+// ContextKey is the context key for the access level.
+var ContextKey = &struct{ string }{"access"}
+
+// FromContext returns the access level from the context.
+func FromContext(ctx context.Context) AccessLevel {
+	if ac, ok := ctx.Value(ContextKey).(AccessLevel); ok {
+		return ac
+	}
+
+	return -1
+}
+
+// WithContext returns a new context with the access level.
+func WithContext(ctx context.Context, ac AccessLevel) context.Context {
+	return context.WithValue(ctx, ContextKey, ac)
+}

server/backend/auth.go 🔗

@@ -0,0 +1,38 @@
+package backend
+
+import (
+	"crypto/rand"
+	"encoding/hex"
+
+	"github.com/charmbracelet/log"
+	"golang.org/x/crypto/bcrypt"
+)
+
+const saltySalt = "salty-soft-serve"
+
+// HashPassword hashes the password using bcrypt.
+func HashPassword(password string) (string, error) {
+	crypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost)
+	if err != nil {
+		return "", err
+	}
+
+	return string(crypt), nil
+}
+
+// VerifyPassword verifies the password against the hash.
+func VerifyPassword(password, hash string) bool {
+	err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt))
+	return err == nil
+}
+
+// GenerateAccessToken returns a random unique token.
+func GenerateAccessToken() string {
+	buf := make([]byte, 20)
+	if _, err := rand.Read(buf); err != nil {
+		log.Error("unable to generate access token")
+		return ""
+	}
+
+	return "ss_" + hex.EncodeToString(buf)
+}

server/backend/collab.go 🔗

@@ -52,6 +52,10 @@ func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, err
 //
 // It implements backend.Backend.
 func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) {
+	if username == "" {
+		return false, nil
+	}
+
 	repo = utils.SanitizeRepo(repo)
 	var m models.Collab
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {

server/backend/hooks.go 🔗

@@ -63,7 +63,7 @@ func populateLastModified(ctx context.Context, d *Backend, name string) error {
 	if r, ok := _rr.(*repo); ok {
 		rr = r
 	} else {
-		return proto.ErrRepoNotExist
+		return proto.ErrRepoNotFound
 	}
 
 	r, err := rr.Open()

server/backend/lfs.go 🔗

@@ -43,7 +43,8 @@ func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx
 					return db.WrapError(err)
 				}
 
-				return strg.Put(path.Join("objects", p.RelativePath()), content)
+				_, err := strg.Put(path.Join("objects", p.RelativePath()), content)
+				return err
 			})
 		})
 	}

server/backend/repo.go 🔗

@@ -216,7 +216,7 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
 	op := filepath.Join(d.reposPath(), oldRepo)
 	np := filepath.Join(d.reposPath(), newRepo)
 	if _, err := os.Stat(op); err != nil {
-		return proto.ErrRepoNotExist
+		return proto.ErrRepoNotFound
 	}
 
 	if _, err := os.Stat(np); err == nil {
@@ -290,14 +290,20 @@ func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository
 
 	rp := filepath.Join(d.reposPath(), name+".git")
 	if _, err := os.Stat(rp); err != nil {
-		return nil, os.ErrNotExist
+		if !errors.Is(err, fs.ErrNotExist) {
+			d.logger.Errorf("failed to stat repository path: %v", err)
+		}
+		return nil, proto.ErrRepoNotFound
 	}
 
 	if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
 		var err error
 		m, err = d.store.GetRepoByName(ctx, tx, name)
-		return err
+		return db.WrapError(err)
 	}); err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, proto.ErrRepoNotFound
+		}
 		return nil, db.WrapError(err)
 	}
 

server/backend/user.go 🔗

@@ -2,6 +2,7 @@ package backend
 
 import (
 	"context"
+	"errors"
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/server/access"
@@ -40,6 +41,7 @@ func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ss
 }
 
 // AccessLevelForUser returns the access level of a user for a repository.
+// TODO: user repository ownership
 func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel {
 	var username string
 	anon := d.AnonAccess(ctx)
@@ -53,7 +55,11 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
 	}
 
 	// If the repository exists, check if the user is a collaborator.
-	r, _ := d.Repository(ctx, repo)
+	r := proto.RepositoryFromContext(ctx)
+	if r == nil {
+		r, _ = d.Repository(ctx, repo)
+	}
+
 	if r != nil {
 		// If the user is a collaborator, they have read/write access.
 		isCollab, _ := d.IsCollaborator(ctx, repo, username)
@@ -107,7 +113,11 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
 		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
 		return err
 	}); err != nil {
-		return nil, db.WrapError(err)
+		err = db.WrapError(err)
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, proto.ErrUserNotFound
+		}
+		return nil, err
 	}
 
 	return &user{
@@ -126,13 +136,17 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
 		var err error
 		m, err = d.store.FindUserByPublicKey(ctx, tx, pk)
 		if err != nil {
-			return err
+			return db.WrapError(err)
 		}
 
 		pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
 		return err
 	}); err != nil {
-		return nil, db.WrapError(err)
+		err = db.WrapError(err)
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, proto.ErrUserNotFound
+		}
+		return nil, err
 	}
 
 	return &user{
@@ -276,6 +290,25 @@ func (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) err
 	)
 }
 
+// SetPassword sets the password of a user.
+func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword string) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
+	password, err := HashPassword(rawPassword)
+	if err != nil {
+		return err
+	}
+
+	return db.WrapError(
+		d.db.TransactionContext(ctx, func(tx *db.Tx) error {
+			return d.store.SetUserPasswordByUsername(ctx, tx, username, password)
+		}),
+	)
+}
+
 type user struct {
 	user       models.User
 	publicKeys []ssh.PublicKey

server/config/ssh.go 🔗

@@ -0,0 +1,8 @@
+package config
+
+import "github.com/charmbracelet/keygen"
+
+// KeyPair returns the server's SSH key pair.
+func (c SSHConfig) KeyPair() (*keygen.SSHKeyPair, error) {
+	return keygen.New(c.KeyPath, keygen.WithKeyType(keygen.Ed25519))
+}

server/db/errors.go 🔗

@@ -14,7 +14,7 @@ var (
 	ErrDuplicateKey = errors.New("duplicate key value violates table constraint")
 
 	// ErrRecordNotFound is returned when a record is not found.
-	ErrRecordNotFound = errors.New("record not found")
+	ErrRecordNotFound = sql.ErrNoRows
 )
 
 // WrapError is a convenient function that unite various database driver

server/db/migrate/0002_create_lfs_tables_postgres.up.sql 🔗

@@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS lfs_objects (
   oid TEXT NOT NULL,
   size INTEGER NOT NULL,
   repo_id INTEGER NOT NULL,
-  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-  updated_at DATETIME NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP NOT NULL,
   UNIQUE (oid, repo_id),
   CONSTRAINT repo_id_fk
   FOREIGN KEY(repo_id) REFERENCES repos(id)
@@ -18,11 +18,15 @@ CREATE TABLE IF NOT EXISTS lfs_locks (
   user_id INTEGER NOT NULL,
   path TEXT NOT NULL,
   refname TEXT,
-  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-  updated_at DATETIME NOT NULL,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP NOT NULL,
   UNIQUE (repo_id, path),
   CONSTRAINT repo_id_fk
   FOREIGN KEY(repo_id) REFERENCES repos(id)
   ON DELETE CASCADE
+  ON UPDATE CASCADE,
+  CONSTRAINT user_id_fk
+  FOREIGN KEY(user_id) REFERENCES users(id)
+  ON DELETE CASCADE
   ON UPDATE CASCADE
 );

server/db/migrate/0002_create_lfs_tables_sqlite.up.sql 🔗

@@ -24,5 +24,9 @@ CREATE TABLE IF NOT EXISTS lfs_locks (
   CONSTRAINT repo_id_fk
   FOREIGN KEY(repo_id) REFERENCES repos(id)
   ON DELETE CASCADE
+  ON UPDATE CASCADE,
+  CONSTRAINT user_id_fk
+  FOREIGN KEY(user_id) REFERENCES users(id)
+  ON DELETE CASCADE
   ON UPDATE CASCADE
 );

server/db/migrate/0003_password_tokens.go 🔗

@@ -0,0 +1,23 @@
+package migrate
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+)
+
+const (
+	passwordTokensName    = "password tokens"
+	passwordTokensVersion = 3
+)
+
+var passwordTokens = Migration{
+	Version: passwordTokensVersion,
+	Name:    passwordTokensName,
+	Migrate: func(ctx context.Context, tx *db.Tx) error {
+		return migrateUp(ctx, tx, passwordTokensVersion, passwordTokensName)
+	},
+	Rollback: func(ctx context.Context, tx *db.Tx) error {
+		return migrateDown(ctx, tx, passwordTokensVersion, passwordTokensName)
+	},
+}

server/db/migrate/0003_password_tokens_postgres.up.sql 🔗

@@ -0,0 +1,15 @@
+ALTER TABLE users ADD COLUMN password TEXT;
+
+CREATE TABLE IF NOT EXISTS access_tokens (
+  id SERIAL PRIMARY KEY,
+  name text NOT NULL,
+  token TEXT NOT NULL UNIQUE,
+  user_id INTEGER NOT NULL,
+  expires_at TIMESTAMP,
+  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at TIMESTAMP NOT NULL,
+  CONSTRAINT user_id_fk
+  FOREIGN KEY (user_id) REFERENCES users(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);

server/db/migrate/0003_password_tokens_sqlite.up.sql 🔗

@@ -0,0 +1,15 @@
+ALTER TABLE users ADD COLUMN password TEXT;
+
+CREATE TABLE IF NOT EXISTS access_tokens (
+  id INTEGER primary key autoincrement,
+  token text NOT NULL UNIQUE,
+  name text NOT NULL,
+  user_id INTEGER NOT NULL,
+  expires_at DATETIME,
+  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME NOT NULL,
+  CONSTRAINT user_id_fk
+  FOREIGN KEY (user_id) REFERENCES users(id)
+  ON DELETE CASCADE
+  ON UPDATE CASCADE
+);

server/db/migrate/migrate.go 🔗

@@ -106,7 +106,7 @@ func Rollback(ctx context.Context, dbx *db.DB) error {
 			}
 		}
 
-		if len(migrations) < int(migrs.Version) {
+		if migrs.Version == 0 || len(migrations) < int(migrs.Version) {
 			return fmt.Errorf("there are no migrations to rollback")
 		}
 

server/db/migrate/migrations.go 🔗

@@ -17,6 +17,7 @@ var sqls embed.FS
 var migrations = []Migration{
 	createTables,
 	createLFSTables,
+	passwordTokens,
 }
 
 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {

server/db/models/access_token.go 🔗

@@ -0,0 +1,17 @@
+package models
+
+import (
+	"database/sql"
+	"time"
+)
+
+// AccessToken represents an access token.
+type AccessToken struct {
+	ID        int64        `db:"id"`
+	Name      string       `db:"name"`
+	UserID    int64        `db:"user_id"`
+	Token     string       `db:"token"`
+	ExpiresAt sql.NullTime `db:"expires_at"`
+	CreatedAt time.Time    `db:"created_at"`
+	UpdatedAt time.Time    `db:"updated_at"`
+}

server/db/models/user.go 🔗

@@ -1,12 +1,16 @@
 package models
 
-import "time"
+import (
+	"database/sql"
+	"time"
+)
 
 // User represents a user.
 type User struct {
-	ID        int64     `db:"id"`
-	Username  string    `db:"username"`
-	Admin     bool      `db:"admin"`
-	CreatedAt time.Time `db:"created_at"`
-	UpdatedAt time.Time `db:"updated_at"`
+	ID        int64          `db:"id"`
+	Username  string         `db:"username"`
+	Admin     bool           `db:"admin"`
+	Password  sql.NullString `db:"password"`
+	CreatedAt time.Time      `db:"created_at"`
+	UpdatedAt time.Time      `db:"updated_at"`
 }

server/git/lfs.go 🔗

@@ -10,18 +10,18 @@ import (
 	"path"
 	"path/filepath"
 	"strconv"
+	"strings"
 	"time"
 
 	"github.com/charmbracelet/git-lfs-transfer/transfer"
 	"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"
 	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/lfs"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/storage"
 	"github.com/charmbracelet/soft-serve/server/store"
-	"github.com/charmbracelet/soft-serve/server/utils"
 	"github.com/rubyist/tracerx"
 )
 
@@ -56,20 +56,18 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 		return errors.New("missing args")
 	}
 
-	logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
-	handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
-	be := backend.FromContext(ctx)
-	repoName := cmd.Args[0]
-	repoName = utils.SanitizeRepo(repoName)
 	op := cmd.Args[1]
-
-	repo, err := be.Repository(ctx, repoName)
-	if err != nil {
-		logger.Errorf("error getting repo: %v", err)
-		return err
+	if op != lfs.OperationDownload && op != lfs.OperationUpload {
+		return errors.New("invalid operation")
 	}
 
-	ctx = context.WithValue(ctx, proto.ContextKeyRepository, repo)
+	logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
+	handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Error("no repository in context")
+		return proto.ErrRepoNotFound
+	}
 
 	// Advertise capabilities.
 	for _, cap := range []string{
@@ -102,15 +100,10 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
 }
 
 // Batch implements transfer.Backend.
-func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.BatchItem, error) {
-	repo, ok := t.ctx.Value(proto.ContextKeyRepository).(proto.Repository)
-	if !ok {
-		return nil, errors.New("no repository in context")
-	}
-
+func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]string) ([]transfer.BatchItem, error) {
 	items := make([]transfer.BatchItem, 0)
 	for _, p := range pointers {
-		obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, repo.ID(), p.Oid)
+		obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), p.Oid)
 		if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
 			return items, db.WrapError(err)
 		}
@@ -121,7 +114,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.B
 		}
 
 		if exist && obj.ID == 0 {
-			if err := t.store.CreateLFSObject(t.ctx, t.dbx, repo.ID(), p.Oid, p.Size); err != nil {
+			if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), p.Oid, p.Size); err != nil {
 				return items, db.WrapError(err)
 			}
 		}
@@ -137,7 +130,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.B
 }
 
 // Download implements transfer.Backend.
-func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) {
+func (t *lfsTransfer) Download(oid string, _ map[string]string) (fs.File, error) {
 	cfg := config.FromContext(t.ctx)
 	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
 	pointer := transfer.Pointer{Oid: oid}
@@ -146,11 +139,12 @@ func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) {
 
 type uploadObject struct {
 	oid    string
+	size   int64
 	object storage.Object
 }
 
 // StartUpload implements transfer.Backend.
-func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interface{}, error) {
+func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ map[string]string) (interface{}, error) {
 	if r == nil {
 		return nil, fmt.Errorf("no reader: %w", transfer.ErrMissingData)
 	}
@@ -164,7 +158,8 @@ func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interfa
 	tempName := fmt.Sprintf("%s%x", oid, randBytes)
 	tempName = path.Join(tempDir, tempName)
 
-	if err := t.storage.Put(tempName, r); err != nil {
+	written, err := t.storage.Put(tempName, r)
+	if err != nil {
 		t.logger.Errorf("error putting object: %v", err)
 		return nil, err
 	}
@@ -179,24 +174,43 @@ func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interfa
 
 	return uploadObject{
 		oid:    oid,
+		size:   written,
 		object: obj,
 	}, nil
 }
 
 // FinishUpload implements transfer.Backend.
-func (t *lfsTransfer) FinishUpload(state interface{}, _ ...string) error {
+func (t *lfsTransfer) FinishUpload(state interface{}, args map[string]string) error {
 	upl, ok := state.(uploadObject)
 	if !ok {
 		return errors.New("invalid state")
 	}
 
+	var size int64
+	for _, arg := range args {
+		if strings.HasPrefix(arg, "size=") {
+			size, _ = strconv.ParseInt(strings.TrimPrefix(arg, "size="), 10, 64)
+			break
+		}
+	}
+
 	pointer := transfer.Pointer{
 		Oid: upl.oid,
 	}
+	if size > 0 {
+		pointer.Size = size
+	} else {
+		pointer.Size = upl.size
+	}
+
+	if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), pointer.Oid, pointer.Size); err != nil {
+		return db.WrapError(err)
+	}
 
 	expectedPath := path.Join("objects", pointer.RelativePath())
 	if err := t.storage.Rename(upl.object.Name(), expectedPath); err != nil {
 		t.logger.Errorf("error renaming object: %v", err)
+		_ = t.store.DeleteLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), pointer.Oid)
 		return err
 	}
 
@@ -218,19 +232,17 @@ func (t *lfsTransfer) Verify(oid string, args map[string]string) (transfer.Statu
 		return transfer.NewFailureStatus(transfer.StatusBadRequest, "invalid size argument"), nil
 	}
 
-	pointer := transfer.Pointer{
-		Oid:  oid,
-		Size: expectedSize,
-	}
-	expectedPath := path.Join("objects", pointer.RelativePath())
-	stat, err := t.storage.Stat(expectedPath)
+	obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), oid)
 	if err != nil {
-		t.logger.Errorf("error stating object: %v", err)
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return transfer.NewFailureStatus(transfer.StatusNotFound, "object not found"), nil
+		}
+		t.logger.Errorf("error getting object: %v", err)
 		return nil, err
 	}
 
-	if stat.Size() != expectedSize {
-		t.logger.Errorf("size mismatch: %d != %d", stat.Size(), expectedSize)
+	if obj.Size != expectedSize {
+		t.logger.Errorf("size mismatch: %d != %d", obj.Size, expectedSize)
 		return transfer.NewFailureStatus(transfer.StatusConflict, "size mismatch"), nil
 	}
 
@@ -239,20 +251,21 @@ func (t *lfsTransfer) Verify(oid string, args map[string]string) (transfer.Statu
 
 type lfsLockBackend struct {
 	*lfsTransfer
+	args map[string]string
 	user proto.User
 }
 
 var _ transfer.LockBackend = (*lfsLockBackend)(nil)
 
 // LockBackend implements transfer.Backend.
-func (t *lfsTransfer) LockBackend() transfer.LockBackend {
-	user, ok := t.ctx.Value(proto.ContextKeyUser).(proto.User)
-	if !ok {
+func (t *lfsTransfer) LockBackend(args map[string]string) transfer.LockBackend {
+	user := proto.UserFromContext(t.ctx)
+	if user == nil {
 		t.logger.Errorf("no user in context while creating lock backend, repo %s", t.repo.Name())
 		return nil
 	}
 
-	return &lfsLockBackend{t, user}
+	return &lfsLockBackend{t, args, user}
 }
 
 // Create implements transfer.LockBackend.
@@ -288,14 +301,14 @@ func (l *lfsLockBackend) Create(path string, refname string) (transfer.Lock, err
 // FromID implements transfer.LockBackend.
 func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) {
 	var lock LFSLock
-	user, ok := l.ctx.Value(proto.ContextKeyUser).(proto.User)
-	if !ok || user == nil {
-		return nil, errors.New("no user in context")
+	iid, err := strconv.ParseInt(id, 10, 64)
+	if err != nil {
+		return nil, err
 	}
 
 	if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {
 		var err error
-		lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, user.ID(), id)
+		lock.lock, err = l.store.GetLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), iid)
 		if err != nil {
 			return db.WrapError(err)
 		}
@@ -303,6 +316,9 @@ func (l *lfsLockBackend) FromID(id string) (transfer.Lock, error) {
 		lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID)
 		return db.WrapError(err)
 	}); err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, transfer.ErrNotFound
+		}
 		l.logger.Errorf("error getting lock: %v", err)
 		return nil, err
 	}
@@ -326,6 +342,9 @@ func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) {
 		lock.owner, err = l.store.GetUserByID(l.ctx, tx, lock.lock.UserID)
 		return db.WrapError(err)
 	}); err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return nil, transfer.ErrNotFound
+		}
 		l.logger.Errorf("error getting lock: %v", err)
 		return nil, err
 	}
@@ -336,15 +355,32 @@ func (l *lfsLockBackend) FromPath(path string) (transfer.Lock, error) {
 }
 
 // Range implements transfer.LockBackend.
-func (l *lfsLockBackend) Range(fn func(transfer.Lock) error) error {
+func (l *lfsLockBackend) Range(cursor string, limit int, fn func(transfer.Lock) error) (string, error) {
+	var nextCursor string
 	var locks []*LFSLock
 
+	page, _ := strconv.Atoi(cursor)
+	if page <= 0 {
+		page = 1
+	}
+
+	if limit <= 0 {
+		limit = lfs.DefaultLocksLimit
+	} else if limit > 100 {
+		limit = 100
+	}
+
 	if err := l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {
-		mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID())
+		l.logger.Debug("getting locks", "limit", limit, "page", page)
+		mlocks, err := l.store.GetLFSLocks(l.ctx, tx, l.repo.ID(), page, limit)
 		if err != nil {
 			return db.WrapError(err)
 		}
 
+		if len(mlocks) == limit {
+			nextCursor = strconv.Itoa(page + 1)
+		}
+
 		users := make(map[int64]models.User, 0)
 		for _, mlock := range mlocks {
 			owner, ok := users[mlock.UserID]
@@ -362,25 +398,39 @@ func (l *lfsLockBackend) Range(fn func(transfer.Lock) error) error {
 
 		return nil
 	}); err != nil {
-		return err
+		return "", err
 	}
 
 	for _, lock := range locks {
 		if err := fn(lock); err != nil {
-			return err
+			return "", err
 		}
 	}
 
-	return nil
+	return nextCursor, nil
 }
 
 // Unlock implements transfer.LockBackend.
 func (l *lfsLockBackend) Unlock(lock transfer.Lock) error {
-	return l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {
+	id, err := strconv.ParseInt(lock.ID(), 10, 64)
+	if err != nil {
+		return err
+	}
+
+	err = l.dbx.TransactionContext(l.ctx, func(tx *db.Tx) error {
 		return db.WrapError(
-			l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.user.ID(), lock.ID()),
+			l.store.DeleteLFSLockForUserByID(l.ctx, tx, l.repo.ID(), l.user.ID(), id),
 		)
 	})
+	if err != nil {
+		if errors.Is(err, db.ErrRecordNotFound) {
+			return transfer.ErrNotFound
+		}
+		l.logger.Error("error unlocking lock", "err", err)
+		return err
+	}
+
+	return nil
 }
 
 // LFSLock is a Git LFS lock object.

server/git/lfs_auth.go 🔗

@@ -0,0 +1,85 @@
+package git
+
+import (
+	"context"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"time"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/jwk"
+	"github.com/charmbracelet/soft-serve/server/lfs"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// LFSAuthenticate implements teh Git LFS SSH authentication command.
+// Context must have *config.Config, *log.Logger, proto.User.
+// cmd.Args should have the repo path and operation as arguments.
+func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error {
+	if len(cmd.Args) < 2 {
+		return errors.New("missing args")
+	}
+
+	logger := log.FromContext(ctx).WithPrefix("ssh.lfs-authenticate")
+	operation := cmd.Args[1]
+	if operation != lfs.OperationDownload && operation != lfs.OperationUpload {
+		logger.Errorf("invalid operation: %s", operation)
+		return errors.New("invalid operation")
+	}
+
+	user := proto.UserFromContext(ctx)
+	if user == nil {
+		logger.Errorf("missing user")
+		return proto.ErrUserNotFound
+	}
+
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Errorf("missing repository")
+		return proto.ErrRepoNotFound
+	}
+
+	cfg := config.FromContext(ctx)
+	kp, err := jwk.NewPair(cfg)
+	if err != nil {
+		logger.Error("failed to get JWK pair", "err", err)
+		return err
+	}
+
+	now := time.Now()
+	expiresIn := time.Minute * 5
+	expiresAt := now.Add(expiresIn)
+	claims := jwt.RegisteredClaims{
+		Subject:   fmt.Sprintf("%s#%d", user.Username(), user.ID()),
+		ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour
+		NotBefore: jwt.NewNumericDate(now),
+		IssuedAt:  jwt.NewNumericDate(now),
+		Issuer:    cfg.HTTP.PublicURL,
+		Audience: []string{
+			repo.Name(),
+		},
+	}
+
+	token := jwt.NewWithClaims(jwk.SigningMethod, claims)
+	token.Header["kid"] = kp.JWK().KeyID
+	j, err := token.SignedString(kp.PrivateKey())
+	if err != nil {
+		logger.Error("failed to sign token", "err", err)
+		return err
+	}
+
+	href := fmt.Sprintf("%s/%s.git/info/lfs", cfg.HTTP.PublicURL, repo.Name())
+	logger.Debug("generated token", "token", j, "href", href, "expires_at", expiresAt)
+
+	return json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{
+		Header: map[string]string{
+			"Authorization": fmt.Sprintf("Bearer %s", j),
+		},
+		Href:      href,
+		ExpiresAt: expiresAt,
+		ExpiresIn: expiresIn,
+	})
+}

server/git/service.go 🔗

@@ -25,7 +25,8 @@ const (
 	ReceivePackService Service = "git-receive-pack"
 	// LFSTransferService is the LFS transfer service.
 	LFSTransferService Service = "git-lfs-transfer"
-	// TODO: add support for git-lfs-authenticate
+	// LFSAuthenticateService is the LFS authenticate service.
+	LFSAuthenticateService = "git-lfs-authenticate"
 )
 
 // String returns the string representation of the service.
@@ -45,6 +46,8 @@ func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
 		return gitServiceHandler(ctx, s, cmd)
 	case LFSTransferService:
 		return LFSTransfer(ctx, cmd)
+	case LFSAuthenticateService:
+		return LFSAuthenticate(ctx, cmd)
 	default:
 		return fmt.Errorf("unsupported service: %s", s)
 	}

server/jwk/jwk.go 🔗

@@ -0,0 +1,49 @@
+package jwk
+
+import (
+	"crypto"
+	"crypto/sha256"
+	"fmt"
+
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/go-jose/go-jose/v3"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to
+// sign and verify tokens.
+var SigningMethod = &jwt.SigningMethodEd25519{}
+
+// Pair is a JSON Web Key pair.
+type Pair struct {
+	privateKey crypto.PrivateKey
+	jwk        jose.JSONWebKey
+}
+
+// PrivateKey returns the private key.
+func (p Pair) PrivateKey() crypto.PrivateKey {
+	return p.privateKey
+}
+
+// JWK returns the JSON Web Key.
+func (p Pair) JWK() jose.JSONWebKey {
+	return p.jwk
+}
+
+// NewPair creates a new JSON Web Key pair.
+func NewPair(cfg *config.Config) (Pair, error) {
+	kp, err := cfg.SSH.KeyPair()
+	if err != nil {
+		return Pair{}, err
+	}
+
+	sum := sha256.Sum256(kp.RawPrivateKey())
+	kid := fmt.Sprintf("%x", sum)
+	jwk := jose.JSONWebKey{
+		Key:       kp.CryptoPublicKey(),
+		KeyID:     kid,
+		Algorithm: SigningMethod.Alg(),
+	}
+
+	return Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil
+}

server/lfs/common.go 🔗

@@ -1,6 +1,8 @@
 package lfs
 
-import "time"
+import (
+	"time"
+)
 
 const (
 	// MediaType contains the media type for LFS server requests.
@@ -20,6 +22,10 @@ const (
 
 	// ActionVerify is the action name for a verify request.
 	ActionVerify = "verify"
+
+	// DefaultLocksLimit is the default number of locks to return in a single
+	// request.
+	DefaultLocksLimit = 20
 )
 
 // Pointer contains LFS pointer data
@@ -86,3 +92,72 @@ type BatchRequest struct {
 type Reference struct {
 	Name string `json:"name"`
 }
+
+// AuthenticateResponse is the git-lfs-authenticate JSON response object.
+type AuthenticateResponse struct {
+	Header    map[string]string `json:"header"`
+	Href      string            `json:"href"`
+	ExpiresIn time.Duration     `json:"expires_in"`
+	ExpiresAt time.Time         `json:"expires_at"`
+}
+
+// LockCreateRequest contains the request data for creating a lock.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json
+type LockCreateRequest struct {
+	Path string    `json:"path"`
+	Ref  Reference `json:"ref,omitempty"`
+}
+
+// Owner contains the owner data for a lock.
+type Owner struct {
+	Name string `json:"name"`
+}
+
+// Lock contains the response data for creating a lock.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json
+type Lock struct {
+	ID       string    `json:"id"`
+	Path     string    `json:"path"`
+	LockedAt time.Time `json:"locked_at"`
+	Owner    Owner     `json:"owner,omitempty"`
+}
+
+// LockDeleteRequest contains the request data for deleting a lock.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-delete-request-schema.json
+type LockDeleteRequest struct {
+	Force bool      `json:"force,omitempty"`
+	Ref   Reference `json:"ref,omitempty"`
+}
+
+// LockListResponse contains the response data for listing locks.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-list-response-schema.json
+type LockListResponse struct {
+	Locks      []Lock `json:"locks"`
+	NextCursor string `json:"next_cursor,omitempty"`
+}
+
+// LockVerifyRequest contains the request data for verifying a lock.
+type LockVerifyRequest struct {
+	Ref    Reference `json:"ref,omitempty"`
+	Cursor string    `json:"cursor,omitempty"`
+	Limit  int       `json:"limit,omitempty"`
+}
+
+// LockVerifyResponse contains the response data for verifying a lock.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-verify-response-schema.json
+type LockVerifyResponse struct {
+	Ours       []Lock `json:"ours"`
+	Theirs     []Lock `json:"theirs"`
+	NextCursor string `json:"next_cursor,omitempty"`
+}
+
+// LockResponse contains the response data for a lock.
+type LockResponse struct {
+	Lock Lock `json:"lock"`
+	ErrorResponse
+}

server/proto/context.go 🔗

@@ -0,0 +1,35 @@
+package proto
+
+import "context"
+
+// ContextKeyRepository is the context key for the repository.
+var ContextKeyRepository = &struct{ string }{"repository"}
+
+// ContextKeyUser is the context key for the user.
+var ContextKeyUser = &struct{ string }{"user"}
+
+// RepositoryFromContext returns the repository from the context.
+func RepositoryFromContext(ctx context.Context) Repository {
+	if r, ok := ctx.Value(ContextKeyRepository).(Repository); ok {
+		return r
+	}
+	return nil
+}
+
+// UserFromContext returns the user from the context.
+func UserFromContext(ctx context.Context) User {
+	if u, ok := ctx.Value(ContextKeyUser).(User); ok {
+		return u
+	}
+	return nil
+}
+
+// WithRepositoryContext returns a new context with the repository.
+func WithRepositoryContext(ctx context.Context, r Repository) context.Context {
+	return context.WithValue(ctx, ContextKeyRepository, r)
+}
+
+// WithUserContext returns a new context with the user.
+func WithUserContext(ctx context.Context, u User) context.Context {
+	return context.WithValue(ctx, ContextKeyUser, u)
+}

server/proto/errors.go 🔗

@@ -6,11 +6,13 @@ import (
 
 var (
 	// ErrUnauthorized is returned when the user is not authorized to perform action.
-	ErrUnauthorized = errors.New("Unauthorized")
+	ErrUnauthorized = errors.New("unauthorized")
 	// ErrFileNotFound is returned when the file is not found.
-	ErrFileNotFound = errors.New("File not found")
-	// ErrRepoNotExist is returned when a repository does not exist.
-	ErrRepoNotExist = errors.New("repository does not exist")
+	ErrFileNotFound = errors.New("file not found")
+	// ErrRepoNotFound is returned when a repository does not exist.
+	ErrRepoNotFound = errors.New("repository not found")
 	// ErrRepoExist is returned when a repository already exists.
 	ErrRepoExist = errors.New("repository already exists")
+	// ErrUserNotFound is returned when a user does not exist.
+	ErrUserNotFound = errors.New("user does not exist")
 )

server/proto/repo.go 🔗

@@ -6,9 +6,6 @@ import (
 	"github.com/charmbracelet/soft-serve/git"
 )
 
-// ContextKeyRepository is the context key for the repository.
-var ContextKeyRepository = &struct{ string }{"repository"}
-
 // Repository is a Git repository interface.
 type Repository interface {
 	// ID returns the repository's ID.

server/proto/user.go 🔗

@@ -2,9 +2,6 @@ package proto
 
 import "golang.org/x/crypto/ssh"
 
-// ContextKeyUser is the context key for the user.
-var ContextKeyUser = &struct{ string }{"user"}
-
 // User is an interface representing a user.
 type User interface {
 	// ID returns the user's ID.

server/ssh/cmd/cmd.go 🔗

@@ -89,7 +89,6 @@ func cmdName(args []string) string {
 func RootCommand(s ssh.Session) *cobra.Command {
 	ctx := s.Context()
 	cfg := config.FromContext(ctx)
-	be := backend.FromContext(ctx)
 
 	args := s.Command()
 	cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
@@ -140,7 +139,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
 	rootCmd.CompletionOptions.DisableDefaultCmd = true
 	rootCmd.SetErr(s.Stderr())
 
-	user, _ := be.UserByPublicKey(s.Context(), s.PublicKey())
+	user := proto.UserFromContext(ctx)
 	isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
 	if user != nil || isAdmin {
 		if isAdmin {
@@ -154,6 +153,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
 			infoCommand(),
 			pubkeyCommand(),
 			setUsernameCommand(),
+			jwtCommand(),
 		)
 	}
 
@@ -169,8 +169,8 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
 	ctx := cmd.Context()
 	be := backend.FromContext(ctx)
 	rn := utils.SanitizeRepo(repo)
-	pk := sshutils.PublicKeyFromContext(ctx)
-	auth := be.AccessLevelByPublicKey(cmd.Context(), rn, pk)
+	user := proto.UserFromContext(ctx)
+	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
 	if auth < access.ReadOnlyAccess {
 		return proto.ErrUnauthorized
 	}
@@ -188,14 +188,13 @@ func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
 
 func checkIfAdmin(cmd *cobra.Command, _ []string) error {
 	ctx := cmd.Context()
-	be := backend.FromContext(ctx)
 	cfg := config.FromContext(ctx)
 	pk := sshutils.PublicKeyFromContext(ctx)
 	if isPublicKeyAdmin(cfg, pk) {
 		return nil
 	}
 
-	user, _ := be.UserByPublicKey(ctx, pk)
+	user := proto.UserFromContext(ctx)
 	if user == nil {
 		return proto.ErrUnauthorized
 	}
@@ -215,9 +214,9 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
 
 	ctx := cmd.Context()
 	be := backend.FromContext(ctx)
-	pk := sshutils.PublicKeyFromContext(ctx)
 	rn := utils.SanitizeRepo(repo)
-	auth := be.AccessLevelByPublicKey(ctx, rn, pk)
+	user := proto.UserFromContext(ctx)
+	auth := be.AccessLevelForUser(cmd.Context(), rn, user)
 	if auth < access.ReadWriteAccess {
 		return proto.ErrUnauthorized
 	}

server/ssh/cmd/jwt.go 🔗

@@ -0,0 +1,56 @@
+package cmd
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/jwk"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/golang-jwt/jwt/v5"
+	"github.com/spf13/cobra"
+)
+
+func jwtCommand() *cobra.Command {
+	cmd := &cobra.Command{
+		Use:   "jwt [repository1 repository2...]",
+		Short: "Generate a JSON Web Token",
+		Args:  cobra.MinimumNArgs(0),
+		RunE: func(cmd *cobra.Command, args []string) error {
+			ctx := cmd.Context()
+			cfg := config.FromContext(ctx)
+			kp, err := jwk.NewPair(cfg)
+			if err != nil {
+				return err
+			}
+
+			user := proto.UserFromContext(ctx)
+			if user == nil {
+				return proto.ErrUserNotFound
+			}
+
+			now := time.Now()
+			expiresAt := now.Add(time.Hour)
+			claims := jwt.RegisteredClaims{
+				Subject:   fmt.Sprintf("%s#%d", user.Username(), user.ID()),
+				ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour
+				NotBefore: jwt.NewNumericDate(now),
+				IssuedAt:  jwt.NewNumericDate(now),
+				Issuer:    cfg.HTTP.PublicURL,
+				Audience:  args,
+			}
+
+			token := jwt.NewWithClaims(jwk.SigningMethod, claims)
+			token.Header["kid"] = kp.JWK().KeyID
+			j, err := token.SignedString(kp.PrivateKey())
+			if err != nil {
+				return err
+			}
+
+			cmd.Println(j)
+			return nil
+		},
+	}
+
+	return cmd
+}

server/ssh/git.go 🔗

@@ -25,47 +25,51 @@ func handleGit(s ssh.Session) {
 	cmdLine := s.Command()
 	start := time.Now()
 
-	var username string
-	user := ctx.Value(proto.ContextKeyUser).(proto.User)
-	if user != nil {
-		username = user.Username()
-	}
-
 	// repo should be in the form of "repo.git"
 	name := utils.SanitizeRepo(cmdLine[1])
 	pk := s.PublicKey()
 	ak := sshutils.MarshalAuthorizedKey(pk)
+	user := proto.UserFromContext(ctx)
 	accessLevel := be.AccessLevelForUser(ctx, name, user)
 	// git bare repositories should end in ".git"
 	// https://git-scm.com/docs/gitrepository-layout
-	repo := name + ".git"
+	repoDir := name + ".git"
 	reposDir := filepath.Join(cfg.DataPath, "repos")
-	if err := git.EnsureWithin(reposDir, repo); err != nil {
+	if err := git.EnsureWithin(reposDir, repoDir); err != nil {
 		sshFatal(s, err)
 		return
 	}
 
+	// Set repo in context
+	repo, _ := be.Repository(ctx, name)
+	ctx.SetValue(proto.ContextKeyRepository, repo)
+
 	// Environment variables to pass down to git hooks.
 	envs := []string{
 		"SOFT_SERVE_REPO_NAME=" + name,
-		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
+		"SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
 		"SOFT_SERVE_PUBLIC_KEY=" + ak,
-		"SOFT_SERVE_USERNAME=" + username,
 		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
 	}
 
+	if user != nil {
+		envs = append(envs,
+			"SOFT_SERVE_USERNAME="+user.Username(),
+		)
+	}
+
 	// Add ssh session & config environ
 	envs = append(envs, s.Environ()...)
 	envs = append(envs, cfg.Environ()...)
 
-	repoDir := filepath.Join(reposDir, repo)
+	repoPath := filepath.Join(reposDir, repoDir)
 	service := git.Service(cmdLine[0])
 	cmd := git.ServiceCommand{
 		Stdin:  s,
 		Stdout: s,
 		Stderr: s.Stderr(),
 		Env:    envs,
-		Dir:    repoDir,
+		Dir:    repoPath,
 	}
 
 	logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
@@ -80,7 +84,7 @@ func handleGit(s ssh.Session) {
 			sshFatal(s, git.ErrNotAuthed)
 			return
 		}
-		if _, err := be.Repository(ctx, name); err != nil {
+		if repo == nil {
 			if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{Private: false}); err != nil {
 				log.Errorf("failed to create repo: %s", err)
 				sshFatal(s, err)
@@ -105,10 +109,8 @@ func handleGit(s ssh.Session) {
 			return
 		}
 
-		handler := git.UploadPack
 		switch service {
 		case git.UploadArchiveService:
-			handler = git.UploadArchive
 			uploadArchiveCounter.WithLabelValues(name).Inc()
 			defer func() {
 				uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
@@ -120,14 +122,16 @@ func handleGit(s ssh.Session) {
 			}()
 		}
 
-		err := handler(ctx, cmd)
+		err := service.Handler(ctx, cmd)
 		if errors.Is(err, git.ErrInvalidRepo) {
 			sshFatal(s, git.ErrInvalidRepo)
 		} else if err != nil {
 			logger.Error("git middleware", "err", err)
 			sshFatal(s, git.ErrSystemMalfunction)
 		}
-	case git.LFSTransferService:
+
+		return
+	case git.LFSTransferService, git.LFSAuthenticateService:
 		if accessLevel < access.ReadWriteAccess {
 			sshFatal(s, git.ErrNotAuthed)
 			return
@@ -144,7 +148,7 @@ func handleGit(s ssh.Session) {
 			cmdLine[2],
 		}
 
-		if err := git.LFSTransfer(ctx, cmd); err != nil {
+		if err := service.Handler(ctx, cmd); err != nil {
 			logger.Error("git middleware", "err", err)
 			sshFatal(s, git.ErrSystemMalfunction)
 			return

server/ssh/ssh.go 🔗

@@ -10,13 +10,11 @@ import (
 
 	"github.com/charmbracelet/keygen"
 	"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"
 	"github.com/charmbracelet/soft-serve/server/git"
 	"github.com/charmbracelet/soft-serve/server/proto"
-	"github.com/charmbracelet/soft-serve/server/sshutils"
 	"github.com/charmbracelet/soft-serve/server/store"
 	"github.com/charmbracelet/ssh"
 	"github.com/charmbracelet/wish"
@@ -191,17 +189,16 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed
 		return false
 	}
 
-	ak := sshutils.MarshalAuthorizedKey(pk)
 	defer func(allowed *bool) {
 		publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc()
 	}(&allowed)
 
 	user, _ := s.be.UserByPublicKey(ctx, pk)
-	ctx.SetValue(proto.ContextKeyUser, user)
+	if user != nil {
+		ctx.SetValue(proto.ContextKeyUser, user)
+		allowed = true
+	}
 
-	ac := s.be.AccessLevelForUser(ctx, "", user)
-	s.logger.Debugf("access level for %q: %s", ak, ac)
-	allowed = ac >= access.ReadWriteAccess
 	return
 }
 

server/storage/local.go 🔗

@@ -41,19 +41,18 @@ func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {
 }
 
 // Put implements Storage.
-func (l *LocalStorage) Put(name string, r io.Reader) error {
+func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) {
 	name = l.fixPath(name)
 	if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
-		return err
+		return 0, err
 	}
 
 	f, err := os.Create(name)
 	if err != nil {
-		return err
+		return 0, err
 	}
 	defer f.Close() // nolint: errcheck
-	_, err = io.Copy(f, r)
-	return err
+	return io.Copy(f, r)
 }
 
 // Exists implements Storage.

server/storage/storage.go 🔗

@@ -16,7 +16,7 @@ type Object interface {
 type Storage interface {
 	Open(name string) (Object, error)
 	Stat(name string) (fs.FileInfo, error)
-	Put(name string, r io.Reader) error
+	Put(name string, r io.Reader) (int64, error)
 	Delete(name string) error
 	Exists(name string) (bool, error)
 	Rename(oldName, newName string) error

server/store/collab.go 🔗

@@ -0,0 +1,17 @@
+package store
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+)
+
+// CollaboratorStore is an interface for managing collaborators.
+type CollaboratorStore interface {
+	GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
+	AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
+	RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
+	ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
+	ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
+}

server/store/database/lfs.go 🔗

@@ -2,7 +2,6 @@ package database
 
 import (
 	"context"
-	"strconv"
 	"strings"
 
 	"github.com/charmbracelet/soft-serve/server/db"
@@ -37,17 +36,43 @@ func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID
 }
 
 // GetLFSLocks implements store.LFSStore.
-func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSLock, error) {
+func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) {
+	if page <= 0 {
+		page = 1
+	}
+
 	var locks []models.LFSLock
 	query := tx.Rebind(`
 		SELECT *
 		FROM lfs_locks
-		WHERE repo_id = ?;
+		WHERE repo_id = ?
+		ORDER BY updated_at DESC
+		LIMIT ? OFFSET ?;
 	`)
-	err := tx.SelectContext(ctx, &locks, query, repoID)
+	err := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit)
 	return locks, db.WrapError(err)
 }
 
+func (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) {
+	locks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit)
+	if err != nil {
+		return nil, 0, err
+	}
+
+	var count int64
+	query := tx.Rebind(`
+		SELECT COUNT(*)
+		FROM lfs_locks
+		WHERE repo_id = ?;
+	`)
+	err = tx.GetContext(ctx, &count, query, repoID)
+	if err != nil {
+		return nil, 0, db.WrapError(err)
+	}
+
+	return locks, count, nil
+}
+
 // GetLFSLocksForUser implements store.LFSStore.
 func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) {
 	var locks []models.LFSLock
@@ -61,16 +86,16 @@ func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID i
 }
 
 // GetLFSLocksForPath implements store.LFSStore.
-func (*lfsStore) GetLFSLocksForPath(ctx context.Context, tx db.Handler, repoID int64, path string) ([]models.LFSLock, error) {
+func (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) {
 	path = sanitizePath(path)
-	var locks []models.LFSLock
+	var lock models.LFSLock
 	query := tx.Rebind(`
 		SELECT *
 		FROM lfs_locks
 		WHERE repo_id = ? AND path = ?;
 	`)
-	err := tx.SelectContext(ctx, &locks, query, repoID, path)
-	return locks, db.WrapError(err)
+	err := tx.GetContext(ctx, &lock, query, repoID, path)
+	return lock, db.WrapError(err)
 }
 
 // GetLFSLockForUserPath implements store.LFSStore.
@@ -87,51 +112,46 @@ func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoI
 }
 
 // GetLFSLockByID implements store.LFSStore.
-func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id string) (models.LFSLock, error) {
-	iid, err := strconv.Atoi(id)
-	if err != nil {
-		return models.LFSLock{}, err
-	}
-
+func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) {
 	var lock models.LFSLock
 	query := tx.Rebind(`
 		SELECT *
 		FROM lfs_locks
 		WHERE lfs_locks.id = ?;
 	`)
-	err = tx.GetContext(ctx, &lock, query, iid)
+	err := tx.GetContext(ctx, &lock, query, id)
 	return lock, db.WrapError(err)
 }
 
 // GetLFSLockForUserByID implements store.LFSStore.
-func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, userID int64, id string) (models.LFSLock, error) {
-	iid, err := strconv.Atoi(id)
-	if err != nil {
-		return models.LFSLock{}, err
-	}
-
+func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error) {
 	var lock models.LFSLock
 	query := tx.Rebind(`
 		SELECT *
 		FROM lfs_locks
-		WHERE id = ? AND user_id = ?;
+		WHERE id = ? AND user_id = ? AND repo_id = ?;
 	`)
-	err = tx.GetContext(ctx, &lock, query, iid, userID)
+	err := tx.GetContext(ctx, &lock, query, id, userID, repoID)
 	return lock, db.WrapError(err)
 }
 
 // DeleteLFSLockForUserByID implements store.LFSStore.
-func (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, userID int64, id string) error {
-	iid, err := strconv.Atoi(id)
-	if err != nil {
-		return err
-	}
+func (*lfsStore) DeleteLFSLockForUserByID(ctx context.Context, tx db.Handler, repoID int64, userID int64, id int64) error {
+	query := tx.Rebind(`
+		DELETE FROM lfs_locks
+		WHERE repo_id = ? AND user_id = ? AND id = ?;
+	`)
+	_, err := tx.ExecContext(ctx, query, repoID, userID, id)
+	return db.WrapError(err)
+}
 
+// DeleteLFSLock implements store.LFSStore.
+func (*lfsStore) DeleteLFSLock(ctx context.Context, tx db.Handler, repoID int64, id int64) error {
 	query := tx.Rebind(`
 		DELETE FROM lfs_locks
-		WHERE user_id = ? AND id = ?;
+		WHERE repo_id = ? AND id = ?;
 	`)
-	_, err = tx.ExecContext(ctx, query, userID, iid)
+	_, err := tx.ExecContext(ctx, query, repoID, id)
 	return db.WrapError(err)
 }
 

server/store/database/user.go 🔗

@@ -214,3 +214,22 @@ func (*userStore) SetUsernameByUsername(ctx context.Context, tx db.Handler, user
 	_, err := tx.ExecContext(ctx, query, newUsername, username)
 	return err
 }
+
+// SetUserPassword implements store.UserStore.
+func (*userStore) SetUserPassword(ctx context.Context, tx db.Handler, userID int64, password string) error {
+	query := tx.Rebind(`UPDATE users SET password = ? WHERE id = ?;`)
+	_, err := tx.ExecContext(ctx, query, password, userID)
+	return err
+}
+
+// SetUserPasswordByUsername implements store.UserStore.
+func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler, username string, password string) error {
+	username = strings.ToLower(username)
+	if err := utils.ValidateUsername(username); err != nil {
+		return err
+	}
+
+	query := tx.Rebind(`UPDATE users SET password = ? WHERE username = ?;`)
+	_, err := tx.ExecContext(ctx, query, password, username)
+	return err
+}

server/store/lfs.go 🔗

@@ -16,11 +16,13 @@ type LFSStore interface {
 	DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error
 
 	CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error
-	GetLFSLocks(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSLock, error)
+	GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error)
+	GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error)
 	GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)
-	GetLFSLocksForPath(ctx context.Context, h db.Handler, repoID int64, path string) ([]models.LFSLock, error)
+	GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error)
 	GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)
-	GetLFSLockByID(ctx context.Context, h db.Handler, id string) (models.LFSLock, error)
-	GetLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) (models.LFSLock, error)
-	DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) error
+	GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error)
+	GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error)
+	DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error
+	DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error
 }

server/store/repo.go 🔗

@@ -0,0 +1,27 @@
+package store
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+)
+
+// RepositoryStore is an interface for managing repositories.
+type RepositoryStore interface {
+	GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
+	GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
+	CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
+	DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
+	SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
+
+	GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)
+	SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error
+	GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)
+	SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error
+	GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)
+	SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error
+	GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
+	SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
+	GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
+}

server/store/settings.go 🔗

@@ -0,0 +1,16 @@
+package store
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/access"
+	"github.com/charmbracelet/soft-serve/server/db"
+)
+
+// SettingStore is an interface for managing settings.
+type SettingStore interface {
+	GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)
+	SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error
+	GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)
+	SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error
+}

server/store/store.go 🔗

@@ -1,66 +1,5 @@
 package store
 
-import (
-	"context"
-
-	"github.com/charmbracelet/soft-serve/server/access"
-	"github.com/charmbracelet/soft-serve/server/db"
-	"github.com/charmbracelet/soft-serve/server/db/models"
-	"golang.org/x/crypto/ssh"
-)
-
-// SettingStore is an interface for managing settings.
-type SettingStore interface {
-	GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)
-	SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error
-	GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)
-	SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error
-}
-
-// RepositoryStore is an interface for managing repositories.
-type RepositoryStore interface {
-	GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
-	GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
-	CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
-	DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
-	SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
-
-	GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)
-	SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error
-	GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)
-	SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error
-	GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)
-	SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error
-	GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
-	SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
-	GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
-}
-
-// UserStore is an interface for managing users.
-type UserStore interface {
-	GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)
-	FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)
-	FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
-	GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
-	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
-	DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
-	SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error
-	SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error
-	AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
-	RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
-	ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error)
-	ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error)
-}
-
-// CollaboratorStore is an interface for managing collaborators.
-type CollaboratorStore interface {
-	GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
-	AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
-	RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
-	ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
-	ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
-}
-
 // Store is an interface for managing repositories, users, and settings.
 type Store interface {
 	RepositoryStore

server/store/user.go 🔗

@@ -0,0 +1,27 @@
+package store
+
+import (
+	"context"
+
+	"github.com/charmbracelet/soft-serve/server/db"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"golang.org/x/crypto/ssh"
+)
+
+// UserStore is an interface for managing users.
+type UserStore interface {
+	GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)
+	FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)
+	FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
+	GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
+	CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
+	DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
+	SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error
+	SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error
+	AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
+	RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
+	ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error)
+	ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error)
+	SetUserPassword(ctx context.Context, h db.Handler, userID int64, password string) error
+	SetUserPasswordByUsername(ctx context.Context, h db.Handler, username string, password string) error
+}

server/web/auth.go 🔗

@@ -0,0 +1,109 @@
+package web
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+
+	"github.com/charmbracelet/log"
+	"github.com/charmbracelet/soft-serve/server/backend"
+	"github.com/charmbracelet/soft-serve/server/config"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/golang-jwt/jwt/v5"
+)
+
+// authenticate authenticates the user from the request.
+func authenticate(r *http.Request) (proto.User, error) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx)
+
+	// Check for auth header
+	header := r.Header.Get("Authorization")
+	if header != "" {
+		logger.Debug("authorization", "header", header)
+
+		parts := strings.SplitN(header, " ", 2)
+		if len(parts) != 2 {
+			return nil, errors.New("invalid authorization header")
+		}
+
+		// TODO: add basic, and token types
+		be := backend.FromContext(ctx)
+		switch strings.ToLower(parts[0]) {
+		case "bearer":
+			claims, err := getJWTClaims(ctx, parts[1])
+			if err != nil {
+				return nil, err
+			}
+
+			// Find the user
+			parts := strings.SplitN(claims.Subject, "#", 2)
+			if len(parts) != 2 {
+				logger.Error("invalid jwt subject", "subject", claims.Subject)
+				return nil, errors.New("invalid jwt subject")
+			}
+
+			user, err := be.User(ctx, parts[0])
+			if err != nil {
+				logger.Error("failed to get user", "err", err)
+				return nil, err
+			}
+
+			expectedSubject := fmt.Sprintf("%s#%d", user.Username(), user.ID())
+			if expectedSubject != claims.Subject {
+				logger.Error("invalid jwt subject", "subject", claims.Subject, "expected", expectedSubject)
+				return nil, errors.New("invalid jwt subject")
+			}
+
+			return user, nil
+		default:
+			return nil, errors.New("invalid authorization header")
+		}
+	}
+
+	logger.Debug("no authorization header")
+
+	return nil, proto.ErrUserNotFound
+}
+
+// ErrInvalidToken is returned when a token is invalid.
+var ErrInvalidToken = errors.New("invalid token")
+
+func getJWTClaims(ctx context.Context, bearer string) (*jwt.RegisteredClaims, error) {
+	cfg := config.FromContext(ctx)
+	logger := log.FromContext(ctx).WithPrefix("http.auth")
+	kp, err := cfg.SSH.KeyPair()
+	if err != nil {
+		return nil, err
+	}
+
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		return nil, errors.New("missing repository")
+	}
+
+	token, err := jwt.ParseWithClaims(bearer, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
+		if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok {
+			return nil, errors.New("invalid signing method")
+		}
+
+		return kp.CryptoPublicKey(), nil
+	},
+		jwt.WithIssuer(cfg.HTTP.PublicURL),
+		jwt.WithIssuedAt(),
+		jwt.WithAudience(repo.Name()),
+	)
+	if err != nil {
+		logger.Error("failed to parse jwt", "err", err)
+		return nil, ErrInvalidToken
+	}
+
+	claims, ok := token.Claims.(*jwt.RegisteredClaims)
+	if !token.Valid || !ok {
+		return nil, ErrInvalidToken
+	}
+
+	return claims, nil
+}

server/web/context.go 🔗

@@ -7,6 +7,8 @@ import (
 	"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"
+	"github.com/charmbracelet/soft-serve/server/store"
 )
 
 // NewContextMiddleware returns a new context middleware.
@@ -15,12 +17,16 @@ func NewContextMiddleware(ctx context.Context) func(http.Handler) http.Handler {
 	cfg := config.FromContext(ctx)
 	be := backend.FromContext(ctx)
 	logger := log.FromContext(ctx).WithPrefix("http")
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
 	return func(next http.Handler) http.Handler {
 		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 			ctx := r.Context()
 			ctx = config.WithContext(ctx, cfg)
 			ctx = backend.WithContext(ctx, be)
 			ctx = log.WithContext(ctx, logger)
+			ctx = db.WithContext(ctx, dbx)
+			ctx = store.WithContext(ctx, datastore)
 			r = r.WithContext(ctx)
 			next.ServeHTTP(w, r)
 		})

server/web/git.go 🔗

@@ -20,6 +20,7 @@ import (
 	"github.com/charmbracelet/soft-serve/server/backend"
 	"github.com/charmbracelet/soft-serve/server/config"
 	"github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/server/lfs"
 	"github.com/charmbracelet/soft-serve/server/proto"
 	"github.com/charmbracelet/soft-serve/server/utils"
 	"github.com/prometheus/client_golang/prometheus"
@@ -30,7 +31,7 @@ import (
 
 // GitRoute is a route for git services.
 type GitRoute struct {
-	method  string
+	method  []string
 	pattern *regexp.Regexp
 	handler http.HandlerFunc
 }
@@ -43,19 +44,33 @@ func (g GitRoute) Match(r *http.Request) *http.Request {
 	ctx := r.Context()
 	cfg := config.FromContext(ctx)
 	if m := re.FindStringSubmatch(r.URL.Path); m != nil {
+		// This finds the Git objects & packs filenames in the URL.
 		file := strings.Replace(r.URL.Path, m[1]+"/", "", 1)
-		repo := utils.SanitizeRepo(m[1]) + ".git"
+		repo := utils.SanitizeRepo(m[1])
 
 		var service git.Service
+		var oid string    // LFS object ID
+		var lockID string // LFS lock ID
 		switch {
 		case strings.HasSuffix(r.URL.Path, git.UploadPackService.String()):
 			service = git.UploadPackService
 		case strings.HasSuffix(r.URL.Path, git.ReceivePackService.String()):
 			service = git.ReceivePackService
+		case len(m) > 2:
+			if strings.HasPrefix(file, "info/lfs/objects/basic/") {
+				oid = m[2]
+			} else if strings.HasPrefix(file, "info/lfs/locks/") && strings.HasSuffix(file, "/unlock") {
+				lockID = m[2]
+			}
+			fallthrough
+		case strings.HasPrefix(file, "info/lfs"):
+			service = gitLfsService
 		}
 
+		ctx = context.WithValue(ctx, pattern.Variable("lock_id"), lockID)
+		ctx = context.WithValue(ctx, pattern.Variable("oid"), oid)
 		ctx = context.WithValue(ctx, pattern.Variable("service"), service.String())
-		ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo))
+		ctx = context.WithValue(ctx, pattern.Variable("dir"), filepath.Join(cfg.DataPath, "repos", repo+".git"))
 		ctx = context.WithValue(ctx, pattern.Variable("repo"), repo)
 		ctx = context.WithValue(ctx, pattern.Variable("file"), file)
 
@@ -67,7 +82,15 @@ func (g GitRoute) Match(r *http.Request) *http.Request {
 
 // ServeHTTP implements http.Handler.
 func (g GitRoute) ServeHTTP(w http.ResponseWriter, r *http.Request) {
-	if r.Method != g.method {
+	var hasMethod bool
+	for _, m := range g.method {
+		if m == r.Method {
+			hasMethod = true
+			break
+		}
+	}
+
+	if !hasMethod {
 		renderMethodNotAllowed(w, r)
 		return
 	}
@@ -93,109 +116,204 @@ var (
 	}, []string{"repo", "file"})
 )
 
-func gitRoutes() []Route {
-	routes := make([]Route, 0)
+var (
+	serviceRpcMatcher            = regexp.MustCompile("(.*?)/(?:git-upload-pack|git-receive-pack)$") // nolint: revive
+	getInfoRefsMatcher           = regexp.MustCompile("(.*?)/info/refs$")
+	getTextFileMatcher           = regexp.MustCompile("(.*?)/(?:HEAD|objects/info/alternates|objects/info/http-alternates|objects/info/[^/]*)$")
+	getInfoPacksMatcher          = regexp.MustCompile("(.*?)/objects/info/packs$")
+	getLooseObjectMatcher        = regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$")
+	getPackFileMatcher           = regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$`)
+	getIdxFileMatcher            = regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$`)
+	serviceLfsBatchMatcher       = regexp.MustCompile("(.*?)/info/lfs/objects/batch$")
+	serviceLfsBasicMatcher       = regexp.MustCompile("(.*?)/info/lfs/objects/basic/([0-9a-f]{64})$")
+	serviceLfsBasicVerifyMatcher = regexp.MustCompile("(.*?)/info/lfs/objects/basic/verify$")
+)
 
+var gitRoutes = []GitRoute{
 	// Git services
 	// These routes don't handle authentication/authorization.
 	// This is handled through wrapping the handlers for each route.
 	// See below (withAccess).
-	// TODO: add lfs support
-	for _, route := range []GitRoute{
-		{
-			pattern: regexp.MustCompile("(.*?)/git-upload-pack$"),
-			method:  http.MethodPost,
-			handler: serviceRpc,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/git-receive-pack$"),
-			method:  http.MethodPost,
-			handler: serviceRpc,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/info/refs$"),
-			method:  http.MethodGet,
-			handler: getInfoRefs,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/HEAD$"),
-			method:  http.MethodGet,
-			handler: getTextFile,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/objects/info/alternates$"),
-			method:  http.MethodGet,
-			handler: getTextFile,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/objects/info/http-alternates$"),
-			method:  http.MethodGet,
-			handler: getTextFile,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/objects/info/packs$"),
-			method:  http.MethodGet,
-			handler: getInfoPacks,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/objects/info/[^/]*$"),
-			method:  http.MethodGet,
-			handler: getTextFile,
-		},
-		{
-			pattern: regexp.MustCompile("(.*?)/objects/[0-9a-f]{2}/[0-9a-f]{38}$"),
-			method:  http.MethodGet,
-			handler: getLooseObject,
-		},
-		{
-			pattern: regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.pack$`),
-			method:  http.MethodGet,
-			handler: getPackFile,
-		},
-		{
-			pattern: regexp.MustCompile(`(.*?)/objects/pack/pack-[0-9a-f]{40}\.idx$`),
-			method:  http.MethodGet,
-			handler: getIdxFile,
-		},
-	} {
-		route.handler = withAccess(route.handler)
-		routes = append(routes, route)
-	}
-
-	return routes
+	{
+		pattern: serviceRpcMatcher,
+		method:  []string{http.MethodPost},
+		handler: serviceRpc,
+	},
+	{
+		pattern: getInfoRefsMatcher,
+		method:  []string{http.MethodGet},
+		handler: getInfoRefs,
+	},
+	{
+		pattern: getTextFileMatcher,
+		method:  []string{http.MethodGet},
+		handler: getTextFile,
+	},
+	{
+		pattern: getTextFileMatcher,
+		method:  []string{http.MethodGet},
+		handler: getTextFile,
+	},
+	{
+		pattern: getInfoPacksMatcher,
+		method:  []string{http.MethodGet},
+		handler: getInfoPacks,
+	},
+	{
+		pattern: getLooseObjectMatcher,
+		method:  []string{http.MethodGet},
+		handler: getLooseObject,
+	},
+	{
+		pattern: getPackFileMatcher,
+		method:  []string{http.MethodGet},
+		handler: getPackFile,
+	},
+	{
+		pattern: getIdxFileMatcher,
+		method:  []string{http.MethodGet},
+		handler: getIdxFile,
+	},
+	// Git LFS
+	{
+		pattern: serviceLfsBatchMatcher,
+		method:  []string{http.MethodPost},
+		handler: serviceLfsBatch,
+	},
+	{
+		// Git LFS basic object handler
+		pattern: serviceLfsBasicMatcher,
+		method:  []string{http.MethodGet, http.MethodPut},
+		handler: serviceLfsBasic,
+	},
+	{
+		pattern: serviceLfsBasicVerifyMatcher,
+		method:  []string{http.MethodPost},
+		handler: serviceLfsBasicVerify,
+	},
+	// Git LFS locks
+	{
+		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks$`),
+		method:  []string{http.MethodPost, http.MethodGet},
+		handler: serviceLfsLocks,
+	},
+	{
+		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/verify$`),
+		method:  []string{http.MethodPost},
+		handler: serviceLfsLocksVerify,
+	},
+	{
+		pattern: regexp.MustCompile(`(.*?)/info/lfs/locks/([0-9]+)/unlock$`),
+		method:  []string{http.MethodPost},
+		handler: serviceLfsLocksDelete,
+	},
 }
 
 // withAccess handles auth.
-func withAccess(fn http.HandlerFunc) http.HandlerFunc {
+func withAccess(next http.Handler) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		ctx := r.Context()
-		be := backend.FromContext(ctx)
 		logger := log.FromContext(ctx)
+		be := backend.FromContext(ctx)
 
-		if !be.AllowKeyless(ctx) {
-			renderForbidden(w)
+		// Store repository in context
+		repoName := pat.Param(r, "repo")
+		repo, err := be.Repository(ctx, repoName)
+		if err != nil {
+			if !errors.Is(err, proto.ErrRepoNotFound) {
+				logger.Error("failed to get repository", "err", err)
+			}
+			renderNotFound(w)
+			return
+		}
+
+		ctx = proto.WithRepositoryContext(ctx, repo)
+		r = r.WithContext(ctx)
+
+		user, err := authenticate(r)
+		if err != nil {
+			switch {
+			case errors.Is(err, ErrInvalidToken):
+			case errors.Is(err, proto.ErrUserNotFound):
+			default:
+				logger.Error("failed to authenticate", "err", err)
+			}
+		}
+
+		if user == nil && !be.AllowKeyless(ctx) {
+			renderUnauthorized(w)
 			return
 		}
 
-		repo := pat.Param(r, "repo")
+		// Store user in context
+		ctx = proto.WithUserContext(ctx, user)
+		r = r.WithContext(ctx)
+
+		if user != nil {
+			logger.Info("found user", "username", user.Username())
+		}
+
 		service := git.Service(pat.Param(r, "service"))
-		accessLevel := be.AccessLevel(ctx, repo, "")
+		if service == "" {
+			// Get service from request params
+			service = getServiceType(r)
+		}
+
+		accessLevel := be.AccessLevelForUser(ctx, repoName, user)
+		ctx = access.WithContext(ctx, accessLevel)
+		r = r.WithContext(ctx)
+
+		logger.Info("access level", "repo", repoName, "level", accessLevel)
 
+		file := pat.Param(r, "file")
 		switch service {
 		case git.ReceivePackService:
 			if accessLevel < access.ReadWriteAccess {
 				renderUnauthorized(w)
 				return
 			}
-
-			// Create the repo if it doesn't exist.
-			if _, err := be.Repository(ctx, repo); err != nil {
-				if _, err := be.CreateRepository(ctx, repo, proto.RepositoryOptions{}); err != nil {
-					logger.Error("failed to create repository", "repo", repo, "err", err)
-					renderInternalServerError(w)
-					return
+		case gitLfsService:
+			switch {
+			case strings.HasPrefix(file, "info/lfs/locks"):
+				switch {
+				case strings.HasSuffix(file, "lfs/locks"), strings.HasSuffix(file, "/unlock") && r.Method == http.MethodPost:
+					// Create lock, list locks, and delete lock require write access
+					fallthrough
+				case strings.HasSuffix(file, "lfs/locks/verify"):
+					// Locks verify requires write access
+					// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md#unauthorized-response-2
+					if accessLevel < access.ReadWriteAccess {
+						renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
+							Message: "write access required",
+						})
+						return
+					}
+				}
+			case strings.HasPrefix(file, "info/lfs/objects/basic"):
+				switch r.Method {
+				case http.MethodPut:
+					// Basic upload
+					if accessLevel < access.ReadWriteAccess {
+						renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
+							Message: "write access required",
+						})
+						return
+					}
+				case http.MethodGet:
+					// Basic download
+				case http.MethodPost:
+					// Basic verify
 				}
 			}
+			if accessLevel < access.ReadOnlyAccess {
+				hdr := `Basic realm="Git LFS" charset="UTF-8", Token, Bearer`
+				w.Header().Set("LFS-Authenticate", hdr)
+				w.Header().Set("WWW-Authenticate", hdr)
+				renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
+					Message: "credentials needed",
+				})
+				return
+			}
 		default:
 			if accessLevel < access.ReadOnlyAccess {
 				renderUnauthorized(w)
@@ -203,7 +321,7 @@ func withAccess(fn http.HandlerFunc) http.HandlerFunc {
 			}
 		}
 
-		fn(w, r)
+		next.ServeHTTP(w, r)
 	}
 }
 
@@ -212,7 +330,7 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
 	cfg := config.FromContext(ctx)
 	logger := log.FromContext(ctx)
-	service, dir, repo := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
+	service, dir, repoName := git.Service(pat.Param(r, "service")), pat.Param(r, "dir"), pat.Param(r, "repo")
 
 	if !isSmart(r, service) {
 		renderForbidden(w)
@@ -220,7 +338,18 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 	}
 
 	if service == git.ReceivePackService {
-		gitHttpReceiveCounter.WithLabelValues(repo)
+		gitHttpReceiveCounter.WithLabelValues(repoName)
+
+		// Create the repo if it doesn't exist.
+		be := backend.FromContext(ctx)
+		repo := proto.RepositoryFromContext(ctx)
+		if repo == nil {
+			if _, err := be.CreateRepository(ctx, repoName, proto.RepositoryOptions{}); err != nil {
+				logger.Error("failed to create repository", "repo", repoName, "err", err)
+				renderInternalServerError(w)
+				return
+			}
+		}
 	}
 
 	w.Header().Set("Content-Type", fmt.Sprintf("application/x-%s-result", service))
@@ -238,10 +367,19 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 		Args:   []string{"--stateless-rpc"},
 	}
 
+	user := proto.UserFromContext(ctx)
+	cmd.Env = append(cmd.Env, []string{
+		"SOFT_SERVE_REPO_NAME=" + repoName,
+		"SOFT_SERVE_REPO_PATH=" + dir,
+		"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
+	}...)
+	if user != nil {
+		cmd.Env = append(cmd.Env, []string{
+			"SOFT_SERVE_USERNAME=" + user.Username(),
+		}...)
+	}
 	if len(version) != 0 {
 		cmd.Env = append(cmd.Env, []string{
-			// TODO: add the rest of env vars when we support pushing using http
-			"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
 			fmt.Sprintf("GIT_PROTOCOL=%s", version),
 		}...)
 	}
@@ -302,11 +440,12 @@ func serviceRpc(w http.ResponseWriter, r *http.Request) {
 
 func getInfoRefs(w http.ResponseWriter, r *http.Request) {
 	ctx := r.Context()
-	dir, repo, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
+	cfg := config.FromContext(ctx)
+	dir, repoName, file := pat.Param(r, "dir"), pat.Param(r, "repo"), pat.Param(r, "file")
 	service := getServiceType(r)
 	version := r.Header.Get("Git-Protocol")
 
-	gitHttpUploadCounter.WithLabelValues(repo, file).Inc()
+	gitHttpUploadCounter.WithLabelValues(repoName, file).Inc()
 
 	if service != "" && (service == git.UploadPackService || service == git.ReceivePackService) {
 		// Smart HTTP
@@ -317,6 +456,17 @@ func getInfoRefs(w http.ResponseWriter, r *http.Request) {
 			Args:   []string{"--stateless-rpc", "--advertise-refs"},
 		}
 
+		user := proto.UserFromContext(ctx)
+		cmd.Env = append(cmd.Env, []string{
+			"SOFT_SERVE_REPO_NAME=" + repoName,
+			"SOFT_SERVE_REPO_PATH=" + dir,
+			"SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
+		}...)
+		if user != nil {
+			cmd.Env = append(cmd.Env, []string{
+				"SOFT_SERVE_USERNAME=" + user.Username(),
+			}...)
+		}
 		if len(version) != 0 {
 			cmd.Env = append(cmd.Env, fmt.Sprintf("GIT_PROTOCOL=%s", version))
 		}
@@ -393,7 +543,8 @@ func getServiceType(r *http.Request) git.Service {
 }
 
 func isSmart(r *http.Request, service git.Service) bool {
-	return r.Header.Get("Content-Type") == fmt.Sprintf("application/x-%s-request", service)
+	contentType := r.Header.Get("Content-Type")
+	return strings.HasPrefix(contentType, fmt.Sprintf("application/x-%s-request", service))
 }
 
 func updateServerInfo(ctx context.Context, dir string) error {
@@ -402,34 +553,32 @@ func updateServerInfo(ctx context.Context, dir string) error {
 
 // HTTP error response handling functions
 
+func renderBadRequest(w http.ResponseWriter) {
+	renderStatus(http.StatusBadRequest)(w, nil)
+}
+
 func renderMethodNotAllowed(w http.ResponseWriter, r *http.Request) {
 	if r.Proto == "HTTP/1.1" {
-		w.WriteHeader(http.StatusMethodNotAllowed)
-		w.Write([]byte("Method Not Allowed")) // nolint: errcheck
+		renderStatus(http.StatusMethodNotAllowed)(w, r)
 	} else {
-		w.WriteHeader(http.StatusBadRequest)
-		w.Write([]byte("Bad Request")) // nolint: errcheck
+		renderBadRequest(w)
 	}
 }
 
 func renderNotFound(w http.ResponseWriter) {
-	w.WriteHeader(http.StatusNotFound)
-	w.Write([]byte("Not Found")) // nolint: errcheck
+	renderStatus(http.StatusNotFound)(w, nil)
 }
 
 func renderUnauthorized(w http.ResponseWriter) {
-	w.WriteHeader(http.StatusUnauthorized)
-	w.Write([]byte("Unauthorized")) // nolint: errcheck
+	renderStatus(http.StatusUnauthorized)(w, nil)
 }
 
 func renderForbidden(w http.ResponseWriter) {
-	w.WriteHeader(http.StatusForbidden)
-	w.Write([]byte("Forbidden")) // nolint: errcheck
+	renderStatus(http.StatusForbidden)(w, nil)
 }
 
 func renderInternalServerError(w http.ResponseWriter) {
-	w.WriteHeader(http.StatusInternalServerError)
-	w.Write([]byte("Internal Server Error")) // nolint: errcheck
+	renderStatus(http.StatusInternalServerError)(w, nil)
 }
 
 // Header writing functions

server/web/git_lfs.go 🔗

@@ -0,0 +1,954 @@
+package web
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"net/http"
+	"net/url"
+	"path"
+	"path/filepath"
+	"strconv"
+	"strings"
+
+	"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"
+	"github.com/charmbracelet/soft-serve/server/db/models"
+	"github.com/charmbracelet/soft-serve/server/git"
+	"github.com/charmbracelet/soft-serve/server/lfs"
+	"github.com/charmbracelet/soft-serve/server/proto"
+	"github.com/charmbracelet/soft-serve/server/storage"
+	"github.com/charmbracelet/soft-serve/server/store"
+	"goji.io/pat"
+)
+
+// Place holder service to handle Git LFS requests.
+const gitLfsService git.Service = "git-lfs-service"
+
+// serviceLfsBatch handles a Git LFS batch requests.
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md
+// TODO: support refname
+// POST: /<repo>.git/info/lfs/objects/batch
+func serviceLfsBatch(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	logger := log.FromContext(ctx).WithPrefix("http.lfs")
+
+	if !isLfs(r) {
+		logger.Errorf("invalid content type: %s", r.Header.Get("Content-Type"))
+		renderNotAcceptable(w)
+		return
+	}
+
+	var batchRequest lfs.BatchRequest
+	defer r.Body.Close() // nolint: errcheck
+	if err := json.NewDecoder(r.Body).Decode(&batchRequest); err != nil {
+		logger.Errorf("error decoding json: %s", err)
+		return
+	}
+
+	// We only accept basic transfers for now
+	// Default to basic if no transfer is specified
+	if len(batchRequest.Transfers) > 0 {
+		var isBasic bool
+		for _, t := range batchRequest.Transfers {
+			if t == lfs.TransferBasic {
+				isBasic = true
+				break
+			}
+		}
+
+		if !isBasic {
+			renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
+				Message: "unsupported transfer",
+			})
+			return
+		}
+	}
+
+	name := pat.Param(r, "repo")
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	cfg := config.FromContext(ctx)
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	// TODO: support S3 storage
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+
+	baseHref := fmt.Sprintf("%s/%s/info/lfs/objects/basic", cfg.HTTP.PublicURL, name+".git")
+
+	var batchResponse lfs.BatchResponse
+	batchResponse.Transfer = lfs.TransferBasic
+	batchResponse.HashAlgo = lfs.HashAlgorithmSHA256
+
+	objects := make([]*lfs.ObjectResponse, 0, len(batchRequest.Objects))
+	// XXX: We don't support objects TTL for now, probably implement that with
+	// S3 using object "expires_at" & "expires_in"
+	switch batchRequest.Operation {
+	case lfs.OperationDownload:
+		for _, o := range batchRequest.Objects {
+			exist, err := strg.Exists(path.Join("objects", o.RelativePath()))
+			if err != nil && !errors.Is(err, fs.ErrNotExist) {
+				logger.Error("error getting object stat", "oid", o.Oid, "repo", name, "err", err)
+				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+					Message: "internal server error",
+				})
+				return
+			}
+
+			obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), o.Oid)
+			if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
+				logger.Error("error getting object from database", "oid", o.Oid, "repo", name, "err", err)
+				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+					Message: "internal server error",
+				})
+				return
+			}
+
+			if !exist {
+				objects = append(objects, &lfs.ObjectResponse{
+					Pointer: o,
+					Error: &lfs.ObjectError{
+						Code:    http.StatusNotFound,
+						Message: "object not found",
+					},
+				})
+			} else if obj.Size != o.Size {
+				objects = append(objects, &lfs.ObjectResponse{
+					Pointer: o,
+					Error: &lfs.ObjectError{
+						Code:    http.StatusUnprocessableEntity,
+						Message: "size mismatch",
+					},
+				})
+			} else if o.IsValid() {
+				download := &lfs.Link{
+					Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
+				}
+				if auth := r.Header.Get("Authorization"); auth != "" {
+					download.Header = map[string]string{
+						"Authorization": auth,
+					}
+				}
+
+				objects = append(objects, &lfs.ObjectResponse{
+					Pointer: o,
+					Actions: map[string]*lfs.Link{
+						lfs.ActionDownload: download,
+					},
+				})
+
+				// If the object doesn't exist in the database, create it
+				if exist && obj.ID == 0 {
+					if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), o.Oid, o.Size); err != nil {
+						logger.Error("error creating object in datastore", "oid", o.Oid, "repo", name, "err", err)
+						renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+							Message: "internal server error",
+						})
+						return
+					}
+				}
+			} else {
+				logger.Error("invalid object", "oid", o.Oid, "repo", name)
+				objects = append(objects, &lfs.ObjectResponse{
+					Pointer: o,
+					Error: &lfs.ObjectError{
+						Code:    http.StatusUnprocessableEntity,
+						Message: "invalid object",
+					},
+				})
+			}
+		}
+	case lfs.OperationUpload:
+		// Object upload logic happens in the "basic" API route
+		for _, o := range batchRequest.Objects {
+			if !o.IsValid() {
+				objects = append(objects, &lfs.ObjectResponse{
+					Pointer: o,
+					Error: &lfs.ObjectError{
+						Code:    http.StatusUnprocessableEntity,
+						Message: "invalid object",
+					},
+				})
+			} else {
+				upload := &lfs.Link{
+					Href: fmt.Sprintf("%s/%s", baseHref, o.Oid),
+					Header: map[string]string{
+						// NOTE: git-lfs v2.5.0 sets the Content-Type based on the uploaded file.
+						// This ensures that the client always uses the designated value for the header.
+						"Content-Type": "application/octet-stream",
+					},
+				}
+				verify := &lfs.Link{
+					Href: fmt.Sprintf("%s/verify", baseHref),
+				}
+				if auth := r.Header.Get("Authorization"); auth != "" {
+					upload.Header["Authorization"] = auth
+					verify.Header = map[string]string{
+						"Authorization": auth,
+					}
+				}
+
+				objects = append(objects, &lfs.ObjectResponse{
+					Pointer: o,
+					Actions: map[string]*lfs.Link{
+						lfs.ActionUpload: upload,
+						// Verify uploaded objects
+						// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md#verification
+						lfs.ActionVerify: verify,
+					},
+				})
+			}
+		}
+	default:
+		renderJSON(w, http.StatusUnprocessableEntity, lfs.ErrorResponse{
+			Message: "unsupported operation",
+		})
+		return
+	}
+
+	batchResponse.Objects = objects
+	renderJSON(w, http.StatusOK, batchResponse)
+}
+
+// serviceLfsBasic implements Git LFS basic transfer API
+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/basic-transfers.md
+func serviceLfsBasic(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		serviceLfsBasicDownload(w, r)
+	case http.MethodPut:
+		serviceLfsBasicUpload(w, r)
+	}
+}
+
+// GET: /<repo>.git/info/lfs/objects/basic/<oid>
+func serviceLfsBasicDownload(w http.ResponseWriter, r *http.Request) {
+	ctx := r.Context()
+	oid := pat.Param(r, "oid")
+	repo := proto.RepositoryFromContext(ctx)
+	cfg := config.FromContext(ctx)
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
+	datastore := store.FromContext(ctx)
+	dbx := db.FromContext(ctx)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+
+	obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid)
+	if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
+		logger.Error("error getting object from database", "oid", oid, "repo", repo.Name(), "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	pointer := lfs.Pointer{Oid: oid}
+	f, err := strg.Open(path.Join("objects", pointer.RelativePath()))
+	if err != nil {
+		logger.Error("error opening object", "oid", oid, "err", err)
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "object not found",
+		})
+		return
+	}
+
+	w.Header().Set("Content-Type", "application/octet-stream")
+	w.Header().Set("Content-Length", strconv.FormatInt(obj.Size, 10))
+	defer f.Close() // nolint: errcheck
+	if _, err := io.Copy(w, f); err != nil {
+		logger.Error("error copying object to response", "oid", oid, "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+}
+
+// PUT: /<repo>.git/info/lfs/objects/basic/<oid>
+func serviceLfsBasicUpload(w http.ResponseWriter, r *http.Request) {
+	if !isBinary(r) {
+		renderJSON(w, http.StatusUnsupportedMediaType, lfs.ErrorResponse{
+			Message: "invalid content type",
+		})
+		return
+	}
+
+	ctx := r.Context()
+	oid := pat.Param(r, "oid")
+	cfg := config.FromContext(ctx)
+	be := backend.FromContext(ctx)
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	name := pat.Param(r, "repo")
+
+	defer r.Body.Close() // nolint: errcheck
+	repo, err := be.Repository(ctx, name)
+	if err != nil {
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	// NOTE: Git LFS client will retry uploading the same object if there was a
+	// partial error, so we need to skip existing objects.
+	if _, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), oid); err == nil {
+		// Object exists, skip request
+		io.Copy(io.Discard, r.Body) // nolint: errcheck
+		renderStatus(http.StatusOK)(w, nil)
+		return
+	} else if !errors.Is(err, db.ErrRecordNotFound) {
+		logger.Error("error getting object", "oid", oid, "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	pointer := lfs.Pointer{Oid: oid}
+	if _, err := strg.Put(path.Join("objects", pointer.RelativePath()), r.Body); err != nil {
+		logger.Error("error writing object", "oid", oid, "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	size, err := strconv.ParseInt(r.Header.Get("Content-Length"), 10, 64)
+	if err != nil {
+		logger.Error("error parsing content length", "err", err)
+		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
+			Message: "invalid content length",
+		})
+		return
+	}
+
+	if err := datastore.CreateLFSObject(ctx, dbx, repo.ID(), oid, size); err != nil {
+		logger.Error("error creating object", "oid", oid, "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	renderStatus(http.StatusOK)(w, nil)
+}
+
+// POST: /<repo>.git/info/lfs/objects/basic/verify
+func serviceLfsBasicVerify(w http.ResponseWriter, r *http.Request) {
+	if !isLfs(r) {
+		renderNotAcceptable(w)
+		return
+	}
+
+	var pointer lfs.Pointer
+	ctx := r.Context()
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-basic")
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Error("error getting repository from context")
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	defer r.Body.Close() // nolint: errcheck
+	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",
+		})
+		return
+	}
+
+	cfg := config.FromContext(ctx)
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
+	if stat, err := strg.Stat(path.Join("objects", pointer.RelativePath())); err == nil {
+		// Verify object is in the database.
+		obj, err := datastore.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)
+		if err != nil {
+			if errors.Is(err, db.ErrRecordNotFound) {
+				logger.Error("object not found", "oid", pointer.Oid)
+				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+					Message: "object not found",
+				})
+				return
+			}
+			logger.Error("error getting object", "oid", pointer.Oid, "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		if obj.Size != pointer.Size {
+			renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
+				Message: "object size mismatch",
+			})
+			return
+		}
+
+		if pointer.IsValid() && stat.Size() == pointer.Size {
+			renderStatus(http.StatusOK)(w, nil)
+			return
+		}
+	} else if errors.Is(err, fs.ErrNotExist) {
+		logger.Error("file not found", "oid", pointer.Oid)
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "object not found",
+		})
+		return
+	} else {
+		logger.Error("error getting object", "oid", pointer.Oid, "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+}
+
+func serviceLfsLocks(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+		serviceLfsLocksGet(w, r)
+	case http.MethodPost:
+		serviceLfsLocksCreate(w, r)
+	default:
+		renderMethodNotAllowed(w, r)
+	}
+}
+
+// POST: /<repo>.git/info/lfs/objects/locks
+func serviceLfsLocksCreate(w http.ResponseWriter, r *http.Request) {
+	if !isLfs(r) {
+		renderNotAcceptable(w)
+		return
+	}
+
+	ctx := r.Context()
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
+
+	var req lfs.LockCreateRequest
+	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",
+		})
+		return
+	}
+
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Error("error getting repository from context")
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	user := proto.UserFromContext(ctx)
+	if user == nil {
+		logger.Error("error getting user from context")
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "user not found",
+		})
+		return
+	}
+
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	if err := datastore.CreateLFSLockForUser(ctx, dbx, repo.ID(), user.ID(), req.Path, req.Ref.Name); err != nil {
+		err = db.WrapError(err)
+		if errors.Is(err, db.ErrDuplicateKey) {
+			errResp := lfs.LockResponse{
+				ErrorResponse: lfs.ErrorResponse{
+					Message: "lock already exists",
+				},
+			}
+			lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
+			if err == nil {
+				errResp.Lock = lfs.Lock{
+					ID:       strconv.FormatInt(lock.ID, 10),
+					Path:     lock.Path,
+					LockedAt: lock.CreatedAt,
+				}
+				lockOwner := lfs.Owner{
+					Name: user.Username(),
+				}
+				if lock.UserID != user.ID() {
+					owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
+					if err != nil {
+						logger.Error("error getting lock owner", "err", err)
+						renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+							Message: "internal server error",
+						})
+						return
+					}
+					lockOwner.Name = owner.Username
+				}
+				errResp.Lock.Owner = lockOwner
+			}
+			renderJSON(w, http.StatusConflict, errResp)
+			return
+		}
+		logger.Error("error creating lock", "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	lock, err := datastore.GetLFSLockForUserPath(ctx, dbx, repo.ID(), user.ID(), req.Path)
+	if err != nil {
+		logger.Error("error getting lock", "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	renderJSON(w, http.StatusCreated, lfs.LockResponse{
+		Lock: lfs.Lock{
+			ID:       strconv.FormatInt(lock.ID, 10),
+			Path:     lock.Path,
+			LockedAt: lock.CreatedAt,
+			Owner: lfs.Owner{
+				Name: user.Username(),
+			},
+		},
+	})
+}
+
+// GET: /<repo>.git/info/lfs/objects/locks
+func serviceLfsLocksGet(w http.ResponseWriter, r *http.Request) {
+	accept := r.Header.Get("Accept")
+	if !strings.HasPrefix(accept, lfs.MediaType) {
+		renderNotAcceptable(w)
+		return
+	}
+
+	parseLocksQuery := func(values url.Values) (path string, id int64, cursor int, limit int, refspec string) {
+		path = values.Get("path")
+		idStr := values.Get("id")
+		if idStr != "" {
+			id, _ = strconv.ParseInt(idStr, 10, 64)
+		}
+		cursorStr := values.Get("cursor")
+		if cursorStr != "" {
+			cursor, _ = strconv.Atoi(cursorStr)
+		}
+		limitStr := values.Get("limit")
+		if limitStr != "" {
+			limit, _ = strconv.Atoi(limitStr)
+		}
+		refspec = values.Get("refspec")
+		return
+	}
+
+	ctx := r.Context()
+	// TODO: respect refspec
+	path, id, cursor, limit, _ := parseLocksQuery(r.URL.Query())
+	if limit > 100 {
+		limit = 100
+	} else if limit <= 0 {
+		limit = lfs.DefaultLocksLimit
+	}
+
+	// cursor is the page number
+	if cursor <= 0 {
+		cursor = 1
+	}
+
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Error("error getting repository from context")
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	if id > 0 {
+		lock, err := datastore.GetLFSLockByID(ctx, dbx, id)
+		if err != nil {
+			if errors.Is(err, db.ErrRecordNotFound) {
+				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+					Message: "lock not found",
+				})
+				return
+			}
+			logger.Error("error getting lock", "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
+		if err != nil {
+			logger.Error("error getting lock owner", "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		renderJSON(w, http.StatusOK, lfs.LockListResponse{
+			Locks: []lfs.Lock{
+				{
+					ID:       strconv.FormatInt(lock.ID, 10),
+					Path:     lock.Path,
+					LockedAt: lock.CreatedAt,
+					Owner: lfs.Owner{
+						Name: owner.Username,
+					},
+				},
+			},
+		})
+		return
+	} else if path != "" {
+		lock, err := datastore.GetLFSLockForPath(ctx, dbx, repo.ID(), path)
+		if err != nil {
+			if errors.Is(err, db.ErrRecordNotFound) {
+				renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+					Message: "lock not found",
+				})
+				return
+			}
+			logger.Error("error getting lock", "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
+		if err != nil {
+			logger.Error("error getting lock owner", "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		renderJSON(w, http.StatusOK, lfs.LockListResponse{
+			Locks: []lfs.Lock{
+				{
+					ID:       strconv.FormatInt(lock.ID, 10),
+					Path:     lock.Path,
+					LockedAt: lock.CreatedAt,
+					Owner: lfs.Owner{
+						Name: owner.Username,
+					},
+				},
+			},
+		})
+		return
+	} else {
+		locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
+		if err != nil {
+			logger.Error("error getting locks", "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		lockList := make([]lfs.Lock, len(locks))
+		users := map[int64]models.User{}
+		for i, lock := range locks {
+			owner, ok := users[lock.UserID]
+			if !ok {
+				owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
+				if err != nil {
+					logger.Error("error getting lock owner", "err", err)
+					renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+						Message: "internal server error",
+					})
+					return
+				}
+				users[lock.UserID] = owner
+			}
+
+			lockList[i] = lfs.Lock{
+				ID:       strconv.FormatInt(lock.ID, 10),
+				Path:     lock.Path,
+				LockedAt: lock.CreatedAt,
+				Owner: lfs.Owner{
+					Name: owner.Username,
+				},
+			}
+		}
+
+		resp := lfs.LockListResponse{
+			Locks: lockList,
+		}
+		if len(locks) == limit {
+			resp.NextCursor = strconv.Itoa(cursor + 1)
+		}
+
+		renderJSON(w, http.StatusOK, resp)
+		return
+	}
+}
+
+// POST: /<repo>.git/info/lfs/objects/locks/verify
+func serviceLfsLocksVerify(w http.ResponseWriter, r *http.Request) {
+	if !isLfs(r) {
+		renderNotAcceptable(w)
+		return
+	}
+
+	ctx := r.Context()
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Error("error getting repository from context")
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	var req lfs.LockVerifyRequest
+	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",
+		})
+		return
+	}
+
+	// TODO: refspec
+	cursor, _ := strconv.Atoi(req.Cursor)
+	if cursor <= 0 {
+		cursor = 1
+	}
+
+	limit := req.Limit
+	if limit > 100 {
+		limit = 100
+	} else if limit <= 0 {
+		limit = lfs.DefaultLocksLimit
+	}
+
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	user := proto.UserFromContext(ctx)
+	ours := make([]lfs.Lock, 0)
+	theirs := make([]lfs.Lock, 0)
+
+	var resp lfs.LockVerifyResponse
+	locks, err := datastore.GetLFSLocks(ctx, dbx, repo.ID(), cursor, limit)
+	if err != nil {
+		logger.Error("error getting locks", "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	users := map[int64]models.User{}
+	for _, lock := range locks {
+		owner, ok := users[lock.UserID]
+		if !ok {
+			owner, err = datastore.GetUserByID(ctx, dbx, lock.UserID)
+			if err != nil {
+				logger.Error("error getting lock owner", "err", err)
+				renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+					Message: "internal server error",
+				})
+				return
+			}
+			users[lock.UserID] = owner
+		}
+
+		l := lfs.Lock{
+			ID:       strconv.FormatInt(lock.ID, 10),
+			Path:     lock.Path,
+			LockedAt: lock.CreatedAt,
+			Owner: lfs.Owner{
+				Name: owner.Username,
+			},
+		}
+
+		if user != nil && user.ID() == lock.UserID {
+			ours = append(ours, l)
+		} else {
+			theirs = append(theirs, l)
+		}
+	}
+
+	resp.Ours = ours
+	resp.Theirs = theirs
+
+	if len(locks) == limit {
+		resp.NextCursor = strconv.Itoa(cursor + 1)
+	}
+
+	renderJSON(w, http.StatusOK, resp)
+}
+
+// POST: /<repo>.git/info/lfs/objects/locks/:lockID/unlock
+func serviceLfsLocksDelete(w http.ResponseWriter, r *http.Request) {
+	if !isLfs(r) {
+		renderNotAcceptable(w)
+		return
+	}
+
+	ctx := r.Context()
+	logger := log.FromContext(ctx).WithPrefix("http.lfs-locks")
+	lockIDStr := pat.Param(r, "lock_id")
+	if lockIDStr == "" {
+		logger.Error("error getting lock id")
+		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
+			Message: "invalid request",
+		})
+		return
+	}
+
+	lockID, err := strconv.ParseInt(lockIDStr, 10, 64)
+	if err != nil {
+		logger.Error("error parsing lock id", "err", err)
+		renderJSON(w, http.StatusBadRequest, lfs.ErrorResponse{
+			Message: "invalid request",
+		})
+		return
+	}
+
+	var req lfs.LockDeleteRequest
+	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",
+		})
+		return
+	}
+
+	dbx := db.FromContext(ctx)
+	datastore := store.FromContext(ctx)
+	repo := proto.RepositoryFromContext(ctx)
+	if repo == nil {
+		logger.Error("error getting repository from context")
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "repository not found",
+		})
+		return
+	}
+
+	// The lock being deleted
+	lock, err := datastore.GetLFSLockByID(ctx, dbx, lockID)
+	if err != nil {
+		logger.Error("error getting lock", "err", err)
+		renderJSON(w, http.StatusNotFound, lfs.ErrorResponse{
+			Message: "lock not found",
+		})
+		return
+	}
+
+	owner, err := datastore.GetUserByID(ctx, dbx, lock.UserID)
+	if err != nil {
+		logger.Error("error getting lock owner", "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	// Delete another user's lock
+	l := lfs.Lock{
+		ID:       strconv.FormatInt(lock.ID, 10),
+		Path:     lock.Path,
+		LockedAt: lock.CreatedAt,
+		Owner: lfs.Owner{
+			Name: owner.Username,
+		},
+	}
+	if req.Force {
+		if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
+			logger.Error("error deleting lock", "err", err)
+			renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+				Message: "internal server error",
+			})
+			return
+		}
+
+		renderJSON(w, http.StatusOK, l)
+		return
+	}
+
+	// Delete our own lock
+	user := proto.UserFromContext(ctx)
+	if user == nil {
+		logger.Error("error getting user from context")
+		renderJSON(w, http.StatusUnauthorized, lfs.ErrorResponse{
+			Message: "unauthorized",
+		})
+		return
+	}
+
+	if owner.ID != user.ID() {
+		logger.Error("error deleting another user's lock")
+		renderJSON(w, http.StatusForbidden, lfs.ErrorResponse{
+			Message: "lock belongs to another user",
+		})
+		return
+	}
+
+	if err := datastore.DeleteLFSLock(ctx, dbx, repo.ID(), lockID); err != nil {
+		logger.Error("error deleting lock", "err", err)
+		renderJSON(w, http.StatusInternalServerError, lfs.ErrorResponse{
+			Message: "internal server error",
+		})
+		return
+	}
+
+	renderJSON(w, http.StatusOK, lfs.LockResponse{Lock: l})
+}
+
+// renderJSON renders a JSON response with the given status code and value. It
+// also sets the Content-Type header to the JSON LFS media type (application/vnd.git-lfs+json).
+func renderJSON(w http.ResponseWriter, statusCode int, v interface{}) {
+	hdrLfs(w)
+	w.WriteHeader(statusCode)
+	if err := json.NewEncoder(w).Encode(v); err != nil {
+		log.Error("error encoding json", "err", err)
+	}
+}
+
+func renderNotAcceptable(w http.ResponseWriter) {
+	renderStatus(http.StatusNotAcceptable)(w, nil)
+}
+
+func isLfs(r *http.Request) bool {
+	contentType := r.Header.Get("Content-Type")
+	accept := r.Header.Get("Accept")
+	return strings.HasPrefix(contentType, lfs.MediaType) && strings.HasPrefix(accept, lfs.MediaType)
+}
+
+func isBinary(r *http.Request) bool {
+	contentType := r.Header.Get("Content-Type")
+	return strings.HasPrefix(contentType, "application/octet-stream")
+}
+
+func hdrLfs(w http.ResponseWriter) {
+	w.Header().Set("Content-Type", lfs.MediaType)
+	w.Header().Set("Accept", lfs.MediaType)
+}

server/web/http.go 🔗

@@ -5,6 +5,7 @@ import (
 	"net/http"
 	"time"
 
+	"github.com/charmbracelet/log"
 	"github.com/charmbracelet/soft-serve/server/config"
 )
 
@@ -18,6 +19,7 @@ type HTTPServer struct {
 // NewHTTPServer creates a new HTTP server.
 func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
 	cfg := config.FromContext(ctx)
+	logger := log.FromContext(ctx).WithPrefix("http")
 	s := &HTTPServer{
 		ctx: ctx,
 		cfg: cfg,
@@ -28,6 +30,7 @@ func NewHTTPServer(ctx context.Context) (*HTTPServer, error) {
 			ReadTimeout:       time.Second * 10,
 			WriteTimeout:      time.Second * 10,
 			MaxHeaderBytes:    http.DefaultMaxHeaderBytes,
+			ErrorLog:          logger.StandardLog(log.StandardLogOptions{ForceLevel: log.ErrorLevel}),
 		},
 	}
 

server/web/server.go 🔗

@@ -23,8 +23,8 @@ func NewRouter(ctx context.Context) *goji.Mux {
 	mux.Use(NewLoggingMiddleware)
 
 	// Git routes
-	for _, service := range gitRoutes() {
-		mux.Handle(service, service)
+	for _, service := range gitRoutes {
+		mux.Handle(service, withAccess(service))
 	}
 
 	// go-get handler

server/web/util.go 🔗

@@ -0,0 +1,10 @@
+package web
+
+import "net/http"
+
+func renderStatus(code int) http.HandlerFunc {
+	return func(w http.ResponseWriter, _ *http.Request) {
+		w.WriteHeader(code)
+		w.Write([]byte(http.StatusText(code))) // nolint: errcheck
+	}
+}

testscript/testdata/help.txtar 🔗

@@ -13,6 +13,7 @@ Usage:
 Available Commands:
   help         Help about any command
   info         Show your info
+  jwt          Generate a JSON Web Token
   pubkey       Manage your public keys
   repo         Manage repositories
   set-username Set your username

testscript/testdata/repo-perms.txtar 🔗

@@ -31,33 +31,33 @@ soft repo collab list repo1
 
 # regular user can't access it
 ! usoft repo info repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo tree repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo tag list repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo tag delete repo1 v1.0.0
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo blob repo1 README.md
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo description repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo description repo1 'new desc'
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo project-name repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo private repo1 true
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo private repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo rename repo1 repo11
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo branch default repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo branch default repo1 main
-stderr 'Unauthorized'
+stderr 'unauthorized'
 ! usoft repo delete repo1
-stderr 'Unauthorized'
+stderr 'unauthorized'
 
 # add user1 as collab
 soft repo collab add repo1 user1

testscript/testdata/repo-tree.txtar 🔗

@@ -35,7 +35,7 @@ cmp stdout tree3.txt
 # print tree of folder that does not exist
 ! soft repo tree repo1 folder2
 ! stdout .
-stderr 'File not found'
+stderr 'file not found'
 
 # print tree of bad revision
 ! soft repo tree repo1 badrev folder