f69f06422af623b37da2ed91a6672c84763fa7dd
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/cmd/soft/hook.go b/cmd/soft/hook.go
2index fdfbaed46d1b441a5f89f8412a90b2e2464c7b0a..1cbbf511e7d0786e06513f3000a3737230fe7c61 100644
3--- a/cmd/soft/hook.go
4+++ b/cmd/soft/hook.go
5@@ -4,6 +4,7 @@ import (
6 "bufio"
7 "bytes"
8 "context"
9+ "errors"
10 "fmt"
11 "io"
12 "os"
13@@ -11,6 +12,7 @@ import (
14 "path/filepath"
15 "strings"
16
17+ "github.com/charmbracelet/log"
18 "github.com/charmbracelet/soft-serve/server/backend"
19 "github.com/charmbracelet/soft-serve/server/config"
20 "github.com/charmbracelet/soft-serve/server/hooks"
21@@ -18,16 +20,35 @@ import (
22 )
23
24 var (
25+ // ErrInternalServerError indicates that an internal server error occurred.
26+ ErrInternalServerError = errors.New("internal server error")
27+
28 // Deprecated: this flag is ignored.
29 configPath string
30
31 hookCmd = &cobra.Command{
32- Use: "hook",
33- Short: "Run git server hooks",
34- Long: "Handles Soft Serve git server hooks.",
35- Hidden: true,
36- PersistentPreRunE: initBackendContext,
37- PersistentPostRunE: closeDBContext,
38+ Use: "hook",
39+ Short: "Run git server hooks",
40+ Long: "Handles Soft Serve git server hooks.",
41+ Hidden: true,
42+ PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
43+ logger := log.FromContext(cmd.Context())
44+ if err := initBackendContext(cmd, args); err != nil {
45+ logger.Error("failed to initialize backend context", "err", err)
46+ return ErrInternalServerError
47+ }
48+
49+ return nil
50+ },
51+ PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
52+ logger := log.FromContext(cmd.Context())
53+ if err := closeDBContext(cmd, args); err != nil {
54+ logger.Error("failed to close backend", "err", err)
55+ return ErrInternalServerError
56+ }
57+
58+ return nil
59+ },
60 }
61
62 // Git hooks read the config from the environment, based on
63diff --git a/cmd/soft/root.go b/cmd/soft/root.go
64index 7f03982d437c09440c5c65e0932bd3c9af294f9f..9e443b5b39a77ad3028c47ce57c6ddc94146c387 100644
65--- a/cmd/soft/root.go
66+++ b/cmd/soft/root.go
67@@ -2,7 +2,9 @@ package main
68
69 import (
70 "context"
71+ "errors"
72 "fmt"
73+ "io/fs"
74 "os"
75 "runtime/debug"
76 "strings"
77@@ -146,6 +148,11 @@ func newDefaultLogger(cfg *config.Config) (*log.Logger, *os.File, error) {
78 func initBackendContext(cmd *cobra.Command, _ []string) error {
79 ctx := cmd.Context()
80 cfg := config.FromContext(ctx)
81+ if _, err := os.Stat(cfg.DataPath); errors.Is(err, fs.ErrNotExist) {
82+ if err := os.MkdirAll(cfg.DataPath, os.ModePerm); err != nil {
83+ return fmt.Errorf("create data directory: %w", err)
84+ }
85+ }
86 dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
87 if err != nil {
88 return fmt.Errorf("open database: %w", err)
89diff --git a/go.mod b/go.mod
90index 0a9d11dcdd3532e6ac2c27621cc67f3a1a04a057..7c03a19ecdb9251d50427acff535e87aec4443c3 100644
91--- a/go.mod
92+++ b/go.mod
93@@ -19,12 +19,14 @@ require (
94
95 require (
96 github.com/caarlos0/env/v8 v8.0.0
97- github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1
98+ github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245
99 github.com/charmbracelet/keygen v0.4.3
100- github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35
101+ github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2
102 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155
103+ github.com/go-jose/go-jose/v3 v3.0.0
104 github.com/gobwas/glob v0.2.3
105 github.com/gogs/git-module v1.8.2
106+ github.com/golang-jwt/jwt/v5 v5.0.0
107 github.com/hashicorp/golang-lru/v2 v2.0.4
108 github.com/jmoiron/sqlx v1.3.5
109 github.com/lib/pq v1.10.9
110@@ -47,6 +49,7 @@ require (
111 require (
112 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
113 github.com/atotto/clipboard v0.1.4 // indirect
114+ github.com/avast/retry-go v3.0.0+incompatible // indirect
115 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
116 github.com/aymerick/douceur v0.2.0 // indirect
117 github.com/beorn7/perks v1.0.1 // indirect
118@@ -54,7 +57,10 @@ require (
119 github.com/cespare/xxhash/v2 v2.2.0 // indirect
120 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
121 github.com/dlclark/regexp2 v1.4.0 // indirect
122+ github.com/git-lfs/git-lfs/v3 v3.3.0 // indirect
123+ github.com/git-lfs/gitobj/v2 v2.1.1 // indirect
124 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
125+ github.com/git-lfs/wildmatch/v2 v2.0.1 // indirect
126 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
127 github.com/go-logfmt/logfmt v0.6.0 // indirect
128 github.com/golang/protobuf v1.5.3 // indirect
129@@ -62,6 +68,7 @@ require (
130 github.com/gorilla/css v1.0.0 // indirect
131 github.com/inconshreveable/mousetrap v1.1.0 // indirect
132 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
133+ github.com/leonelquinteros/gotext v1.5.2 // indirect
134 github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
135 github.com/mattn/go-isatty v0.0.18 // indirect
136 github.com/mattn/go-localereader v0.0.1 // indirect
137@@ -74,21 +81,22 @@ require (
138 github.com/muesli/mango v0.1.0 // indirect
139 github.com/muesli/mango-pflag v0.1.0 // indirect
140 github.com/olekukonko/tablewriter v0.0.5 // indirect
141+ github.com/pkg/errors v0.9.1 // indirect
142 github.com/prometheus/client_model v0.3.0 // indirect
143 github.com/prometheus/common v0.42.0 // indirect
144 github.com/prometheus/procfs v0.10.1 // indirect
145 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
146- github.com/rivo/uniseg v0.2.0 // indirect
147+ github.com/rivo/uniseg v0.4.3 // indirect
148 github.com/sahilm/fuzzy v0.1.0 // indirect
149 github.com/spf13/pflag v1.0.5 // indirect
150 github.com/yuin/goldmark v1.5.2 // indirect
151 github.com/yuin/goldmark-emoji v1.0.1 // indirect
152- golang.org/x/mod v0.9.0 // indirect
153+ golang.org/x/mod v0.10.0 // indirect
154 golang.org/x/net v0.12.0 // indirect
155 golang.org/x/sys v0.10.0 // indirect
156 golang.org/x/term v0.10.0 // indirect
157 golang.org/x/text v0.11.0 // indirect
158- golang.org/x/tools v0.6.0 // indirect
159+ golang.org/x/tools v0.9.1 // indirect
160 google.golang.org/protobuf v1.30.0 // indirect
161 gopkg.in/warnings.v0 v0.1.2 // indirect
162 lukechampine.com/uint128 v1.2.0 // indirect
163diff --git a/go.sum b/go.sum
164index 7846d7c62bcc53de401544721db60589731d64ac..bf526c8d886dab318bd9fce79cc26acffa7f9ce7 100644
165--- a/go.sum
166+++ b/go.sum
167@@ -1,9 +1,16 @@
168+github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
169+github.com/acomagu/bufpipe v1.0.4/go.mod h1:mxdxdup/WdsKVreO5GpW4+M/1CE2sMG4jeGJ2sYmHc4=
170 github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
171 github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
172+github.com/alexbrainman/sspi v0.0.0-20210105120005-909beea2cc74/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
173 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
174 github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
175+github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
176 github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
177 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
178+github.com/avast/retry-go v2.4.2+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
179+github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0=
180+github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY=
181 github.com/aymanbagabas/go-osc52 v1.0.3/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4=
182 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
183 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
184@@ -21,8 +28,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5
185 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
186 github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
187 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
188-github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1 h1:/QzZzTDdlDYGZeC2O2y/Qw+AiHqh3vCsO4yrKDWXtqs=
189-github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
190+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245 h1:PeGKqKX84IAFhFSWjTyPGiLzzEPcv94C9qKsYBk2nbQ=
191+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230725143853-5dd0632f9245/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
192 github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
193 github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
194 github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgyyO1Mg=
195@@ -31,6 +38,8 @@ github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZ
196 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
197 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
198 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
199+github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2 h1:0O3FNIElGsbl/nnUpeUVHqET7ZETJz6cUQocn/CKhoU=
200+github.com/charmbracelet/log v0.2.3-0.20230725142510-280c4e3f1ef2/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
201 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
202 github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
203 github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4=
204@@ -43,14 +52,28 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
205 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
206 github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
207 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
208+github.com/dpotapov/go-spnego v0.0.0-20210315154721-298b63a54430/go.mod h1:AVSs/gZKt1bOd2AhkhbS7Qh56Hv7klde22yXVbwYJhc=
209 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
210 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
211+github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
212+github.com/git-lfs/git-lfs/v3 v3.3.0 h1:cbRy9akD9/hDD7BaVifyNkWkURwC8RSPLzX9+siS+OE=
213+github.com/git-lfs/git-lfs/v3 v3.3.0/go.mod h1:5y2vfVQpxUmceMlraOmmaQ83pYptQYCvPl32ybO2IVw=
214+github.com/git-lfs/gitobj/v2 v2.1.1 h1:tf/VU6zL1kxa3he+nf6FO/syX+LGkm6WGDsMpfuXV7Q=
215+github.com/git-lfs/gitobj/v2 v2.1.1/go.mod h1:q6aqxl6Uu3gWsip5GEKpw+7459F97er8COmU45ncAxw=
216+github.com/git-lfs/go-netrc v0.0.0-20210914205454-f0c862dd687a/go.mod h1:70O4NAtvWn1jW8V8V+OKrJJYcxDLTmIozfi2fmSz5SI=
217+github.com/git-lfs/pktline v0.0.0-20210330133718-06e9096e2825/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
218 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
219 github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
220+github.com/git-lfs/wildmatch/v2 v2.0.1 h1:Ds+aobrV5bK0wStILUOn9irllPyf9qrFETbKzwzoER8=
221+github.com/git-lfs/wildmatch/v2 v2.0.1/go.mod h1:EVqonpk9mXbREP3N8UkwoWdrF249uHpCUo5CPXY81gw=
222+github.com/gliderlabs/ssh v0.3.5/go.mod h1:8XB4KraRrX39qHhT6yxPsHedjA08I/uBVwj4xC+/+z4=
223 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
224 github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
225+github.com/go-git/go-billy/v5 v5.4.1/go.mod h1:vjbugF6Fz7JIflbVpl1hJsGjSHNltrSw45YK/ukIvQg=
226 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
227 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
228+github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
229+github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
230 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
231 github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
232 github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
233@@ -59,32 +82,56 @@ github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
234 github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
235 github.com/gogs/git-module v1.8.2 h1:fwvSMjc51d5bBG3Q2OyFF8HTZFDVbETQ+mSAvfXebQw=
236 github.com/gogs/git-module v1.8.2/go.mod h1:GUSSUH+RM7fZOtjhS6Obh4B9aAvs3EeROpazfMNMF8g=
237+github.com/golang-jwt/jwt/v5 v5.0.0 h1:1n1XNM9hk7O9mnQoNBGolZvzebBQ7p93ULHRc28XJUE=
238+github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
239 github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
240 github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
241 github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
242 github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
243 github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
244+github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
245 github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
246 github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
247+github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
248 github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
249 github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
250 github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
251 github.com/gorilla/css v1.0.0 h1:BQqNyPTi50JCFMTw/b67hByjMVXZRwGha6wxVGkeihY=
252 github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl60c=
253+github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
254+github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
255+github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
256 github.com/hashicorp/golang-lru/v2 v2.0.4 h1:7GHuZcgid37q8o5i3QI9KMT4nCWQQ3Kx3Ov6bb9MfK0=
257 github.com/hashicorp/golang-lru/v2 v2.0.4/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
258+github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
259 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
260 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
261+github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
262+github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
263+github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
264+github.com/jcmturner/gofork v1.0.0/go.mod h1:MK8+TM0La+2rjBD4jE12Kj1pCCxK7d2LK/UM3ncEo0o=
265+github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
266+github.com/jcmturner/gokrb5/v8 v8.4.2/go.mod h1:sb+Xq/fTY5yktf/VxLsE3wlfPqQjp0aWNYyvBVK62bc=
267diff --git a/server/access/context.go b/server/access/context.go
268new file mode 100644
269index 0000000000000000000000000000000000000000..3bc08c011eca9066c25442367ecdf5e3e53a2d68
270--- /dev/null
271+++ b/server/access/context.go
272@@ -0,0 +1,20 @@
273+package access
274+
275+import "context"
276+
277+// ContextKey is the context key for the access level.
278+var ContextKey = &struct{ string }{"access"}
279+
280+// FromContext returns the access level from the context.
281+func FromContext(ctx context.Context) AccessLevel {
282+ if ac, ok := ctx.Value(ContextKey).(AccessLevel); ok {
283+ return ac
284+ }
285+
286+ return -1
287+}
288+
289+// WithContext returns a new context with the access level.
290+func WithContext(ctx context.Context, ac AccessLevel) context.Context {
291+ return context.WithValue(ctx, ContextKey, ac)
292+}
293diff --git a/server/backend/auth.go b/server/backend/auth.go
294new file mode 100644
295index 0000000000000000000000000000000000000000..62160fccb22b349e5730de8945956123c7b53ee7
296--- /dev/null
297+++ b/server/backend/auth.go
298@@ -0,0 +1,38 @@
299+package backend
300+
301+import (
302+ "crypto/rand"
303+ "encoding/hex"
304+
305+ "github.com/charmbracelet/log"
306+ "golang.org/x/crypto/bcrypt"
307+)
308+
309+const saltySalt = "salty-soft-serve"
310+
311+// HashPassword hashes the password using bcrypt.
312+func HashPassword(password string) (string, error) {
313+ crypt, err := bcrypt.GenerateFromPassword([]byte(password+saltySalt), bcrypt.DefaultCost)
314+ if err != nil {
315+ return "", err
316+ }
317+
318+ return string(crypt), nil
319+}
320+
321+// VerifyPassword verifies the password against the hash.
322+func VerifyPassword(password, hash string) bool {
323+ err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password+saltySalt))
324+ return err == nil
325+}
326+
327+// GenerateAccessToken returns a random unique token.
328+func GenerateAccessToken() string {
329+ buf := make([]byte, 20)
330+ if _, err := rand.Read(buf); err != nil {
331+ log.Error("unable to generate access token")
332+ return ""
333+ }
334+
335+ return "ss_" + hex.EncodeToString(buf)
336+}
337diff --git a/server/backend/collab.go b/server/backend/collab.go
338index 92bfca821854554fc7e879474da2a21ac2fef8e4..78c5b8a230ab285875953e62aeb273d69eef0381 100644
339--- a/server/backend/collab.go
340+++ b/server/backend/collab.go
341@@ -52,6 +52,10 @@ func (d *Backend) Collaborators(ctx context.Context, repo string) ([]string, err
342 //
343 // It implements backend.Backend.
344 func (d *Backend) IsCollaborator(ctx context.Context, repo string, username string) (bool, error) {
345+ if username == "" {
346+ return false, nil
347+ }
348+
349 repo = utils.SanitizeRepo(repo)
350 var m models.Collab
351 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
352diff --git a/server/backend/hooks.go b/server/backend/hooks.go
353index b28c5ecd78bf8a20d30f26cd0b4fe10adda3bae1..fe10432edb74abc72279afb6cdd02d1ef2700ea1 100644
354--- a/server/backend/hooks.go
355+++ b/server/backend/hooks.go
356@@ -63,7 +63,7 @@ func populateLastModified(ctx context.Context, d *Backend, name string) error {
357 if r, ok := _rr.(*repo); ok {
358 rr = r
359 } else {
360- return proto.ErrRepoNotExist
361+ return proto.ErrRepoNotFound
362 }
363
364 r, err := rr.Open()
365diff --git a/server/backend/lfs.go b/server/backend/lfs.go
366index dfc21ea69d5eb9193d347b39011591f4fd9c9367..eac557ecf07fee83ba8fef0216a8e1074e36e4e4 100644
367--- a/server/backend/lfs.go
368+++ b/server/backend/lfs.go
369@@ -43,7 +43,8 @@ func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx
370 return db.WrapError(err)
371 }
372
373- return strg.Put(path.Join("objects", p.RelativePath()), content)
374+ _, err := strg.Put(path.Join("objects", p.RelativePath()), content)
375+ return err
376 })
377 })
378 }
379diff --git a/server/backend/repo.go b/server/backend/repo.go
380index 15eaea6ff72ea919d66ff897633471373a7c6048..840385112d5703055ac7ce73087ae55b959fd9d7 100644
381--- a/server/backend/repo.go
382+++ b/server/backend/repo.go
383@@ -216,7 +216,7 @@ func (d *Backend) RenameRepository(ctx context.Context, oldName string, newName
384 op := filepath.Join(d.reposPath(), oldRepo)
385 np := filepath.Join(d.reposPath(), newRepo)
386 if _, err := os.Stat(op); err != nil {
387- return proto.ErrRepoNotExist
388+ return proto.ErrRepoNotFound
389 }
390
391 if _, err := os.Stat(np); err == nil {
392@@ -290,14 +290,20 @@ func (d *Backend) Repository(ctx context.Context, name string) (proto.Repository
393
394 rp := filepath.Join(d.reposPath(), name+".git")
395 if _, err := os.Stat(rp); err != nil {
396- return nil, os.ErrNotExist
397+ if !errors.Is(err, fs.ErrNotExist) {
398+ d.logger.Errorf("failed to stat repository path: %v", err)
399+ }
400+ return nil, proto.ErrRepoNotFound
401 }
402
403 if err := d.db.TransactionContext(ctx, func(tx *db.Tx) error {
404 var err error
405 m, err = d.store.GetRepoByName(ctx, tx, name)
406- return err
407+ return db.WrapError(err)
408 }); err != nil {
409+ if errors.Is(err, db.ErrRecordNotFound) {
410+ return nil, proto.ErrRepoNotFound
411+ }
412 return nil, db.WrapError(err)
413 }
414
415diff --git a/server/backend/user.go b/server/backend/user.go
416index 8b5e2a2aeae549bec6d1f19dec0a5e0d00685bbd..ff07660b0f88056c56e723704e849a1dad6095eb 100644
417--- a/server/backend/user.go
418+++ b/server/backend/user.go
419@@ -2,6 +2,7 @@ package backend
420
421 import (
422 "context"
423+ "errors"
424 "strings"
425
426 "github.com/charmbracelet/soft-serve/server/access"
427@@ -40,6 +41,7 @@ func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ss
428 }
429
430 // AccessLevelForUser returns the access level of a user for a repository.
431+// TODO: user repository ownership
432 func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel {
433 var username string
434 anon := d.AnonAccess(ctx)
435@@ -53,7 +55,11 @@ func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user prot
436 }
437
438 // If the repository exists, check if the user is a collaborator.
439- r, _ := d.Repository(ctx, repo)
440+ r := proto.RepositoryFromContext(ctx)
441+ if r == nil {
442+ r, _ = d.Repository(ctx, repo)
443+ }
444+
445 if r != nil {
446 // If the user is a collaborator, they have read/write access.
447 isCollab, _ := d.IsCollaborator(ctx, repo, username)
448@@ -107,7 +113,11 @@ func (d *Backend) User(ctx context.Context, username string) (proto.User, error)
449 pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
450 return err
451 }); err != nil {
452- return nil, db.WrapError(err)
453+ err = db.WrapError(err)
454+ if errors.Is(err, db.ErrRecordNotFound) {
455+ return nil, proto.ErrUserNotFound
456+ }
457+ return nil, err
458 }
459
460 return &user{
461@@ -126,13 +136,17 @@ func (d *Backend) UserByPublicKey(ctx context.Context, pk ssh.PublicKey) (proto.
462 var err error
463 m, err = d.store.FindUserByPublicKey(ctx, tx, pk)
464 if err != nil {
465- return err
466+ return db.WrapError(err)
467 }
468
469 pks, err = d.store.ListPublicKeysByUserID(ctx, tx, m.ID)
470 return err
471 }); err != nil {
472- return nil, db.WrapError(err)
473+ err = db.WrapError(err)
474+ if errors.Is(err, db.ErrRecordNotFound) {
475+ return nil, proto.ErrUserNotFound
476+ }
477+ return nil, err
478 }
479
480 return &user{
481@@ -276,6 +290,25 @@ func (d *Backend) SetAdmin(ctx context.Context, username string, admin bool) err
482 )
483 }
484
485+// SetPassword sets the password of a user.
486+func (d *Backend) SetPassword(ctx context.Context, username string, rawPassword string) error {
487+ username = strings.ToLower(username)
488+ if err := utils.ValidateUsername(username); err != nil {
489+ return err
490+ }
491+
492+ password, err := HashPassword(rawPassword)
493+ if err != nil {
494+ return err
495+ }
496+
497+ return db.WrapError(
498+ d.db.TransactionContext(ctx, func(tx *db.Tx) error {
499+ return d.store.SetUserPasswordByUsername(ctx, tx, username, password)
500+ }),
501+ )
502+}
503+
504 type user struct {
505 user models.User
506 publicKeys []ssh.PublicKey
507diff --git a/server/config/ssh.go b/server/config/ssh.go
508new file mode 100644
509index 0000000000000000000000000000000000000000..102b391418ac86f1d0f963fc7701591bdd233ce0
510--- /dev/null
511+++ b/server/config/ssh.go
512@@ -0,0 +1,8 @@
513+package config
514+
515+import "github.com/charmbracelet/keygen"
516+
517+// KeyPair returns the server's SSH key pair.
518+func (c SSHConfig) KeyPair() (*keygen.SSHKeyPair, error) {
519+ return keygen.New(c.KeyPath, keygen.WithKeyType(keygen.Ed25519))
520+}
521diff --git a/server/db/errors.go b/server/db/errors.go
522index f793a0588a94c7c2461da50aaf87060ac724d923..9c19b028261f699da9101ea0b6a7b72ce4164a87 100644
523--- a/server/db/errors.go
524+++ b/server/db/errors.go
525@@ -14,7 +14,7 @@ var (
526 ErrDuplicateKey = errors.New("duplicate key value violates table constraint")
527
528 // ErrRecordNotFound is returned when a record is not found.
529- ErrRecordNotFound = errors.New("record not found")
530+ ErrRecordNotFound = sql.ErrNoRows
531 )
532
533 // WrapError is a convenient function that unite various database driver
534diff --git a/server/db/migrate/0001_create_tables_postgres.down.sql b/server/db/migrate/0001_create_tables_postgres.down.sql
535index 35eeb70dedeae7d862935e287a9ab3516d56b674..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
536--- a/server/db/migrate/0001_create_tables_postgres.down.sql
537+++ b/server/db/migrate/0001_create_tables_postgres.down.sql
538@@ -1,5 +0,0 @@
539-DROP TABLE IF EXISTS collabs;
540-DROP TABLE IF EXISTS repos;
541-DROP TABLE IF EXISTS public_keys;
542-DROP TABLE IF EXISTS users;
543-DROP TABLE IF EXISTS settings;
544diff --git a/server/db/migrate/0001_create_tables_sqlite.down.sql b/server/db/migrate/0001_create_tables_sqlite.down.sql
545index 35eeb70dedeae7d862935e287a9ab3516d56b674..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
546--- a/server/db/migrate/0001_create_tables_sqlite.down.sql
547+++ b/server/db/migrate/0001_create_tables_sqlite.down.sql
548@@ -1,5 +0,0 @@
549-DROP TABLE IF EXISTS collabs;
550-DROP TABLE IF EXISTS repos;
551-DROP TABLE IF EXISTS public_keys;
552-DROP TABLE IF EXISTS users;
553-DROP TABLE IF EXISTS settings;
554diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
555index bae6ea0cd13f5ccf90114fb2e05146bb822a6658..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
556--- a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
557+++ b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
558@@ -1,2 +0,0 @@
559-DROP TABLE IF EXISTS lfs_locks;
560-DROP TABLE IF EXISTS lfs_objects;
561diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
562index fed48900f876e09a5777966c82bf088f41809821..36391d30a3add57cd9c666cfa8e52135797ae8f3 100644
563--- a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
564+++ b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
565@@ -3,8 +3,8 @@ CREATE TABLE IF NOT EXISTS lfs_objects (
566 oid TEXT NOT NULL,
567 size INTEGER NOT NULL,
568 repo_id INTEGER NOT NULL,
569- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
570- updated_at DATETIME NOT NULL,
571+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
572+ updated_at TIMESTAMP NOT NULL,
573 UNIQUE (oid, repo_id),
574 CONSTRAINT repo_id_fk
575 FOREIGN KEY(repo_id) REFERENCES repos(id)
576@@ -18,11 +18,15 @@ CREATE TABLE IF NOT EXISTS lfs_locks (
577 user_id INTEGER NOT NULL,
578 path TEXT NOT NULL,
579 refname TEXT,
580- created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
581- updated_at DATETIME NOT NULL,
582+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
583+ updated_at TIMESTAMP NOT NULL,
584 UNIQUE (repo_id, path),
585 CONSTRAINT repo_id_fk
586 FOREIGN KEY(repo_id) REFERENCES repos(id)
587 ON DELETE CASCADE
588+ ON UPDATE CASCADE,
589+ CONSTRAINT user_id_fk
590+ FOREIGN KEY(user_id) REFERENCES users(id)
591+ ON DELETE CASCADE
592 ON UPDATE CASCADE
593 );
594diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
595index bae6ea0cd13f5ccf90114fb2e05146bb822a6658..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644
596--- a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
597+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
598@@ -1,2 +0,0 @@
599-DROP TABLE IF EXISTS lfs_locks;
600-DROP TABLE IF EXISTS lfs_objects;
601diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
602index 0a43d6849340b93cb0783b1c560128ef005cb83c..0fdf70151afc1b4fe083c96c229ac83591dbf215 100644
603--- a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
604+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
605@@ -24,5 +24,9 @@ CREATE TABLE IF NOT EXISTS lfs_locks (
606 CONSTRAINT repo_id_fk
607 FOREIGN KEY(repo_id) REFERENCES repos(id)
608 ON DELETE CASCADE
609+ ON UPDATE CASCADE,
610+ CONSTRAINT user_id_fk
611+ FOREIGN KEY(user_id) REFERENCES users(id)
612+ ON DELETE CASCADE
613 ON UPDATE CASCADE
614 );
615diff --git a/server/db/migrate/0003_password_tokens.go b/server/db/migrate/0003_password_tokens.go
616new file mode 100644
617index 0000000000000000000000000000000000000000..2bafd57823490d225a3c394d06cbb08daade9b7f
618--- /dev/null
619+++ b/server/db/migrate/0003_password_tokens.go
620@@ -0,0 +1,23 @@
621+package migrate
622+
623+import (
624+ "context"
625+
626+ "github.com/charmbracelet/soft-serve/server/db"
627+)
628+
629+const (
630+ passwordTokensName = "password tokens"
631+ passwordTokensVersion = 3
632+)
633+
634+var passwordTokens = Migration{
635+ Version: passwordTokensVersion,
636+ Name: passwordTokensName,
637+ Migrate: func(ctx context.Context, tx *db.Tx) error {
638+ return migrateUp(ctx, tx, passwordTokensVersion, passwordTokensName)
639+ },
640+ Rollback: func(ctx context.Context, tx *db.Tx) error {
641+ return migrateDown(ctx, tx, passwordTokensVersion, passwordTokensName)
642+ },
643+}
644diff --git a/server/db/migrate/0003_password_tokens_postgres.down.sql b/server/db/migrate/0003_password_tokens_postgres.down.sql
645new file mode 100644
646index 0000000000000000000000000000000000000000..e9e9b5362eac0c0902f547ebaa41b7f8877a6834
647--- /dev/null
648+++ b/server/db/migrate/0003_password_tokens_postgres.down.sql
649@@ -0,0 +1,2 @@
650+ALTER TABLE users DROP COLUMN password;
651+
652diff --git a/server/db/migrate/0003_password_tokens_postgres.up.sql b/server/db/migrate/0003_password_tokens_postgres.up.sql
653new file mode 100644
654index 0000000000000000000000000000000000000000..41e772b38f5a0bcced886a07e259263001a8db8b
655--- /dev/null
656+++ b/server/db/migrate/0003_password_tokens_postgres.up.sql
657@@ -0,0 +1,15 @@
658+ALTER TABLE users ADD COLUMN password TEXT;
659+
660+CREATE TABLE IF NOT EXISTS access_tokens (
661+ id SERIAL PRIMARY KEY,
662+ name text NOT NULL,
663+ token TEXT NOT NULL UNIQUE,
664+ user_id INTEGER NOT NULL,
665+ expires_at TIMESTAMP,
666+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
667+ updated_at TIMESTAMP NOT NULL,
668+ CONSTRAINT user_id_fk
669+ FOREIGN KEY (user_id) REFERENCES users(id)
670+ ON DELETE CASCADE
671+ ON UPDATE CASCADE
672+);
673diff --git a/server/db/migrate/0003_password_tokens_sqlite.down.sql b/server/db/migrate/0003_password_tokens_sqlite.down.sql
674new file mode 100644
675index 0000000000000000000000000000000000000000..aa2562cc04f86147ca60f82aae4e5734f9cd4443
676--- /dev/null
677+++ b/server/db/migrate/0003_password_tokens_sqlite.down.sql
678@@ -0,0 +1 @@
679+ALTER TABLE users DROP COLUMN password;
680diff --git a/server/db/migrate/0003_password_tokens_sqlite.up.sql b/server/db/migrate/0003_password_tokens_sqlite.up.sql
681new file mode 100644
682index 0000000000000000000000000000000000000000..56f2707fa61723d498a5cf54a6d7eb6c688f90ef
683--- /dev/null
684+++ b/server/db/migrate/0003_password_tokens_sqlite.up.sql
685@@ -0,0 +1,15 @@
686+ALTER TABLE users ADD COLUMN password TEXT;
687+
688+CREATE TABLE IF NOT EXISTS access_tokens (
689+ id INTEGER primary key autoincrement,
690+ token text NOT NULL UNIQUE,
691+ name text NOT NULL,
692+ user_id INTEGER NOT NULL,
693+ expires_at DATETIME,
694+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
695+ updated_at DATETIME NOT NULL,
696+ CONSTRAINT user_id_fk
697+ FOREIGN KEY (user_id) REFERENCES users(id)
698+ ON DELETE CASCADE
699+ ON UPDATE CASCADE
700+);
701diff --git a/server/db/migrate/migrate.go b/server/db/migrate/migrate.go
702index 883f3e631c05d2c72960c6e5edeab13be90e1e99..23242a302223f4b68a7d7250759d5f5d9d316ce2 100644
703--- a/server/db/migrate/migrate.go
704+++ b/server/db/migrate/migrate.go
705@@ -106,7 +106,7 @@ func Rollback(ctx context.Context, dbx *db.DB) error {
706 }
707 }
708
709- if len(migrations) < int(migrs.Version) {
710+ if migrs.Version == 0 || len(migrations) < int(migrs.Version) {
711 return fmt.Errorf("there are no migrations to rollback")
712 }
713
714diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
715index 8935ff05c8bff39197a5514f680a4ad91f85d61b..987a71105c0b51651a1d6151e790a16069aa1d21 100644
716--- a/server/db/migrate/migrations.go
717+++ b/server/db/migrate/migrations.go
718@@ -17,6 +17,7 @@ var sqls embed.FS
719 var migrations = []Migration{
720 createTables,
721 createLFSTables,
722+ passwordTokens,
723 }
724
725 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
726diff --git a/server/db/models/access_token.go b/server/db/models/access_token.go
727new file mode 100644
728index 0000000000000000000000000000000000000000..babedef403f24258f9bec2a3fc4ec71e1da8ce69
729--- /dev/null
730+++ b/server/db/models/access_token.go
731@@ -0,0 +1,17 @@
732+package models
733+
734+import (
735+ "database/sql"
736+ "time"
737+)
738+
739+// AccessToken represents an access token.
740+type AccessToken struct {
741+ ID int64 `db:"id"`
742+ Name string `db:"name"`
743+ UserID int64 `db:"user_id"`
744+ Token string `db:"token"`
745+ ExpiresAt sql.NullTime `db:"expires_at"`
746+ CreatedAt time.Time `db:"created_at"`
747+ UpdatedAt time.Time `db:"updated_at"`
748+}
749diff --git a/server/db/models/user.go b/server/db/models/user.go
750index 8404a9bb4473738fc1d21e9ce4340a88ae0ff92d..5ca0d3d9f76f7338e35bddb8aa1df29c614f3715 100644
751--- a/server/db/models/user.go
752+++ b/server/db/models/user.go
753@@ -1,12 +1,16 @@
754 package models
755
756-import "time"
757+import (
758+ "database/sql"
759+ "time"
760+)
761
762 // User represents a user.
763 type User struct {
764- ID int64 `db:"id"`
765- Username string `db:"username"`
766- Admin bool `db:"admin"`
767- CreatedAt time.Time `db:"created_at"`
768- UpdatedAt time.Time `db:"updated_at"`
769+ ID int64 `db:"id"`
770+ Username string `db:"username"`
771+ Admin bool `db:"admin"`
772+ Password sql.NullString `db:"password"`
773+ CreatedAt time.Time `db:"created_at"`
774+ UpdatedAt time.Time `db:"updated_at"`
775 }
776diff --git a/server/git/lfs.go b/server/git/lfs.go
777index 047c59bbe66ee5896be423bf1342e5c407136351..1a1ead5a1ffb65cd18f084fc17a702c036e7e4b4 100644
778--- a/server/git/lfs.go
779+++ b/server/git/lfs.go
780@@ -10,18 +10,18 @@ import (
781 "path"
782 "path/filepath"
783 "strconv"
784+ "strings"
785 "time"
786
787 "github.com/charmbracelet/git-lfs-transfer/transfer"
788 "github.com/charmbracelet/log"
789- "github.com/charmbracelet/soft-serve/server/backend"
790 "github.com/charmbracelet/soft-serve/server/config"
791 "github.com/charmbracelet/soft-serve/server/db"
792 "github.com/charmbracelet/soft-serve/server/db/models"
793+ "github.com/charmbracelet/soft-serve/server/lfs"
794 "github.com/charmbracelet/soft-serve/server/proto"
795 "github.com/charmbracelet/soft-serve/server/storage"
796 "github.com/charmbracelet/soft-serve/server/store"
797- "github.com/charmbracelet/soft-serve/server/utils"
798 "github.com/rubyist/tracerx"
799 )
800
801@@ -56,20 +56,18 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
802 return errors.New("missing args")
803 }
804
805- logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
806- handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
807- be := backend.FromContext(ctx)
808- repoName := cmd.Args[0]
809- repoName = utils.SanitizeRepo(repoName)
810 op := cmd.Args[1]
811-
812- repo, err := be.Repository(ctx, repoName)
813- if err != nil {
814- logger.Errorf("error getting repo: %v", err)
815- return err
816+ if op != lfs.OperationDownload && op != lfs.OperationUpload {
817+ return errors.New("invalid operation")
818 }
819
820- ctx = context.WithValue(ctx, proto.ContextKeyRepository, repo)
821+ logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
822+ handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
823+ repo := proto.RepositoryFromContext(ctx)
824+ if repo == nil {
825+ logger.Error("no repository in context")
826+ return proto.ErrRepoNotFound
827+ }
828
829 // Advertise capabilities.
830 for _, cap := range []string{
831@@ -102,15 +100,10 @@ func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
832 }
833
834 // Batch implements transfer.Backend.
835-func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.BatchItem, error) {
836- repo, ok := t.ctx.Value(proto.ContextKeyRepository).(proto.Repository)
837- if !ok {
838- return nil, errors.New("no repository in context")
839- }
840-
841+func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer, _ map[string]string) ([]transfer.BatchItem, error) {
842 items := make([]transfer.BatchItem, 0)
843 for _, p := range pointers {
844- obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, repo.ID(), p.Oid)
845+ obj, err := t.store.GetLFSObjectByOid(t.ctx, t.dbx, t.repo.ID(), p.Oid)
846 if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
847 return items, db.WrapError(err)
848 }
849@@ -121,7 +114,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.B
850 }
851
852 if exist && obj.ID == 0 {
853- if err := t.store.CreateLFSObject(t.ctx, t.dbx, repo.ID(), p.Oid, p.Size); err != nil {
854+ if err := t.store.CreateLFSObject(t.ctx, t.dbx, t.repo.ID(), p.Oid, p.Size); err != nil {
855 return items, db.WrapError(err)
856 }
857 }
858@@ -137,7 +130,7 @@ func (t *lfsTransfer) Batch(_ string, pointers []transfer.Pointer) ([]transfer.B
859 }
860
861 // Download implements transfer.Backend.
862-func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) {
863+func (t *lfsTransfer) Download(oid string, _ map[string]string) (fs.File, error) {
864 cfg := config.FromContext(t.ctx)
865 strg := storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs"))
866 pointer := transfer.Pointer{Oid: oid}
867@@ -146,11 +139,12 @@ func (t *lfsTransfer) Download(oid string, _ ...string) (fs.File, error) {
868
869 type uploadObject struct {
870 oid string
871+ size int64
872 object storage.Object
873 }
874
875 // StartUpload implements transfer.Backend.
876-func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ ...string) (interface{}, error) {
877+func (t *lfsTransfer) StartUpload(oid string, r io.Reader, _ map[string]string) (interface{}, error) {
878 if r == nil {
879 return nil, fmt.Errorf("no reader: %w", transfer.ErrMissingData)
880diff --git a/server/git/lfs_auth.go b/server/git/lfs_auth.go
881new file mode 100644
882index 0000000000000000000000000000000000000000..0568d290742b71f5e2e30507bd9d868e2c25e4e6
883--- /dev/null
884+++ b/server/git/lfs_auth.go
885@@ -0,0 +1,85 @@
886+package git
887+
888+import (
889+ "context"
890+ "encoding/json"
891+ "errors"
892+ "fmt"
893+ "time"
894+
895+ "github.com/charmbracelet/log"
896+ "github.com/charmbracelet/soft-serve/server/config"
897+ "github.com/charmbracelet/soft-serve/server/jwk"
898+ "github.com/charmbracelet/soft-serve/server/lfs"
899+ "github.com/charmbracelet/soft-serve/server/proto"
900+ "github.com/golang-jwt/jwt/v5"
901+)
902+
903+// LFSAuthenticate implements teh Git LFS SSH authentication command.
904+// Context must have *config.Config, *log.Logger, proto.User.
905+// cmd.Args should have the repo path and operation as arguments.
906+func LFSAuthenticate(ctx context.Context, cmd ServiceCommand) error {
907+ if len(cmd.Args) < 2 {
908+ return errors.New("missing args")
909+ }
910+
911+ logger := log.FromContext(ctx).WithPrefix("ssh.lfs-authenticate")
912+ operation := cmd.Args[1]
913+ if operation != lfs.OperationDownload && operation != lfs.OperationUpload {
914+ logger.Errorf("invalid operation: %s", operation)
915+ return errors.New("invalid operation")
916+ }
917+
918+ user := proto.UserFromContext(ctx)
919+ if user == nil {
920+ logger.Errorf("missing user")
921+ return proto.ErrUserNotFound
922+ }
923+
924+ repo := proto.RepositoryFromContext(ctx)
925+ if repo == nil {
926+ logger.Errorf("missing repository")
927+ return proto.ErrRepoNotFound
928+ }
929+
930+ cfg := config.FromContext(ctx)
931+ kp, err := jwk.NewPair(cfg)
932+ if err != nil {
933+ logger.Error("failed to get JWK pair", "err", err)
934+ return err
935+ }
936+
937+ now := time.Now()
938+ expiresIn := time.Minute * 5
939+ expiresAt := now.Add(expiresIn)
940+ claims := jwt.RegisteredClaims{
941+ Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()),
942+ ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour
943+ NotBefore: jwt.NewNumericDate(now),
944+ IssuedAt: jwt.NewNumericDate(now),
945+ Issuer: cfg.HTTP.PublicURL,
946+ Audience: []string{
947+ repo.Name(),
948+ },
949+ }
950+
951+ token := jwt.NewWithClaims(jwk.SigningMethod, claims)
952+ token.Header["kid"] = kp.JWK().KeyID
953+ j, err := token.SignedString(kp.PrivateKey())
954+ if err != nil {
955+ logger.Error("failed to sign token", "err", err)
956+ return err
957+ }
958+
959+ href := fmt.Sprintf("%s/%s.git/info/lfs", cfg.HTTP.PublicURL, repo.Name())
960+ logger.Debug("generated token", "token", j, "href", href, "expires_at", expiresAt)
961+
962+ return json.NewEncoder(cmd.Stdout).Encode(lfs.AuthenticateResponse{
963+ Header: map[string]string{
964+ "Authorization": fmt.Sprintf("Bearer %s", j),
965+ },
966+ Href: href,
967+ ExpiresAt: expiresAt,
968+ ExpiresIn: expiresIn,
969+ })
970+}
971diff --git a/server/git/service.go b/server/git/service.go
972index 51a53a0c887b56c4596ecaaaa90fed1617d1c161..fb5dbf5acb5340174d178d2fb3c20f38cf4a7098 100644
973--- a/server/git/service.go
974+++ b/server/git/service.go
975@@ -25,7 +25,8 @@ const (
976 ReceivePackService Service = "git-receive-pack"
977 // LFSTransferService is the LFS transfer service.
978 LFSTransferService Service = "git-lfs-transfer"
979- // TODO: add support for git-lfs-authenticate
980+ // LFSAuthenticateService is the LFS authenticate service.
981+ LFSAuthenticateService = "git-lfs-authenticate"
982 )
983
984 // String returns the string representation of the service.
985@@ -45,6 +46,8 @@ func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
986 return gitServiceHandler(ctx, s, cmd)
987 case LFSTransferService:
988 return LFSTransfer(ctx, cmd)
989+ case LFSAuthenticateService:
990+ return LFSAuthenticate(ctx, cmd)
991 default:
992 return fmt.Errorf("unsupported service: %s", s)
993 }
994diff --git a/server/jwk/jwk.go b/server/jwk/jwk.go
995new file mode 100644
996index 0000000000000000000000000000000000000000..51ea86be8db75bff443e492614fae36425df5f0b
997--- /dev/null
998+++ b/server/jwk/jwk.go
999@@ -0,0 +1,49 @@
1000+package jwk
1001+
1002+import (
1003+ "crypto"
1004+ "crypto/sha256"
1005+ "fmt"
1006+
1007+ "github.com/charmbracelet/soft-serve/server/config"
1008+ "github.com/go-jose/go-jose/v3"
1009+ "github.com/golang-jwt/jwt/v5"
1010+)
1011+
1012+// SigningMethod is a JSON Web Token signing method. It uses Ed25519 keys to
1013+// sign and verify tokens.
1014+var SigningMethod = &jwt.SigningMethodEd25519{}
1015+
1016+// Pair is a JSON Web Key pair.
1017+type Pair struct {
1018+ privateKey crypto.PrivateKey
1019+ jwk jose.JSONWebKey
1020+}
1021+
1022+// PrivateKey returns the private key.
1023+func (p Pair) PrivateKey() crypto.PrivateKey {
1024+ return p.privateKey
1025+}
1026+
1027+// JWK returns the JSON Web Key.
1028+func (p Pair) JWK() jose.JSONWebKey {
1029+ return p.jwk
1030+}
1031+
1032+// NewPair creates a new JSON Web Key pair.
1033+func NewPair(cfg *config.Config) (Pair, error) {
1034+ kp, err := cfg.SSH.KeyPair()
1035+ if err != nil {
1036+ return Pair{}, err
1037+ }
1038+
1039+ sum := sha256.Sum256(kp.RawPrivateKey())
1040+ kid := fmt.Sprintf("%x", sum)
1041+ jwk := jose.JSONWebKey{
1042+ Key: kp.CryptoPublicKey(),
1043+ KeyID: kid,
1044+ Algorithm: SigningMethod.Alg(),
1045+ }
1046+
1047+ return Pair{privateKey: kp.PrivateKey(), jwk: jwk}, nil
1048+}
1049diff --git a/server/lfs/common.go b/server/lfs/common.go
1050index 1bd2473068ab09f69b829ce5b292c50d9dd08097..aa98c65278b7c35385f15324890235918b05e16e 100644
1051--- a/server/lfs/common.go
1052+++ b/server/lfs/common.go
1053@@ -1,6 +1,8 @@
1054 package lfs
1055
1056-import "time"
1057+import (
1058+ "time"
1059+)
1060
1061 const (
1062 // MediaType contains the media type for LFS server requests.
1063@@ -20,6 +22,10 @@ const (
1064
1065 // ActionVerify is the action name for a verify request.
1066 ActionVerify = "verify"
1067+
1068+ // DefaultLocksLimit is the default number of locks to return in a single
1069+ // request.
1070+ DefaultLocksLimit = 20
1071 )
1072
1073 // Pointer contains LFS pointer data
1074@@ -86,3 +92,72 @@ type BatchRequest struct {
1075 type Reference struct {
1076 Name string `json:"name"`
1077 }
1078+
1079+// AuthenticateResponse is the git-lfs-authenticate JSON response object.
1080+type AuthenticateResponse struct {
1081+ Header map[string]string `json:"header"`
1082+ Href string `json:"href"`
1083+ ExpiresIn time.Duration `json:"expires_in"`
1084+ ExpiresAt time.Time `json:"expires_at"`
1085+}
1086+
1087+// LockCreateRequest contains the request data for creating a lock.
1088+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1089+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-request-schema.json
1090+type LockCreateRequest struct {
1091+ Path string `json:"path"`
1092+ Ref Reference `json:"ref,omitempty"`
1093+}
1094+
1095+// Owner contains the owner data for a lock.
1096+type Owner struct {
1097+ Name string `json:"name"`
1098+}
1099+
1100+// Lock contains the response data for creating a lock.
1101+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1102+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-create-response-schema.json
1103+type Lock struct {
1104+ ID string `json:"id"`
1105+ Path string `json:"path"`
1106+ LockedAt time.Time `json:"locked_at"`
1107+ Owner Owner `json:"owner,omitempty"`
1108+}
1109+
1110+// LockDeleteRequest contains the request data for deleting a lock.
1111+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1112+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-delete-request-schema.json
1113+type LockDeleteRequest struct {
1114+ Force bool `json:"force,omitempty"`
1115+ Ref Reference `json:"ref,omitempty"`
1116+}
1117+
1118+// LockListResponse contains the response data for listing locks.
1119+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1120+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-list-response-schema.json
1121+type LockListResponse struct {
1122+ Locks []Lock `json:"locks"`
1123+ NextCursor string `json:"next_cursor,omitempty"`
1124+}
1125+
1126+// LockVerifyRequest contains the request data for verifying a lock.
1127+type LockVerifyRequest struct {
1128+ Ref Reference `json:"ref,omitempty"`
1129+ Cursor string `json:"cursor,omitempty"`
1130+ Limit int `json:"limit,omitempty"`
1131+}
1132+
1133+// LockVerifyResponse contains the response data for verifying a lock.
1134+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/locking.md
1135+// https://github.com/git-lfs/git-lfs/blob/main/locking/schemas/http-lock-verify-response-schema.json
1136+type LockVerifyResponse struct {
1137+ Ours []Lock `json:"ours"`
1138+ Theirs []Lock `json:"theirs"`
1139+ NextCursor string `json:"next_cursor,omitempty"`
1140+}
1141+
1142+// LockResponse contains the response data for a lock.
1143+type LockResponse struct {
1144+ Lock Lock `json:"lock"`
1145+ ErrorResponse
1146+}
1147diff --git a/server/proto/context.go b/server/proto/context.go
1148new file mode 100644
1149index 0000000000000000000000000000000000000000..cd022dcac00ccae5da434ddb3583797ddf83fc2f
1150--- /dev/null
1151+++ b/server/proto/context.go
1152@@ -0,0 +1,35 @@
1153+package proto
1154+
1155+import "context"
1156+
1157+// ContextKeyRepository is the context key for the repository.
1158+var ContextKeyRepository = &struct{ string }{"repository"}
1159+
1160+// ContextKeyUser is the context key for the user.
1161+var ContextKeyUser = &struct{ string }{"user"}
1162+
1163+// RepositoryFromContext returns the repository from the context.
1164+func RepositoryFromContext(ctx context.Context) Repository {
1165+ if r, ok := ctx.Value(ContextKeyRepository).(Repository); ok {
1166+ return r
1167+ }
1168+ return nil
1169+}
1170+
1171+// UserFromContext returns the user from the context.
1172+func UserFromContext(ctx context.Context) User {
1173+ if u, ok := ctx.Value(ContextKeyUser).(User); ok {
1174+ return u
1175+ }
1176+ return nil
1177+}
1178+
1179+// WithRepositoryContext returns a new context with the repository.
1180+func WithRepositoryContext(ctx context.Context, r Repository) context.Context {
1181+ return context.WithValue(ctx, ContextKeyRepository, r)
1182+}
1183+
1184+// WithUserContext returns a new context with the user.
1185+func WithUserContext(ctx context.Context, u User) context.Context {
1186+ return context.WithValue(ctx, ContextKeyUser, u)
1187+}
1188diff --git a/server/proto/errors.go b/server/proto/errors.go
1189index b2d12dd07abf85061710e13a6611e3c2f310802f..781ee6b3296f27cdcad6ab81e1300543a4070a2e 100644
1190--- a/server/proto/errors.go
1191+++ b/server/proto/errors.go
1192@@ -6,11 +6,13 @@ import (
1193
1194 var (
1195 // ErrUnauthorized is returned when the user is not authorized to perform action.
1196- ErrUnauthorized = errors.New("Unauthorized")
1197+ ErrUnauthorized = errors.New("unauthorized")
1198 // ErrFileNotFound is returned when the file is not found.
1199- ErrFileNotFound = errors.New("File not found")
1200- // ErrRepoNotExist is returned when a repository does not exist.
1201- ErrRepoNotExist = errors.New("repository does not exist")
1202+ ErrFileNotFound = errors.New("file not found")
1203+ // ErrRepoNotFound is returned when a repository does not exist.
1204+ ErrRepoNotFound = errors.New("repository not found")
1205 // ErrRepoExist is returned when a repository already exists.
1206 ErrRepoExist = errors.New("repository already exists")
1207+ // ErrUserNotFound is returned when a user does not exist.
1208+ ErrUserNotFound = errors.New("user does not exist")
1209 )
1210diff --git a/server/proto/repo.go b/server/proto/repo.go
1211index e721d44a2b115165d13502992a04b8287a1a7b26..2d54e9484caf3ec38269be0907a4af075b93d69d 100644
1212--- a/server/proto/repo.go
1213+++ b/server/proto/repo.go
1214@@ -6,9 +6,6 @@ import (
1215 "github.com/charmbracelet/soft-serve/git"
1216 )
1217
1218-// ContextKeyRepository is the context key for the repository.
1219-var ContextKeyRepository = &struct{ string }{"repository"}
1220-
1221 // Repository is a Git repository interface.
1222 type Repository interface {
1223 // ID returns the repository's ID.
1224diff --git a/server/proto/user.go b/server/proto/user.go
1225index f8fd65a1479ccbd9736e9c4af353b303a74402eb..647b241101767450fdcd4a1cc725dff10e46055b 100644
1226--- a/server/proto/user.go
1227+++ b/server/proto/user.go
1228@@ -2,9 +2,6 @@ package proto
1229
1230 import "golang.org/x/crypto/ssh"
1231
1232-// ContextKeyUser is the context key for the user.
1233-var ContextKeyUser = &struct{ string }{"user"}
1234-
1235 // User is an interface representing a user.
1236 type User interface {
1237 // ID returns the user's ID.
1238diff --git a/server/ssh/cmd/cmd.go b/server/ssh/cmd/cmd.go
1239index d7e3f5ddcc9bfe2c78437a4b0a7dff361c6c094d..b965409de30e88d3454765ad6b235b05b7d81e92 100644
1240--- a/server/ssh/cmd/cmd.go
1241+++ b/server/ssh/cmd/cmd.go
1242@@ -89,7 +89,6 @@ func cmdName(args []string) string {
1243 func RootCommand(s ssh.Session) *cobra.Command {
1244 ctx := s.Context()
1245 cfg := config.FromContext(ctx)
1246- be := backend.FromContext(ctx)
1247
1248 args := s.Command()
1249 cliCommandCounter.WithLabelValues(cmdName(args)).Inc()
1250@@ -140,7 +139,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
1251 rootCmd.CompletionOptions.DisableDefaultCmd = true
1252 rootCmd.SetErr(s.Stderr())
1253
1254- user, _ := be.UserByPublicKey(s.Context(), s.PublicKey())
1255+ user := proto.UserFromContext(ctx)
1256 isAdmin := isPublicKeyAdmin(cfg, s.PublicKey()) || (user != nil && user.IsAdmin())
1257 if user != nil || isAdmin {
1258 if isAdmin {
1259@@ -154,6 +153,7 @@ func RootCommand(s ssh.Session) *cobra.Command {
1260 infoCommand(),
1261 pubkeyCommand(),
1262 setUsernameCommand(),
1263+ jwtCommand(),
1264 )
1265 }
1266
1267@@ -169,8 +169,8 @@ func checkIfReadable(cmd *cobra.Command, args []string) error {
1268 ctx := cmd.Context()
1269 be := backend.FromContext(ctx)
1270 rn := utils.SanitizeRepo(repo)
1271- pk := sshutils.PublicKeyFromContext(ctx)
1272- auth := be.AccessLevelByPublicKey(cmd.Context(), rn, pk)
1273+ user := proto.UserFromContext(ctx)
1274+ auth := be.AccessLevelForUser(cmd.Context(), rn, user)
1275 if auth < access.ReadOnlyAccess {
1276 return proto.ErrUnauthorized
1277 }
1278@@ -188,14 +188,13 @@ func isPublicKeyAdmin(cfg *config.Config, pk ssh.PublicKey) bool {
1279
1280 func checkIfAdmin(cmd *cobra.Command, _ []string) error {
1281 ctx := cmd.Context()
1282- be := backend.FromContext(ctx)
1283 cfg := config.FromContext(ctx)
1284 pk := sshutils.PublicKeyFromContext(ctx)
1285 if isPublicKeyAdmin(cfg, pk) {
1286 return nil
1287 }
1288
1289- user, _ := be.UserByPublicKey(ctx, pk)
1290+ user := proto.UserFromContext(ctx)
1291 if user == nil {
1292 return proto.ErrUnauthorized
1293 }
1294@@ -215,9 +214,9 @@ func checkIfCollab(cmd *cobra.Command, args []string) error {
1295
1296 ctx := cmd.Context()
1297 be := backend.FromContext(ctx)
1298- pk := sshutils.PublicKeyFromContext(ctx)
1299 rn := utils.SanitizeRepo(repo)
1300- auth := be.AccessLevelByPublicKey(ctx, rn, pk)
1301+ user := proto.UserFromContext(ctx)
1302+ auth := be.AccessLevelForUser(cmd.Context(), rn, user)
1303 if auth < access.ReadWriteAccess {
1304 return proto.ErrUnauthorized
1305 }
1306diff --git a/server/ssh/cmd/jwt.go b/server/ssh/cmd/jwt.go
1307new file mode 100644
1308index 0000000000000000000000000000000000000000..b574889f5a56f9f3e56ae7c1daf76828bc4957ab
1309--- /dev/null
1310+++ b/server/ssh/cmd/jwt.go
1311@@ -0,0 +1,56 @@
1312+package cmd
1313+
1314+import (
1315+ "fmt"
1316+ "time"
1317+
1318+ "github.com/charmbracelet/soft-serve/server/config"
1319+ "github.com/charmbracelet/soft-serve/server/jwk"
1320+ "github.com/charmbracelet/soft-serve/server/proto"
1321+ "github.com/golang-jwt/jwt/v5"
1322+ "github.com/spf13/cobra"
1323+)
1324+
1325+func jwtCommand() *cobra.Command {
1326+ cmd := &cobra.Command{
1327+ Use: "jwt [repository1 repository2...]",
1328+ Short: "Generate a JSON Web Token",
1329+ Args: cobra.MinimumNArgs(0),
1330+ RunE: func(cmd *cobra.Command, args []string) error {
1331+ ctx := cmd.Context()
1332+ cfg := config.FromContext(ctx)
1333+ kp, err := jwk.NewPair(cfg)
1334+ if err != nil {
1335+ return err
1336+ }
1337+
1338+ user := proto.UserFromContext(ctx)
1339+ if user == nil {
1340+ return proto.ErrUserNotFound
1341+ }
1342+
1343+ now := time.Now()
1344+ expiresAt := now.Add(time.Hour)
1345+ claims := jwt.RegisteredClaims{
1346+ Subject: fmt.Sprintf("%s#%d", user.Username(), user.ID()),
1347+ ExpiresAt: jwt.NewNumericDate(expiresAt), // expire in an hour
1348+ NotBefore: jwt.NewNumericDate(now),
1349+ IssuedAt: jwt.NewNumericDate(now),
1350+ Issuer: cfg.HTTP.PublicURL,
1351+ Audience: args,
1352+ }
1353+
1354+ token := jwt.NewWithClaims(jwk.SigningMethod, claims)
1355+ token.Header["kid"] = kp.JWK().KeyID
1356+ j, err := token.SignedString(kp.PrivateKey())
1357+ if err != nil {
1358+ return err
1359+ }
1360+
1361+ cmd.Println(j)
1362+ return nil
1363+ },
1364+ }
1365+
1366+ return cmd
1367+}
1368diff --git a/server/ssh/git.go b/server/ssh/git.go
1369index d2a030f9ded5d6ef644a363b1c84fcb1e56bd835..02b59e47826c3407516d3f02220c85257b494537 100644
1370--- a/server/ssh/git.go
1371+++ b/server/ssh/git.go
1372@@ -25,47 +25,51 @@ func handleGit(s ssh.Session) {
1373 cmdLine := s.Command()
1374 start := time.Now()
1375
1376- var username string
1377- user := ctx.Value(proto.ContextKeyUser).(proto.User)
1378- if user != nil {
1379- username = user.Username()
1380- }
1381-
1382 // repo should be in the form of "repo.git"
1383 name := utils.SanitizeRepo(cmdLine[1])
1384 pk := s.PublicKey()
1385 ak := sshutils.MarshalAuthorizedKey(pk)
1386+ user := proto.UserFromContext(ctx)
1387 accessLevel := be.AccessLevelForUser(ctx, name, user)
1388 // git bare repositories should end in ".git"
1389 // https://git-scm.com/docs/gitrepository-layout
1390- repo := name + ".git"
1391+ repoDir := name + ".git"
1392 reposDir := filepath.Join(cfg.DataPath, "repos")
1393- if err := git.EnsureWithin(reposDir, repo); err != nil {
1394+ if err := git.EnsureWithin(reposDir, repoDir); err != nil {
1395 sshFatal(s, err)
1396 return
1397 }
1398
1399+ // Set repo in context
1400+ repo, _ := be.Repository(ctx, name)
1401+ ctx.SetValue(proto.ContextKeyRepository, repo)
1402+
1403 // Environment variables to pass down to git hooks.
1404 envs := []string{
1405 "SOFT_SERVE_REPO_NAME=" + name,
1406- "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
1407+ "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repoDir),
1408 "SOFT_SERVE_PUBLIC_KEY=" + ak,
1409- "SOFT_SERVE_USERNAME=" + username,
1410 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
1411 }
1412
1413+ if user != nil {
1414+ envs = append(envs,
1415+ "SOFT_SERVE_USERNAME="+user.Username(),
1416+ )
1417+ }
1418+
1419 // Add ssh session & config environ
1420 envs = append(envs, s.Environ()...)
1421 envs = append(envs, cfg.Environ()...)
1422
1423- repoDir := filepath.Join(reposDir, repo)
1424+ repoPath := filepath.Join(reposDir, repoDir)
1425 service := git.Service(cmdLine[0])
1426 cmd := git.ServiceCommand{
1427 Stdin: s,
1428 Stdout: s,
1429 Stderr: s.Stderr(),
1430 Env: envs,
1431- Dir: repoDir,
1432+ Dir: repoPath,
1433 }
1434
1435 logger.Debug("git middleware", "cmd", service, "access", accessLevel.String())
1436@@ -80,7 +84,7 @@ func handleGit(s ssh.Session) {
1437 sshFatal(s, git.ErrNotAuthed)
1438 return
1439 }
1440- if _, err := be.Repository(ctx, name); err != nil {
1441+ if repo == nil {
1442 if _, err := be.CreateRepository(ctx, name, proto.RepositoryOptions{Private: false}); err != nil {
1443 log.Errorf("failed to create repo: %s", err)
1444 sshFatal(s, err)
1445@@ -105,10 +109,8 @@ func handleGit(s ssh.Session) {
1446 return
1447 }
1448
1449- handler := git.UploadPack
1450 switch service {
1451 case git.UploadArchiveService:
1452- handler = git.UploadArchive
1453 uploadArchiveCounter.WithLabelValues(name).Inc()
1454 defer func() {
1455 uploadArchiveSeconds.WithLabelValues(name).Add(time.Since(start).Seconds())
1456@@ -120,14 +122,16 @@ func handleGit(s ssh.Session) {
1457 }()
1458 }
1459
1460- err := handler(ctx, cmd)
1461+ err := service.Handler(ctx, cmd)
1462 if errors.Is(err, git.ErrInvalidRepo) {
1463 sshFatal(s, git.ErrInvalidRepo)
1464 } else if err != nil {
1465 logger.Error("git middleware", "err", err)
1466 sshFatal(s, git.ErrSystemMalfunction)
1467 }
1468- case git.LFSTransferService:
1469+
1470+ return
1471+ case git.LFSTransferService, git.LFSAuthenticateService:
1472diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
1473index fe6854507ac8fe6b53bf3c6a6a08b588c660ec8d..297973ebf24d0be41b66fa4c8329310858ca4970 100644
1474--- a/server/ssh/ssh.go
1475+++ b/server/ssh/ssh.go
1476@@ -10,13 +10,11 @@ import (
1477
1478 "github.com/charmbracelet/keygen"
1479 "github.com/charmbracelet/log"
1480- "github.com/charmbracelet/soft-serve/server/access"
1481 "github.com/charmbracelet/soft-serve/server/backend"
1482 "github.com/charmbracelet/soft-serve/server/config"
1483 "github.com/charmbracelet/soft-serve/server/db"
1484 "github.com/charmbracelet/soft-serve/server/git"
1485 "github.com/charmbracelet/soft-serve/server/proto"
1486- "github.com/charmbracelet/soft-serve/server/sshutils"
1487 "github.com/charmbracelet/soft-serve/server/store"
1488 "github.com/charmbracelet/ssh"
1489 "github.com/charmbracelet/wish"
1490@@ -191,17 +189,16 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed
1491 return false
1492 }
1493
1494- ak := sshutils.MarshalAuthorizedKey(pk)
1495 defer func(allowed *bool) {
1496 publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc()
1497 }(&allowed)
1498
1499 user, _ := s.be.UserByPublicKey(ctx, pk)
1500- ctx.SetValue(proto.ContextKeyUser, user)
1501+ if user != nil {
1502+ ctx.SetValue(proto.ContextKeyUser, user)
1503+ allowed = true
1504+ }
1505
1506- ac := s.be.AccessLevelForUser(ctx, "", user)
1507- s.logger.Debugf("access level for %q: %s", ak, ac)
1508- allowed = ac >= access.ReadWriteAccess
1509 return
1510 }
1511
1512diff --git a/server/storage/local.go b/server/storage/local.go
1513index 8a51157af6e9a796921225f142083a6243aa1720..496f20e37dcf47dba316250f0cf33878434e16ad 100644
1514--- a/server/storage/local.go
1515+++ b/server/storage/local.go
1516@@ -41,19 +41,18 @@ func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {
1517 }
1518
1519 // Put implements Storage.
1520-func (l *LocalStorage) Put(name string, r io.Reader) error {
1521+func (l *LocalStorage) Put(name string, r io.Reader) (int64, error) {
1522 name = l.fixPath(name)
1523 if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
1524- return err
1525+ return 0, err
1526 }
1527
1528 f, err := os.Create(name)
1529 if err != nil {
1530- return err
1531+ return 0, err
1532 }
1533 defer f.Close() // nolint: errcheck
1534- _, err = io.Copy(f, r)
1535- return err
1536+ return io.Copy(f, r)
1537 }
1538
1539 // Exists implements Storage.
1540diff --git a/server/storage/storage.go b/server/storage/storage.go
1541index dc435dbbbd8b39b6a1e0ac69cc1269276fab32e6..9f8467262643f3b698b629a006002236935da426 100644
1542--- a/server/storage/storage.go
1543+++ b/server/storage/storage.go
1544@@ -16,7 +16,7 @@ type Object interface {
1545 type Storage interface {
1546 Open(name string) (Object, error)
1547 Stat(name string) (fs.FileInfo, error)
1548- Put(name string, r io.Reader) error
1549+ Put(name string, r io.Reader) (int64, error)
1550 Delete(name string) error
1551 Exists(name string) (bool, error)
1552 Rename(oldName, newName string) error
1553diff --git a/server/store/access_token.go b/server/store/access_token.go
1554new file mode 100644
1555index 0000000000000000000000000000000000000000..72440ea2a61d80d1f2121906091cdd28cfe67ffd
1556--- /dev/null
1557+++ b/server/store/access_token.go
1558@@ -0,0 +1 @@
1559+package store
1560diff --git a/server/store/collab.go b/server/store/collab.go
1561new file mode 100644
1562index 0000000000000000000000000000000000000000..430e39d8f667dfb82a57bb53b29d6656b2d95eb6
1563--- /dev/null
1564+++ b/server/store/collab.go
1565@@ -0,0 +1,17 @@
1566+package store
1567+
1568+import (
1569+ "context"
1570+
1571+ "github.com/charmbracelet/soft-serve/server/db"
1572+ "github.com/charmbracelet/soft-serve/server/db/models"
1573+)
1574+
1575+// CollaboratorStore is an interface for managing collaborators.
1576+type CollaboratorStore interface {
1577+ GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
1578+ AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
1579+ RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
1580+ ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
1581+ ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
1582+}
1583diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go
1584index 0233dec7a0ffc48da9bca2004938d4bccb987560..64fef37165004136b701a489fce69db0cc037f31 100644
1585--- a/server/store/database/lfs.go
1586+++ b/server/store/database/lfs.go
1587@@ -2,7 +2,6 @@ package database
1588
1589 import (
1590 "context"
1591- "strconv"
1592 "strings"
1593
1594 "github.com/charmbracelet/soft-serve/server/db"
1595@@ -37,17 +36,43 @@ func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID
1596 }
1597
1598 // GetLFSLocks implements store.LFSStore.
1599-func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSLock, error) {
1600+func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error) {
1601+ if page <= 0 {
1602+ page = 1
1603+ }
1604+
1605 var locks []models.LFSLock
1606 query := tx.Rebind(`
1607 SELECT *
1608 FROM lfs_locks
1609- WHERE repo_id = ?;
1610+ WHERE repo_id = ?
1611+ ORDER BY updated_at DESC
1612+ LIMIT ? OFFSET ?;
1613 `)
1614- err := tx.SelectContext(ctx, &locks, query, repoID)
1615+ err := tx.SelectContext(ctx, &locks, query, repoID, limit, (page-1)*limit)
1616 return locks, db.WrapError(err)
1617 }
1618
1619+func (s *lfsStore) GetLFSLocksWithCount(ctx context.Context, tx db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error) {
1620+ locks, err := s.GetLFSLocks(ctx, tx, repoID, page, limit)
1621+ if err != nil {
1622+ return nil, 0, err
1623+ }
1624+
1625+ var count int64
1626+ query := tx.Rebind(`
1627+ SELECT COUNT(*)
1628+ FROM lfs_locks
1629+ WHERE repo_id = ?;
1630+ `)
1631+ err = tx.GetContext(ctx, &count, query, repoID)
1632+ if err != nil {
1633+ return nil, 0, db.WrapError(err)
1634+ }
1635+
1636+ return locks, count, nil
1637+}
1638+
1639 // GetLFSLocksForUser implements store.LFSStore.
1640 func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) {
1641 var locks []models.LFSLock
1642@@ -61,16 +86,16 @@ func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID i
1643 }
1644
1645 // GetLFSLocksForPath implements store.LFSStore.
1646-func (*lfsStore) GetLFSLocksForPath(ctx context.Context, tx db.Handler, repoID int64, path string) ([]models.LFSLock, error) {
1647+func (*lfsStore) GetLFSLockForPath(ctx context.Context, tx db.Handler, repoID int64, path string) (models.LFSLock, error) {
1648 path = sanitizePath(path)
1649- var locks []models.LFSLock
1650+ var lock models.LFSLock
1651 query := tx.Rebind(`
1652 SELECT *
1653 FROM lfs_locks
1654 WHERE repo_id = ? AND path = ?;
1655 `)
1656- err := tx.SelectContext(ctx, &locks, query, repoID, path)
1657- return locks, db.WrapError(err)
1658+ err := tx.GetContext(ctx, &lock, query, repoID, path)
1659+ return lock, db.WrapError(err)
1660 }
1661
1662 // GetLFSLockForUserPath implements store.LFSStore.
1663@@ -87,51 +112,46 @@ func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoI
1664 }
1665
1666 // GetLFSLockByID implements store.LFSStore.
1667-func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id string) (models.LFSLock, error) {
1668- iid, err := strconv.Atoi(id)
1669- if err != nil {
1670- return models.LFSLock{}, err
1671- }
1672-
1673+func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id int64) (models.LFSLock, error) {
1674 var lock models.LFSLock
1675 query := tx.Rebind(`
1676 SELECT *
1677 FROM lfs_locks
1678 WHERE lfs_locks.id = ?;
1679 `)
1680- err = tx.GetContext(ctx, &lock, query, iid)
1681+ err := tx.GetContext(ctx, &lock, query, id)
1682 return lock, db.WrapError(err)
1683 }
1684
1685 // GetLFSLockForUserByID implements store.LFSStore.
1686-func (*lfsStore) GetLFSLockForUserByID(ctx context.Context, tx db.Handler, userID int64, id string) (models.LFSLock, error) {
1687diff --git a/server/store/database/user.go b/server/store/database/user.go
1688index 9ca824e62bfde8cdb9a2c2d77c6eecd442c59183..c5b50681b0ab5ef9ca3b70fbe044cd2710c43bb3 100644
1689--- a/server/store/database/user.go
1690+++ b/server/store/database/user.go
1691@@ -214,3 +214,22 @@ func (*userStore) SetUsernameByUsername(ctx context.Context, tx db.Handler, user
1692 _, err := tx.ExecContext(ctx, query, newUsername, username)
1693 return err
1694 }
1695+
1696+// SetUserPassword implements store.UserStore.
1697+func (*userStore) SetUserPassword(ctx context.Context, tx db.Handler, userID int64, password string) error {
1698+ query := tx.Rebind(`UPDATE users SET password = ? WHERE id = ?;`)
1699+ _, err := tx.ExecContext(ctx, query, password, userID)
1700+ return err
1701+}
1702+
1703+// SetUserPasswordByUsername implements store.UserStore.
1704+func (*userStore) SetUserPasswordByUsername(ctx context.Context, tx db.Handler, username string, password string) error {
1705+ username = strings.ToLower(username)
1706+ if err := utils.ValidateUsername(username); err != nil {
1707+ return err
1708+ }
1709+
1710+ query := tx.Rebind(`UPDATE users SET password = ? WHERE username = ?;`)
1711+ _, err := tx.ExecContext(ctx, query, password, username)
1712+ return err
1713+}
1714diff --git a/server/store/lfs.go b/server/store/lfs.go
1715index 7632d2472bd7b65c43141e46dab7d49eb0741735..067285eac7cf6ffb910149d2d3df2c0959f5d78b 100644
1716--- a/server/store/lfs.go
1717+++ b/server/store/lfs.go
1718@@ -16,11 +16,13 @@ type LFSStore interface {
1719 DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error
1720
1721 CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error
1722- GetLFSLocks(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSLock, error)
1723+ GetLFSLocks(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, error)
1724+ GetLFSLocksWithCount(ctx context.Context, h db.Handler, repoID int64, page int, limit int) ([]models.LFSLock, int64, error)
1725 GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)
1726- GetLFSLocksForPath(ctx context.Context, h db.Handler, repoID int64, path string) ([]models.LFSLock, error)
1727+ GetLFSLockForPath(ctx context.Context, h db.Handler, repoID int64, path string) (models.LFSLock, error)
1728 GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)
1729- GetLFSLockByID(ctx context.Context, h db.Handler, id string) (models.LFSLock, error)
1730- GetLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) (models.LFSLock, error)
1731- DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) error
1732+ GetLFSLockByID(ctx context.Context, h db.Handler, id int64) (models.LFSLock, error)
1733+ GetLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) (models.LFSLock, error)
1734+ DeleteLFSLock(ctx context.Context, h db.Handler, repoID int64, id int64) error
1735+ DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, repoID int64, userID int64, id int64) error
1736 }
1737diff --git a/server/store/repo.go b/server/store/repo.go
1738new file mode 100644
1739index 0000000000000000000000000000000000000000..c64eb08ee491d46b173193908fb57e8a920f3598
1740--- /dev/null
1741+++ b/server/store/repo.go
1742@@ -0,0 +1,27 @@
1743+package store
1744+
1745+import (
1746+ "context"
1747+
1748+ "github.com/charmbracelet/soft-serve/server/db"
1749+ "github.com/charmbracelet/soft-serve/server/db/models"
1750+)
1751+
1752+// RepositoryStore is an interface for managing repositories.
1753+type RepositoryStore interface {
1754+ GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
1755+ GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
1756+ CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
1757+ DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
1758+ SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
1759+
1760+ GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)
1761+ SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error
1762+ GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)
1763+ SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error
1764+ GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)
1765+ SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error
1766+ GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
1767+ SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
1768+ GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
1769+}
1770diff --git a/server/store/settings.go b/server/store/settings.go
1771new file mode 100644
1772index 0000000000000000000000000000000000000000..75dd9d7afed808cae3593194a5e964c2e5cdfcc9
1773--- /dev/null
1774+++ b/server/store/settings.go
1775@@ -0,0 +1,16 @@
1776+package store
1777+
1778+import (
1779+ "context"
1780+
1781+ "github.com/charmbracelet/soft-serve/server/access"
1782+ "github.com/charmbracelet/soft-serve/server/db"
1783+)
1784+
1785+// SettingStore is an interface for managing settings.
1786+type SettingStore interface {
1787+ GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)
1788+ SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error
1789+ GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)
1790+ SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error
1791+}