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