From bb96848aa54ab1789ffb8d2f20f83fde7a0ca899 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Fri, 21 Jul 2023 16:36:36 -0400 Subject: [PATCH] feat(web): implement git auth and lfs 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 --- 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 +- .../0001_create_tables_postgres.down.sql | 5 - .../0001_create_tables_sqlite.down.sql | 5 - .../0002_create_lfs_tables_postgres.down.sql | 2 - .../0002_create_lfs_tables_postgres.up.sql | 12 +- .../0002_create_lfs_tables_sqlite.down.sql | 2 - .../0002_create_lfs_tables_sqlite.up.sql | 4 + server/db/migrate/0003_password_tokens.go | 23 + .../0003_password_tokens_postgres.down.sql | 2 + .../0003_password_tokens_postgres.up.sql | 15 + .../0003_password_tokens_sqlite.down.sql | 1 + .../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, 2333 insertions(+), 368 deletions(-) create mode 100644 server/access/context.go create mode 100644 server/backend/auth.go create mode 100644 server/config/ssh.go create mode 100644 server/db/migrate/0003_password_tokens.go create mode 100644 server/db/migrate/0003_password_tokens_postgres.down.sql create mode 100644 server/db/migrate/0003_password_tokens_postgres.up.sql create mode 100644 server/db/migrate/0003_password_tokens_sqlite.down.sql create mode 100644 server/db/migrate/0003_password_tokens_sqlite.up.sql create mode 100644 server/db/models/access_token.go create mode 100644 server/git/lfs_auth.go create mode 100644 server/jwk/jwk.go create mode 100644 server/proto/context.go create mode 100644 server/ssh/cmd/jwt.go create mode 100644 server/store/access_token.go create mode 100644 server/store/collab.go create mode 100644 server/store/repo.go create mode 100644 server/store/settings.go create mode 100644 server/store/user.go create mode 100644 server/web/auth.go create mode 100644 server/web/git_lfs.go create mode 100644 server/web/util.go diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go index fdfbaed46..1cbbf511e 100644 --- a/cmd/soft/hook.go +++ b/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 diff --git a/cmd/soft/root.go b/cmd/soft/root.go index 7f03982d4..9e443b5b3 100644 --- a/cmd/soft/root.go +++ b/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) diff --git a/go.mod b/go.mod index 0a9d11dcd..7c03a19ec 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 7846d7c62..bf526c8d8 100644 --- a/go.sum +++ b/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= diff --git a/server/access/context.go b/server/access/context.go new file mode 100644 index 000000000..3bc08c011 --- /dev/null +++ b/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) +} diff --git a/server/backend/auth.go b/server/backend/auth.go new file mode 100644 index 000000000..62160fccb --- /dev/null +++ b/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) +} diff --git a/server/backend/collab.go b/server/backend/collab.go index 92bfca821..78c5b8a23 100644 --- a/server/backend/collab.go +++ b/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 { diff --git a/server/backend/hooks.go b/server/backend/hooks.go index b28c5ecd7..fe10432ed 100644 --- a/server/backend/hooks.go +++ b/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() diff --git a/server/backend/lfs.go b/server/backend/lfs.go index dfc21ea69..eac557ecf 100644 --- a/server/backend/lfs.go +++ b/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 }) }) } diff --git a/server/backend/repo.go b/server/backend/repo.go index 15eaea6ff..840385112 100644 --- a/server/backend/repo.go +++ b/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) } diff --git a/server/backend/user.go b/server/backend/user.go index 8b5e2a2ae..ff07660b0 100644 --- a/server/backend/user.go +++ b/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 diff --git a/server/config/ssh.go b/server/config/ssh.go new file mode 100644 index 000000000..102b39141 --- /dev/null +++ b/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)) +} diff --git a/server/db/errors.go b/server/db/errors.go index f793a0588..9c19b0282 100644 --- a/server/db/errors.go +++ b/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 diff --git a/server/db/migrate/0001_create_tables_postgres.down.sql b/server/db/migrate/0001_create_tables_postgres.down.sql index 35eeb70de..e69de29bb 100644 --- a/server/db/migrate/0001_create_tables_postgres.down.sql +++ b/server/db/migrate/0001_create_tables_postgres.down.sql @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS collabs; -DROP TABLE IF EXISTS repos; -DROP TABLE IF EXISTS public_keys; -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS settings; diff --git a/server/db/migrate/0001_create_tables_sqlite.down.sql b/server/db/migrate/0001_create_tables_sqlite.down.sql index 35eeb70de..e69de29bb 100644 --- a/server/db/migrate/0001_create_tables_sqlite.down.sql +++ b/server/db/migrate/0001_create_tables_sqlite.down.sql @@ -1,5 +0,0 @@ -DROP TABLE IF EXISTS collabs; -DROP TABLE IF EXISTS repos; -DROP TABLE IF EXISTS public_keys; -DROP TABLE IF EXISTS users; -DROP TABLE IF EXISTS settings; diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql index bae6ea0cd..e69de29bb 100644 --- a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql +++ b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS lfs_locks; -DROP TABLE IF EXISTS lfs_objects; diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql index fed48900f..36391d30a 100644 --- a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql +++ b/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 ); diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql index bae6ea0cd..e69de29bb 100644 --- a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql +++ b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql @@ -1,2 +0,0 @@ -DROP TABLE IF EXISTS lfs_locks; -DROP TABLE IF EXISTS lfs_objects; diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql index 0a43d6849..0fdf70151 100644 --- a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql +++ b/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 ); diff --git a/server/db/migrate/0003_password_tokens.go b/server/db/migrate/0003_password_tokens.go new file mode 100644 index 000000000..2bafd5782 --- /dev/null +++ b/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) + }, +} diff --git a/server/db/migrate/0003_password_tokens_postgres.down.sql b/server/db/migrate/0003_password_tokens_postgres.down.sql new file mode 100644 index 000000000..e9e9b5362 --- /dev/null +++ b/server/db/migrate/0003_password_tokens_postgres.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN password; + diff --git a/server/db/migrate/0003_password_tokens_postgres.up.sql b/server/db/migrate/0003_password_tokens_postgres.up.sql new file mode 100644 index 000000000..41e772b38 --- /dev/null +++ b/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 +); diff --git a/server/db/migrate/0003_password_tokens_sqlite.down.sql b/server/db/migrate/0003_password_tokens_sqlite.down.sql new file mode 100644 index 000000000..aa2562cc0 --- /dev/null +++ b/server/db/migrate/0003_password_tokens_sqlite.down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN password; diff --git a/server/db/migrate/0003_password_tokens_sqlite.up.sql b/server/db/migrate/0003_password_tokens_sqlite.up.sql new file mode 100644 index 000000000..56f2707fa --- /dev/null +++ b/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 +); diff --git a/server/db/migrate/migrate.go b/server/db/migrate/migrate.go index 883f3e631..23242a302 100644 --- a/server/db/migrate/migrate.go +++ b/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") } diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go index 8935ff05c..987a71105 100644 --- a/server/db/migrate/migrations.go +++ b/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 { diff --git a/server/db/models/access_token.go b/server/db/models/access_token.go new file mode 100644 index 000000000..babedef40 --- /dev/null +++ b/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"` +} diff --git a/server/db/models/user.go b/server/db/models/user.go index 8404a9bb4..5ca0d3d9f 100644 --- a/server/db/models/user.go +++ b/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"` } diff --git a/server/git/lfs.go b/server/git/lfs.go index 047c59bbe..1a1ead5a1 100644 --- a/server/git/lfs.go +++ b/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. diff --git a/server/git/lfs_auth.go b/server/git/lfs_auth.go new file mode 100644 index 000000000..0568d2907 --- /dev/null +++ b/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, + }) +} diff --git a/server/git/service.go b/server/git/service.go index 51a53a0c8..fb5dbf5ac 100644 --- a/server/git/service.go +++ b/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) } diff --git a/server/jwk/jwk.go b/server/jwk/jwk.go new file mode 100644 index 000000000..51ea86be8 --- /dev/null +++ b/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 +} diff --git a/server/lfs/common.go b/server/lfs/common.go index 1bd247306..aa98c6527 100644 --- a/server/lfs/common.go +++ b/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 +} diff --git a/server/proto/context.go b/server/proto/context.go new file mode 100644 index 000000000..cd022dcac --- /dev/null +++ b/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) +} diff --git a/server/proto/errors.go b/server/proto/errors.go index b2d12dd07..781ee6b32 100644 --- a/server/proto/errors.go +++ b/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") ) diff --git a/server/proto/repo.go b/server/proto/repo.go index e721d44a2..2d54e9484 100644 --- a/server/proto/repo.go +++ b/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. diff --git a/server/proto/user.go b/server/proto/user.go index f8fd65a14..647b24110 100644 --- a/server/proto/user.go +++ b/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. diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go index d7e3f5ddc..b965409de 100644 --- a/server/ssh/cmd/cmd.go +++ b/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 } diff --git a/server/ssh/cmd/jwt.go b/server/ssh/cmd/jwt.go new file mode 100644 index 000000000..b574889f5 --- /dev/null +++ b/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 +} diff --git a/server/ssh/git.go b/server/ssh/git.go index d2a030f9d..02b59e478 100644 --- a/server/ssh/git.go +++ b/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 diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go index fe6854507..297973ebf 100644 --- a/server/ssh/ssh.go +++ b/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 } diff --git a/server/storage/local.go b/server/storage/local.go index 8a51157af..496f20e37 100644 --- a/server/storage/local.go +++ b/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. diff --git a/server/storage/storage.go b/server/storage/storage.go index dc435dbbb..9f8467262 100644 --- a/server/storage/storage.go +++ b/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 diff --git a/server/store/access_token.go b/server/store/access_token.go new file mode 100644 index 000000000..72440ea2a --- /dev/null +++ b/server/store/access_token.go @@ -0,0 +1 @@ +package store diff --git a/server/store/collab.go b/server/store/collab.go new file mode 100644 index 000000000..430e39d8f --- /dev/null +++ b/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) +} diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go index 0233dec7a..64fef3716 100644 --- a/server/store/database/lfs.go +++ b/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) } diff --git a/server/store/database/user.go b/server/store/database/user.go index 9ca824e62..c5b50681b 100644 --- a/server/store/database/user.go +++ b/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 +} diff --git a/server/store/lfs.go b/server/store/lfs.go index 7632d2472..067285eac 100644 --- a/server/store/lfs.go +++ b/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 } diff --git a/server/store/repo.go b/server/store/repo.go new file mode 100644 index 000000000..c64eb08ee --- /dev/null +++ b/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) +} diff --git a/server/store/settings.go b/server/store/settings.go new file mode 100644 index 000000000..75dd9d7af --- /dev/null +++ b/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 +} diff --git a/server/store/store.go b/server/store/store.go index dcaa3165e..9dff3ffcc 100644 --- a/server/store/store.go +++ b/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 diff --git a/server/store/user.go b/server/store/user.go new file mode 100644 index 000000000..b63f53a8f --- /dev/null +++ b/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 +} diff --git a/server/web/auth.go b/server/web/auth.go new file mode 100644 index 000000000..067108fe4 --- /dev/null +++ b/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 +} diff --git a/server/web/context.go b/server/web/context.go index d0a7879af..a527b9da0 100644 --- a/server/web/context.go +++ b/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) }) diff --git a/server/web/git.go b/server/web/git.go index 36e36d1be..b781f6431 100644 --- a/server/web/git.go +++ b/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 diff --git a/server/web/git_lfs.go b/server/web/git_lfs.go new file mode 100644 index 000000000..f243ab973 --- /dev/null +++ b/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: /.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: /.git/info/lfs/objects/basic/ +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: /.git/info/lfs/objects/basic/ +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: /.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: /.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: /.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: /.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: /.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) +} diff --git a/server/web/http.go b/server/web/http.go index 538e9fa37..e4588c45d 100644 --- a/server/web/http.go +++ b/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}), }, } diff --git a/server/web/server.go b/server/web/server.go index 95a8d7df8..44c913c0c 100644 --- a/server/web/server.go +++ b/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 diff --git a/server/web/util.go b/server/web/util.go new file mode 100644 index 000000000..2c68d6991 --- /dev/null +++ b/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 + } +} diff --git a/testscript/testdata/help.txtar b/testscript/testdata/help.txtar index d65bcfb08..64a7c7e6e 100644 --- a/testscript/testdata/help.txtar +++ b/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 diff --git a/testscript/testdata/repo-perms.txtar b/testscript/testdata/repo-perms.txtar index 3d682b6fc..bb3e423ac 100644 --- a/testscript/testdata/repo-perms.txtar +++ b/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 diff --git a/testscript/testdata/repo-tree.txtar b/testscript/testdata/repo-tree.txtar index 50a328325..0ccef0b9f 100644 --- a/testscript/testdata/repo-tree.txtar +++ b/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