8e68dba683ab45107d5ab100b77ea1bfccdb1a2c
- Author
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Committer
- Ayman Bagabas <ayman.bagabas@gmail.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/cmd/soft/root.go b/cmd/soft/root.go
2index b3216162f5601ed44bddfd3fa0bdcc86a8a1825c..7f03982d437c09440c5c65e0932bd3c9af294f9f 100644
3--- a/cmd/soft/root.go
4+++ b/cmd/soft/root.go
5@@ -12,6 +12,8 @@ import (
6 "github.com/charmbracelet/soft-serve/server/backend"
7 "github.com/charmbracelet/soft-serve/server/config"
8 "github.com/charmbracelet/soft-serve/server/db"
9+ "github.com/charmbracelet/soft-serve/server/store"
10+ "github.com/charmbracelet/soft-serve/server/store/database"
11 _ "github.com/lib/pq" // postgres driver
12 "github.com/spf13/cobra"
13 "go.uber.org/automaxprocs/maxprocs"
14@@ -150,6 +152,8 @@ func initBackendContext(cmd *cobra.Command, _ []string) error {
15 }
16
17 ctx = db.WithContext(ctx, dbx)
18+ dbstore := database.New(ctx, dbx)
19+ ctx = store.WithContext(ctx, dbstore)
20 be := backend.New(ctx, cfg, dbx)
21 ctx = backend.WithContext(ctx, be)
22
23diff --git a/git/config.go b/git/config.go
24index 4e9af6ed500302e8d1e7e7afeabf13bbba4bde48..1ebb2470384145be6ff8b2ab4cafd346d41bac64 100644
25--- a/git/config.go
26+++ b/git/config.go
27@@ -1,51 +1,39 @@
28 package git
29
30-// ConfigOptions are options for Config.
31-type ConfigOptions struct {
32- File string
33- All bool
34- Add bool
35- CommandOptions
36-}
37+import (
38+ "os"
39+ "path/filepath"
40
41-// Config gets a git configuration.
42-func Config(key string, opts ...ConfigOptions) (string, error) {
43- var opt ConfigOptions
44- if len(opts) > 0 {
45- opt = opts[0]
46- }
47- cmd := NewCommand("config")
48- if opt.File != "" {
49- cmd.AddArgs("--file", opt.File)
50- }
51- if opt.All {
52- cmd.AddArgs("--get-all")
53- }
54- for _, a := range opt.Args {
55- cmd.AddArgs(a)
56- }
57- cmd.AddArgs(key)
58- bts, err := cmd.Run()
59+ gcfg "github.com/go-git/go-git/v5/plumbing/format/config"
60+)
61+
62+// Config returns the repository Git configuration.
63+func (r *Repository) Config() (*gcfg.Config, error) {
64+ cp := filepath.Join(r.Path, "config")
65+ f, err := os.Open(cp)
66 if err != nil {
67- return "", err
68+ return nil, err
69 }
70- return string(bts), nil
71-}
72
73-// SetConfig sets a git configuration.
74-func SetConfig(key string, value string, opts ...ConfigOptions) error {
75- var opt ConfigOptions
76- if len(opts) > 0 {
77- opt = opts[0]
78+ defer f.Close() // nolint: errcheck
79+ d := gcfg.NewDecoder(f)
80+ cfg := gcfg.New()
81+ if err := d.Decode(cfg); err != nil {
82+ return nil, err
83 }
84- cmd := NewCommand("config")
85- if opt.File != "" {
86- cmd.AddArgs("--file", opt.File)
87- }
88- for _, a := range opt.Args {
89- cmd.AddArgs(a)
90+
91+ return cfg, nil
92+}
93+
94+// SetConfig sets the repository Git configuration.
95+func (r *Repository) SetConfig(cfg *gcfg.Config) error {
96+ cp := filepath.Join(r.Path, "config")
97+ f, err := os.Create(cp)
98+ if err != nil {
99+ return err
100 }
101- cmd.AddArgs(key, value)
102- _, err := cmd.Run()
103- return err
104+
105+ defer f.Close() // nolint: errcheck
106+ e := gcfg.NewEncoder(f)
107+ return e.Encode(cfg)
108 }
109diff --git a/git/repo.go b/git/repo.go
110index 479a4581a6a0ddf3c2094224f98a37f595da1734..ef3d2759d63411b4d7f297ebb838eb959bd6b00d 100644
111--- a/git/repo.go
112+++ b/git/repo.go
113@@ -200,34 +200,6 @@ func (r *Repository) CommitsByPage(ref *Reference, page, size int) (Commits, err
114 return commits, nil
115 }
116
117-// Config returns the config value for the given key.
118-func (r *Repository) Config(key string, opts ...ConfigOptions) (string, error) {
119- dir, err := gitDir(r.Repository)
120- if err != nil {
121- return "", err
122- }
123- var opt ConfigOptions
124- if len(opts) > 0 {
125- opt = opts[0]
126- }
127- opt.File = filepath.Join(dir, "config")
128- return Config(key, opt)
129-}
130-
131-// SetConfig sets the config value for the given key.
132-func (r *Repository) SetConfig(key, value string, opts ...ConfigOptions) error {
133- dir, err := gitDir(r.Repository)
134- if err != nil {
135- return err
136- }
137- var opt ConfigOptions
138- if len(opts) > 0 {
139- opt = opts[0]
140- }
141- opt.File = filepath.Join(dir, "config")
142- return SetConfig(key, value, opt)
143-}
144-
145 // SymbolicRef returns or updates the symbolic reference for the given name.
146 // Both name and ref can be empty.
147 func (r *Repository) SymbolicRef(name string, ref string, opts ...git.SymbolicRefOptions) (string, error) {
148diff --git a/go.mod b/go.mod
149index e84be4d037b67ddf7e1ad1af07739e5f0d883638..0a9d11dcdd3532e6ac2c27621cc67f3a1a04a057 100644
150--- a/go.mod
151+++ b/go.mod
152@@ -19,9 +19,10 @@ require (
153
154 require (
155 github.com/caarlos0/env/v8 v8.0.0
156+ github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1
157 github.com/charmbracelet/keygen v0.4.3
158 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35
159- github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc
160+ github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155
161 github.com/gobwas/glob v0.2.3
162 github.com/gogs/git-module v1.8.2
163 github.com/hashicorp/golang-lru/v2 v2.0.4
164@@ -33,6 +34,7 @@ require (
165 github.com/prometheus/client_golang v1.16.0
166 github.com/robfig/cron/v3 v3.0.1
167 github.com/rogpeppe/go-internal v1.11.0
168+ github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086
169 github.com/spf13/cobra v1.7.0
170 go.uber.org/automaxprocs v1.5.3
171 goji.io v2.0.2+incompatible
172@@ -52,6 +54,8 @@ require (
173 github.com/cespare/xxhash/v2 v2.2.0 // indirect
174 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
175 github.com/dlclark/regexp2 v1.4.0 // indirect
176+ github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 // indirect
177+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
178 github.com/go-logfmt/logfmt v0.6.0 // indirect
179 github.com/golang/protobuf v1.5.3 // indirect
180 github.com/google/uuid v1.3.0 // indirect
181@@ -80,12 +84,13 @@ require (
182 github.com/yuin/goldmark v1.5.2 // indirect
183 github.com/yuin/goldmark-emoji v1.0.1 // indirect
184 golang.org/x/mod v0.9.0 // indirect
185- golang.org/x/net v0.10.0 // indirect
186+ golang.org/x/net v0.12.0 // indirect
187 golang.org/x/sys v0.10.0 // indirect
188 golang.org/x/term v0.10.0 // indirect
189 golang.org/x/text v0.11.0 // indirect
190 golang.org/x/tools v0.6.0 // indirect
191 google.golang.org/protobuf v1.30.0 // indirect
192+ gopkg.in/warnings.v0 v0.1.2 // indirect
193 lukechampine.com/uint128 v1.2.0 // indirect
194 modernc.org/cc/v3 v3.40.0 // indirect
195 modernc.org/ccgo/v3 v3.16.13 // indirect
196diff --git a/go.sum b/go.sum
197index 30314107e3eb2ae3642a2291bd1d6968186e03ad..7846d7c62bcc53de401544721db60589731d64ac 100644
198--- a/go.sum
199+++ b/go.sum
200@@ -21,6 +21,8 @@ github.com/charmbracelet/bubbles v0.16.1 h1:6uzpAAaT9ZqKssntbvZMlksWHruQLNxg49H5
201 github.com/charmbracelet/bubbles v0.16.1/go.mod h1:2QCp9LFlEsBQMvIYERr7Ww2H2bA7xen1idUDIzm/+Xc=
202 github.com/charmbracelet/bubbletea v0.24.2 h1:uaQIKx9Ai6Gdh5zpTbGiWpytMU+CfsPp06RaW2cx/SY=
203 github.com/charmbracelet/bubbletea v0.24.2/go.mod h1:XdrNrV4J8GiyshTtx3DNuYkR1FDaJmO3l2nejekbsgg=
204+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1 h1:/QzZzTDdlDYGZeC2O2y/Qw+AiHqh3vCsO4yrKDWXtqs=
205+github.com/charmbracelet/git-lfs-transfer v0.1.1-0.20230721203144-64d90e7a36a1/go.mod h1:eXJuVicxnjRgRMokmutZdistxoMRjBjjfqvrYq7bCIU=
206 github.com/charmbracelet/glamour v0.6.0 h1:wi8fse3Y7nfcabbbDuwolqTqMQPMnVPeZhDM273bISc=
207 github.com/charmbracelet/glamour v0.6.0/go.mod h1:taqWV4swIMMbWALc0m7AfE9JkPSU8om2538k9ITBxOc=
208 github.com/charmbracelet/keygen v0.4.3 h1:ywOZRwkDlpmkawl0BgLTxaYWDSqp6Y4nfVVmgyyO1Mg=
209@@ -29,8 +31,8 @@ github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZ
210 github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c=
211 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35 h1:VXEaJ1iM2L5N8T2WVbv4y631pzCD3O9s75dONqK+87g=
212 github.com/charmbracelet/log v0.2.3-0.20230713155356-557335e40e35/go.mod h1:ZApwwzDbbETVTIRTk7724yQRJAXIktt98yGVMMaa3y8=
213-github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc h1:JUm+5HigAM5utFiThwIDX9iU0BaheKpuNVr+umi3sFg=
214-github.com/charmbracelet/ssh v0.0.0-20230712221603-7e03c5063afc/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
215+github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155 h1:vJqYhlL0doAWQPz+EX/hK5x/ZYguoua773oRz77zYKo=
216+github.com/charmbracelet/ssh v0.0.0-20230720143903-5bdd92839155/go.mod h1:F1vgddWsb/Yr/OZilFeRZEh5sE/qU0Dt1mKkmke6Zvg=
217 github.com/charmbracelet/wish v1.1.1 h1:KdICASKd2oh2JPvk1Z4CJtAi97cFErXF7NKienPICO4=
218 github.com/charmbracelet/wish v1.1.1/go.mod h1:xh4KZpSULw+Xqb9bcbhw92QAinVB75CVLWrFuyY6IVs=
219 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
220@@ -43,6 +45,10 @@ github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E
221 github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
222 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
223 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
224+github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1 h1:mtDjlmloH7ytdblogrMz1/8Hqua1y8B4ID+bh3rvod0=
225+github.com/git-lfs/pktline v0.0.0-20230103162542-ca444d533ef1/go.mod h1:fenKRzpXDjNpsIBhuhUzvjCKlDjKam0boRAenTE0Q6A=
226+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
227+github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
228 github.com/go-git/go-git/v5 v5.7.0 h1:t9AudWVLmqzlo+4bqdf7GY+46SUuRsx59SboFxkq2aE=
229 github.com/go-git/go-git/v5 v5.7.0/go.mod h1:coJHKEOk5kUClpsNlXrUvPrDxY3w3gjHvhcZd8Fodw8=
230 github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
231@@ -124,6 +130,8 @@ github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo
232 github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
233 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
234 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
235+github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
236+github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
237 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
238 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
239 github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
240@@ -145,6 +153,8 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
241 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
242 github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
243 github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
244+github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086 h1:mncRSDOqYCng7jOD+Y6+IivdRI6Kzv2BLWYkWkdQfu0=
245+github.com/rubyist/tracerx v0.0.0-20170927163412-787959303086/go.mod h1:YpdgDXpumPB/+EGmGTYHeiW/0QVFRzBYTNFaxWfPDk4=
246 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
247 github.com/sahilm/fuzzy v0.1.0 h1:FzWGaw2Opqyu+794ZQ9SYifWv2EIXpwP4q8dY1kDAwI=
248 github.com/sahilm/fuzzy v0.1.0/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
249@@ -157,6 +167,7 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
250 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
251 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
252 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
253+github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
254 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
255 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
256 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
257@@ -179,8 +190,8 @@ golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs=
258 golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
259 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
260 golang.org/x/net v0.0.0-20221002022538-bcab6841153b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
261-golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M=
262-golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
263+golang.org/x/net v0.12.0 h1:cfawfvKITfUsFCeJIHJrbSxpeu/E81khclypR0GVT50=
264+golang.org/x/net v0.12.0/go.mod h1:zEVYFnQC7m/vmpQFELhcD1EWkZlX69l4oqgmer6hfKA=
265 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
266 golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
267 golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E=
268@@ -215,6 +226,8 @@ google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
269 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
270 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
271 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
272+gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
273+gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
274 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
275 gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
276 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
277diff --git a/server/backend/backend.go b/server/backend/backend.go
278index 2d6104d56f2a32563c7724ccd762312895c804a2..586d95132504a852dc8fa339de464d3360aca59d 100644
279--- a/server/backend/backend.go
280+++ b/server/backend/backend.go
281@@ -7,7 +7,6 @@ import (
282 "github.com/charmbracelet/soft-serve/server/config"
283 "github.com/charmbracelet/soft-serve/server/db"
284 "github.com/charmbracelet/soft-serve/server/store"
285- "github.com/charmbracelet/soft-serve/server/store/database"
286 )
287
288 // Backend is the Soft Serve backend that handles users, repositories, and
289@@ -23,7 +22,7 @@ type Backend struct {
290
291 // New returns a new Soft Serve backend.
292 func New(ctx context.Context, cfg *config.Config, db *db.DB) *Backend {
293- dbstore := database.New(ctx, db)
294+ dbstore := store.FromContext(ctx)
295 logger := log.FromContext(ctx).WithPrefix("backend")
296 b := &Backend{
297 ctx: ctx,
298diff --git a/server/backend/lfs.go b/server/backend/lfs.go
299new file mode 100644
300index 0000000000000000000000000000000000000000..dfc21ea69d5eb9193d347b39011591f4fd9c9367
301--- /dev/null
302+++ b/server/backend/lfs.go
303@@ -0,0 +1,85 @@
304+package backend
305+
306+import (
307+ "context"
308+ "errors"
309+ "io"
310+ "path"
311+ "path/filepath"
312+
313+ "github.com/charmbracelet/soft-serve/server/config"
314+ "github.com/charmbracelet/soft-serve/server/db"
315+ "github.com/charmbracelet/soft-serve/server/lfs"
316+ "github.com/charmbracelet/soft-serve/server/proto"
317+ "github.com/charmbracelet/soft-serve/server/storage"
318+ "github.com/charmbracelet/soft-serve/server/store"
319+)
320+
321+// StoreRepoMissingLFSObjects stores missing LFS objects for a repository.
322+func StoreRepoMissingLFSObjects(ctx context.Context, repo proto.Repository, dbx *db.DB, store store.Store, lfsClient lfs.Client) error {
323+ cfg := config.FromContext(ctx)
324+ lfsRoot := filepath.Join(cfg.DataPath, "lfs")
325+
326+ // TODO: support S3 storage
327+ strg := storage.NewLocalStorage(lfsRoot)
328+ pointerChan := make(chan lfs.PointerBlob)
329+ errChan := make(chan error, 1)
330+ r, err := repo.Open()
331+ if err != nil {
332+ return err
333+ }
334+
335+ go lfs.SearchPointerBlobs(ctx, r, pointerChan, errChan)
336+
337+ download := func(pointers []lfs.Pointer) error {
338+ return lfsClient.Download(ctx, pointers, func(p lfs.Pointer, content io.ReadCloser, objectError error) error {
339+ if objectError != nil {
340+ return objectError
341+ }
342+
343+ defer content.Close() // nolint: errcheck
344+ return dbx.TransactionContext(ctx, func(tx *db.Tx) error {
345+ if err := store.CreateLFSObject(ctx, tx, repo.ID(), p.Oid, p.Size); err != nil {
346+ return db.WrapError(err)
347+ }
348+
349+ return strg.Put(path.Join("objects", p.RelativePath()), content)
350+ })
351+ })
352+ }
353+
354+ var batch []lfs.Pointer
355+ for pointer := range pointerChan {
356+ obj, err := store.GetLFSObjectByOid(ctx, dbx, repo.ID(), pointer.Oid)
357+ if err != nil && !errors.Is(err, db.ErrRecordNotFound) {
358+ return db.WrapError(err)
359+ }
360+
361+ exist, err := strg.Exists(path.Join("objects", pointer.RelativePath()))
362+ if err != nil {
363+ return err
364+ }
365+
366+ if exist && obj.ID == 0 {
367+ if err := store.CreateLFSObject(ctx, dbx, repo.ID(), pointer.Oid, pointer.Size); err != nil {
368+ return db.WrapError(err)
369+ }
370+ } else {
371+ batch = append(batch, pointer.Pointer)
372+ // Limit batch requests to 20 objects
373+ if len(batch) >= 20 {
374+ if err := download(batch); err != nil {
375+ return err
376+ }
377+
378+ batch = nil
379+ }
380+ }
381+ }
382+
383+ if err, ok := <-errChan; ok {
384+ return err
385+ }
386+
387+ return nil
388+}
389diff --git a/server/backend/repo.go b/server/backend/repo.go
390index ebd846f4e9328ea71fa10ca1b56d592f5a62b2b4..15eaea6ff72ea919d66ff897633471373a7c6048 100644
391--- a/server/backend/repo.go
392+++ b/server/backend/repo.go
393@@ -7,6 +7,7 @@ import (
394 "fmt"
395 "io/fs"
396 "os"
397+ "path"
398 "path/filepath"
399 "time"
400
401@@ -14,7 +15,9 @@ import (
402 "github.com/charmbracelet/soft-serve/server/db"
403 "github.com/charmbracelet/soft-serve/server/db/models"
404 "github.com/charmbracelet/soft-serve/server/hooks"
405+ "github.com/charmbracelet/soft-serve/server/lfs"
406 "github.com/charmbracelet/soft-serve/server/proto"
407+ "github.com/charmbracelet/soft-serve/server/storage"
408 "github.com/charmbracelet/soft-serve/server/utils"
409 )
410
411@@ -103,7 +106,6 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
412 ),
413 },
414 },
415- // Timeout: time.Hour,
416 }
417
418 if err := git.Clone(remote, rp, copts); err != nil {
419@@ -115,13 +117,51 @@ func (d *Backend) ImportRepository(ctx context.Context, name string, remote stri
420 return nil, err
421 }
422
423- return d.CreateRepository(ctx, name, opts)
424+ r, err := d.CreateRepository(ctx, name, opts)
425+ if err != nil {
426+ d.logger.Error("failed to create repository", "err", err, "name", name)
427+ return nil, err
428+ }
429+
430+ rr, err := r.Open()
431+ if err != nil {
432+ d.logger.Error("failed to open repository", "err", err, "path", rp)
433+ return nil, err
434+ }
435+
436+ rcfg, err := rr.Config()
437+ if err != nil {
438+ d.logger.Error("failed to get repository config", "err", err, "path", rp)
439+ return nil, err
440+ }
441+
442+ rcfg.Section("lfs").SetOption("url", remote)
443+
444+ if err := rr.SetConfig(rcfg); err != nil {
445+ d.logger.Error("failed to set repository config", "err", err, "path", rp)
446+ return nil, err
447+ }
448+
449+ endpoint, err := lfs.NewEndpoint(remote)
450+ if err != nil {
451+ d.logger.Error("failed to create lfs endpoint", "err", err, "path", rp)
452+ return nil, err
453+ }
454+
455+ client := lfs.NewClient(endpoint)
456+
457+ if err := StoreRepoMissingLFSObjects(ctx, r, d.db, d.store, client); err != nil {
458+ d.logger.Error("failed to store missing lfs objects", "err", err, "path", rp)
459+ return nil, err
460+ }
461+
462+ return r, nil
463 }
464
465 // DeleteRepository deletes a repository.
466 //
467 // It implements backend.Backend.
468-func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
469+func (d *Backend) DeleteRepository(ctx context.Context, name string, deleteLFS bool) error {
470 name = utils.SanitizeRepo(name)
471 repo := name + ".git"
472 rp := filepath.Join(d.reposPath(), repo)
473@@ -130,6 +170,26 @@ func (d *Backend) DeleteRepository(ctx context.Context, name string) error {
474 // Delete repo from cache
475 defer d.cache.Delete(name)
476
477+ if deleteLFS {
478+ strg := storage.NewLocalStorage(filepath.Join(d.cfg.DataPath, "lfs"))
479+ objs, err := d.store.GetLFSObjectsByName(ctx, tx, name)
480+ if err != nil {
481+ return err
482+ }
483+
484+ for _, obj := range objs {
485+ p := lfs.Pointer{
486+ Oid: obj.Oid,
487+ Size: obj.Size,
488+ }
489+
490+ d.logger.Debug("deleting lfs object", "repo", name, "oid", obj.Oid)
491+ if err := strg.Delete(path.Join("objects", p.RelativePath())); err != nil {
492+ d.logger.Error("failed to delete lfs object", "repo", name, "err", err, "oid", obj.Oid)
493diff --git a/server/backend/user.go b/server/backend/user.go
494index edefaf686e55eea1b08e43e9fb9a5809ba4d61b7..8b5e2a2aeae549bec6d1f19dec0a5e0d00685bbd 100644
495--- a/server/backend/user.go
496+++ b/server/backend/user.go
497@@ -17,8 +17,36 @@ import (
498 //
499 // It implements backend.Backend.
500 func (d *Backend) AccessLevel(ctx context.Context, repo string, username string) access.AccessLevel {
501- anon := d.AnonAccess(ctx)
502 user, _ := d.User(ctx, username)
503+ return d.AccessLevelForUser(ctx, repo, user)
504+}
505+
506+// AccessLevelByPublicKey returns the access level of a user's public key for a repository.
507+//
508+// It implements backend.Backend.
509+func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel {
510+ for _, k := range d.cfg.AdminKeys() {
511+ if sshutils.KeysEqual(pk, k) {
512+ return access.AdminAccess
513+ }
514+ }
515+
516+ user, _ := d.UserByPublicKey(ctx, pk)
517+ if user != nil {
518+ return d.AccessLevel(ctx, repo, user.Username())
519+ }
520+
521+ return d.AccessLevel(ctx, repo, "")
522+}
523+
524+// AccessLevelForUser returns the access level of a user for a repository.
525+func (d *Backend) AccessLevelForUser(ctx context.Context, repo string, user proto.User) access.AccessLevel {
526+ var username string
527+ anon := d.AnonAccess(ctx)
528+ if user != nil {
529+ username = user.Username()
530+ }
531+
532 // If the user is an admin, they have admin access.
533 if user != nil && user.IsAdmin() {
534 return access.AdminAccess
535@@ -58,24 +86,6 @@ func (d *Backend) AccessLevel(ctx context.Context, repo string, username string)
536 return anon
537 }
538
539-// AccessLevelByPublicKey returns the access level of a user's public key for a repository.
540-//
541-// It implements backend.Backend.
542-func (d *Backend) AccessLevelByPublicKey(ctx context.Context, repo string, pk ssh.PublicKey) access.AccessLevel {
543- for _, k := range d.cfg.AdminKeys() {
544- if sshutils.KeysEqual(pk, k) {
545- return access.AdminAccess
546- }
547- }
548-
549- user, _ := d.UserByPublicKey(ctx, pk)
550- if user != nil {
551- return d.AccessLevel(ctx, repo, user.Username())
552- }
553-
554- return d.AccessLevel(ctx, repo, "")
555-}
556-
557 // User finds a user by username.
558 //
559 // It implements backend.Backend.
560@@ -273,17 +283,22 @@ type user struct {
561
562 var _ proto.User = (*user)(nil)
563
564-// IsAdmin implements store.User
565+// IsAdmin implements proto.User
566 func (u *user) IsAdmin() bool {
567 return u.user.Admin
568 }
569
570-// PublicKeys implements store.User
571+// PublicKeys implements proto.User
572 func (u *user) PublicKeys() []ssh.PublicKey {
573 return u.publicKeys
574 }
575
576-// Username implements store.User
577+// Username implements proto.User
578 func (u *user) Username() string {
579 return u.user.Username
580 }
581+
582+// ID implements proto.User.
583+func (u *user) ID() int64 {
584+ return u.user.ID
585+}
586diff --git a/server/daemon/daemon_test.go b/server/daemon/daemon_test.go
587index c11ddefb5648ad306b0489aa0d24a5d136b08b26..f1ad44caaa23b0422c1389d4f9833d2dc80a54e7 100644
588--- a/server/daemon/daemon_test.go
589+++ b/server/daemon/daemon_test.go
590@@ -17,6 +17,8 @@ import (
591 "github.com/charmbracelet/soft-serve/server/db"
592 "github.com/charmbracelet/soft-serve/server/db/migrate"
593 "github.com/charmbracelet/soft-serve/server/git"
594+ "github.com/charmbracelet/soft-serve/server/store"
595+ "github.com/charmbracelet/soft-serve/server/store/database"
596 "github.com/charmbracelet/soft-serve/server/test"
597 "github.com/go-git/go-git/v5/plumbing/format/pktline"
598 _ "modernc.org/sqlite" // sqlite driver
599@@ -41,15 +43,17 @@ func TestMain(m *testing.M) {
600 log.Fatal(err)
601 }
602 ctx = config.WithContext(ctx, cfg)
603- db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
604+ dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
605 if err != nil {
606 log.Fatal(err)
607 }
608- defer db.Close() // nolint: errcheck
609- if err := migrate.Migrate(ctx, db); err != nil {
610+ defer dbx.Close() // nolint: errcheck
611+ if err := migrate.Migrate(ctx, dbx); err != nil {
612 log.Fatal(err)
613 }
614- be := backend.New(ctx, cfg, db)
615+ datastore := database.New(ctx, dbx)
616+ ctx = store.WithContext(ctx, datastore)
617+ be := backend.New(ctx, cfg, dbx)
618 ctx = backend.WithContext(ctx, be)
619 d, err := NewGitDaemon(ctx)
620 if err != nil {
621@@ -68,7 +72,7 @@ func TestMain(m *testing.M) {
622 os.Unsetenv("SOFT_SERVE_GIT_IDLE_TIMEOUT")
623 os.Unsetenv("SOFT_SERVE_GIT_LISTEN_ADDR")
624 _ = d.Close()
625- _ = db.Close()
626+ _ = dbx.Close()
627 os.Exit(code)
628 }
629
630diff --git a/server/db/context.go b/server/db/context.go
631index 17c70ee4978d8b3c97e0a05177d9cd30d82c3ae8..5e289d8df96a0941d4d594aaba3494f553465e24 100644
632--- a/server/db/context.go
633+++ b/server/db/context.go
634@@ -2,11 +2,12 @@ package db
635
636 import "context"
637
638-var contextKey = struct{ string }{"db"}
639+// ContextKey is the key used to store the database in the context.
640+var ContextKey = struct{ string }{"db"}
641
642 // FromContext returns the database from the context.
643 func FromContext(ctx context.Context) *DB {
644- if db, ok := ctx.Value(contextKey).(*DB); ok {
645+ if db, ok := ctx.Value(ContextKey).(*DB); ok {
646 return db
647 }
648 return nil
649@@ -14,5 +15,5 @@ func FromContext(ctx context.Context) *DB {
650
651 // WithContext returns a new context with the database.
652 func WithContext(ctx context.Context, db *DB) context.Context {
653- return context.WithValue(ctx, contextKey, db)
654+ return context.WithValue(ctx, ContextKey, db)
655 }
656diff --git a/server/db/handler.go b/server/db/handler.go
657new file mode 100644
658index 0000000000000000000000000000000000000000..981cadf21ef38275d7e27f6a2539e9b80f2e7a49
659--- /dev/null
660+++ b/server/db/handler.go
661@@ -0,0 +1,25 @@
662+package db
663+
664+import (
665+ "context"
666+ "database/sql"
667+
668+ "github.com/jmoiron/sqlx"
669+)
670+
671+// Handler is a database handler.
672+type Handler interface {
673+ Rebind(string) string
674+
675+ Select(interface{}, string, ...interface{}) error
676+ Get(interface{}, string, ...interface{}) error
677+ Queryx(string, ...interface{}) (*sqlx.Rows, error)
678+ QueryRowx(string, ...interface{}) *sqlx.Row
679+ Exec(string, ...interface{}) (sql.Result, error)
680+
681+ SelectContext(context.Context, interface{}, string, ...interface{}) error
682+ GetContext(context.Context, interface{}, string, ...interface{}) error
683+ QueryxContext(context.Context, string, ...interface{}) (*sqlx.Rows, error)
684+ QueryRowxContext(context.Context, string, ...interface{}) *sqlx.Row
685+ ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
686+}
687diff --git a/server/db/migrate/0002_create_lfs_tables.go b/server/db/migrate/0002_create_lfs_tables.go
688new file mode 100644
689index 0000000000000000000000000000000000000000..8d4ace82c1f425cdf77e0a06059485589e1fba29
690--- /dev/null
691+++ b/server/db/migrate/0002_create_lfs_tables.go
692@@ -0,0 +1,23 @@
693+package migrate
694+
695+import (
696+ "context"
697+
698+ "github.com/charmbracelet/soft-serve/server/db"
699+)
700+
701+const (
702+ createLFSTablesName = "create lfs tables"
703+ createLFSTablesVersion = 2
704+)
705+
706+var createLFSTables = Migration{
707+ Version: createLFSTablesVersion,
708+ Name: createLFSTablesName,
709+ Migrate: func(ctx context.Context, tx *db.Tx) error {
710+ return migrateUp(ctx, tx, createLFSTablesVersion, createLFSTablesName)
711+ },
712+ Rollback: func(ctx context.Context, tx *db.Tx) error {
713+ return migrateDown(ctx, tx, createLFSTablesVersion, createLFSTablesName)
714+ },
715+}
716diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.down.sql b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
717new file mode 100644
718index 0000000000000000000000000000000000000000..bae6ea0cd13f5ccf90114fb2e05146bb822a6658
719--- /dev/null
720+++ b/server/db/migrate/0002_create_lfs_tables_postgres.down.sql
721@@ -0,0 +1,2 @@
722+DROP TABLE IF EXISTS lfs_locks;
723+DROP TABLE IF EXISTS lfs_objects;
724diff --git a/server/db/migrate/0002_create_lfs_tables_postgres.up.sql b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
725new file mode 100644
726index 0000000000000000000000000000000000000000..fed48900f876e09a5777966c82bf088f41809821
727--- /dev/null
728+++ b/server/db/migrate/0002_create_lfs_tables_postgres.up.sql
729@@ -0,0 +1,28 @@
730+CREATE TABLE IF NOT EXISTS lfs_objects (
731+ id SERIAL PRIMARY KEY,
732+ oid TEXT NOT NULL,
733+ size INTEGER NOT NULL,
734+ repo_id INTEGER NOT NULL,
735+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
736+ updated_at DATETIME NOT NULL,
737+ UNIQUE (oid, repo_id),
738+ CONSTRAINT repo_id_fk
739+ FOREIGN KEY(repo_id) REFERENCES repos(id)
740+ ON DELETE CASCADE
741+ ON UPDATE CASCADE
742+);
743+
744+CREATE TABLE IF NOT EXISTS lfs_locks (
745+ id SERIAL PRIMARY KEY,
746+ repo_id INTEGER NOT NULL,
747+ user_id INTEGER NOT NULL,
748+ path TEXT NOT NULL,
749+ refname TEXT,
750+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
751+ updated_at DATETIME NOT NULL,
752+ UNIQUE (repo_id, path),
753+ CONSTRAINT repo_id_fk
754+ FOREIGN KEY(repo_id) REFERENCES repos(id)
755+ ON DELETE CASCADE
756+ ON UPDATE CASCADE
757+);
758diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
759new file mode 100644
760index 0000000000000000000000000000000000000000..bae6ea0cd13f5ccf90114fb2e05146bb822a6658
761--- /dev/null
762+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.down.sql
763@@ -0,0 +1,2 @@
764+DROP TABLE IF EXISTS lfs_locks;
765+DROP TABLE IF EXISTS lfs_objects;
766diff --git a/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
767new file mode 100644
768index 0000000000000000000000000000000000000000..0a43d6849340b93cb0783b1c560128ef005cb83c
769--- /dev/null
770+++ b/server/db/migrate/0002_create_lfs_tables_sqlite.up.sql
771@@ -0,0 +1,28 @@
772+CREATE TABLE IF NOT EXISTS lfs_objects (
773+ id INTEGER PRIMARY KEY AUTOINCREMENT,
774+ oid TEXT NOT NULL,
775+ size INTEGER NOT NULL,
776+ repo_id INTEGER NOT NULL,
777+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
778+ updated_at DATETIME NOT NULL,
779+ UNIQUE (oid, repo_id),
780+ CONSTRAINT repo_id_fk
781+ FOREIGN KEY(repo_id) REFERENCES repos(id)
782+ ON DELETE CASCADE
783+ ON UPDATE CASCADE
784+);
785+
786+CREATE TABLE IF NOT EXISTS lfs_locks (
787+ id INTEGER PRIMARY KEY AUTOINCREMENT,
788+ repo_id INTEGER NOT NULL,
789+ user_id INTEGER NOT NULL,
790+ path TEXT NOT NULL,
791+ refname TEXT,
792+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
793+ updated_at DATETIME NOT NULL,
794+ UNIQUE (repo_id, path),
795+ CONSTRAINT repo_id_fk
796+ FOREIGN KEY(repo_id) REFERENCES repos(id)
797+ ON DELETE CASCADE
798+ ON UPDATE CASCADE
799+);
800diff --git a/server/db/migrate/migrations.go b/server/db/migrate/migrations.go
801index 88a9e434696ecdfe5ccb58d844d1f244d3bbfce5..8935ff05c8bff39197a5514f680a4ad91f85d61b 100644
802--- a/server/db/migrate/migrations.go
803+++ b/server/db/migrate/migrations.go
804@@ -16,6 +16,7 @@ var sqls embed.FS
805 // Keep this in order of execution, oldest to newest.
806 var migrations = []Migration{
807 createTables,
808+ createLFSTables,
809 }
810
811 func execMigration(ctx context.Context, tx *db.Tx, version int, name string, down bool) error {
812diff --git a/server/db/models/lfs.go b/server/db/models/lfs.go
813new file mode 100644
814index 0000000000000000000000000000000000000000..f93ea55bad91ce1b795184a3e4e86754ecfd89aa
815--- /dev/null
816+++ b/server/db/models/lfs.go
817@@ -0,0 +1,24 @@
818+package models
819+
820+import "time"
821+
822+// LFSObject is a Git LFS object.
823+type LFSObject struct {
824+ ID int64 `db:"id"`
825+ Oid string `db:"oid"`
826+ Size int64 `db:"size"`
827+ RepoID int64 `db:"repo_id"`
828+ CreatedAt time.Time `db:"created_at"`
829+ UpdatedAt time.Time `db:"updated_at"`
830+}
831+
832+// LFSLock is a Git LFS lock.
833+type LFSLock struct {
834+ ID int64 `db:"id"`
835+ Path string `db:"path"`
836+ UserID int64 `db:"user_id"`
837+ RepoID int64 `db:"repo_id"`
838+ Refname string `db:"refname"`
839+ CreatedAt time.Time `db:"created_at"`
840+ UpdatedAt time.Time `db:"updated_at"`
841+}
842diff --git a/server/git/lfs.go b/server/git/lfs.go
843new file mode 100644
844index 0000000000000000000000000000000000000000..047c59bbe66ee5896be423bf1342e5c407136351
845--- /dev/null
846+++ b/server/git/lfs.go
847@@ -0,0 +1,451 @@
848+package git
849+
850+import (
851+ "context"
852+ "crypto/rand"
853+ "errors"
854+ "fmt"
855+ "io"
856+ "io/fs"
857+ "path"
858+ "path/filepath"
859+ "strconv"
860+ "time"
861+
862+ "github.com/charmbracelet/git-lfs-transfer/transfer"
863+ "github.com/charmbracelet/log"
864+ "github.com/charmbracelet/soft-serve/server/backend"
865+ "github.com/charmbracelet/soft-serve/server/config"
866+ "github.com/charmbracelet/soft-serve/server/db"
867+ "github.com/charmbracelet/soft-serve/server/db/models"
868+ "github.com/charmbracelet/soft-serve/server/proto"
869+ "github.com/charmbracelet/soft-serve/server/storage"
870+ "github.com/charmbracelet/soft-serve/server/store"
871+ "github.com/charmbracelet/soft-serve/server/utils"
872+ "github.com/rubyist/tracerx"
873+)
874+
875+func init() {
876+ // git-lfs-transfer uses tracerx for logging.
877+ // use a custom key to avoid conflicts
878+ // SOFT_SERVE_TRACE=1 to enable tracing git-lfs-transfer in soft-serve
879+ tracerx.DefaultKey = "SOFT_SERVE"
880+ tracerx.Prefix = "trace soft-serve-lfs-transfer: "
881+}
882+
883+// lfsTransfer implements transfer.Backend.
884+type lfsTransfer struct {
885+ ctx context.Context
886+ cfg *config.Config
887+ dbx *db.DB
888+ store store.Store
889+ logger *log.Logger
890+ storage storage.Storage
891+ repo proto.Repository
892+}
893+
894+var _ transfer.Backend = &lfsTransfer{}
895+
896+// LFSTransfer is a Git LFS transfer service handler.
897+// ctx is expected to have proto.User, *backend.Backend, *log.Logger,
898+// *config.Config, *db.DB, and store.Store.
899+// The first arg in cmd.Args should be the repo path.
900+// The second arg in cmd.Args should be the LFS operation (download or upload).
901+func LFSTransfer(ctx context.Context, cmd ServiceCommand) error {
902+ if len(cmd.Args) < 2 {
903+ return errors.New("missing args")
904+ }
905+
906+ logger := log.FromContext(ctx).WithPrefix("lfs-transfer")
907+ handler := transfer.NewPktline(cmd.Stdin, cmd.Stdout)
908+ be := backend.FromContext(ctx)
909+ repoName := cmd.Args[0]
910+ repoName = utils.SanitizeRepo(repoName)
911+ op := cmd.Args[1]
912+
913+ repo, err := be.Repository(ctx, repoName)
914+ if err != nil {
915+ logger.Errorf("error getting repo: %v", err)
916+ return err
917+ }
918+
919+ ctx = context.WithValue(ctx, proto.ContextKeyRepository, repo)
920+
921+ // Advertise capabilities.
922+ for _, cap := range []string{
923+ "version=1",
924+ "locking",
925+ } {
926+ if err := handler.WritePacketText(cap); err != nil {
927+ logger.Errorf("error sending capability: %s: %v", cap, err)
928+ return err
929+ }
930+ }
931+
932+ if err := handler.WriteFlush(); err != nil {
933+ logger.Error("error sending flush", "err", err)
934+ return err
935+ }
936+
937+ cfg := config.FromContext(ctx)
938+ processor := transfer.NewProcessor(handler, &lfsTransfer{
939+ ctx: ctx,
940+ cfg: cfg,
941+ dbx: db.FromContext(ctx),
942+ store: store.FromContext(ctx),
943+ logger: logger,
944+ storage: storage.NewLocalStorage(filepath.Join(cfg.DataPath, "lfs")),
945+ repo: repo,
946+ })
947diff --git a/server/git/service.go b/server/git/service.go
948index b2cd6aa26b294a95d38d072e45054358be56bacd..51a53a0c887b56c4596ecaaaa90fed1617d1c161 100644
949--- a/server/git/service.go
950+++ b/server/git/service.go
951@@ -23,6 +23,9 @@ const (
952 UploadArchiveService Service = "git-upload-archive"
953 // ReceivePackService is the receive-pack service.
954 ReceivePackService Service = "git-receive-pack"
955+ // LFSTransferService is the LFS transfer service.
956+ LFSTransferService Service = "git-lfs-transfer"
957+ // TODO: add support for git-lfs-authenticate
958 )
959
960 // String returns the string representation of the service.
961@@ -40,6 +43,8 @@ func (s Service) Handler(ctx context.Context, cmd ServiceCommand) error {
962 switch s {
963 case UploadPackService, UploadArchiveService, ReceivePackService:
964 return gitServiceHandler(ctx, s, cmd)
965+ case LFSTransferService:
966+ return LFSTransfer(ctx, cmd)
967 default:
968 return fmt.Errorf("unsupported service: %s", s)
969 }
970@@ -57,6 +62,8 @@ func gitServiceHandler(ctx context.Context, svc Service, scmd ServiceCommand) er
971 "-c", "uploadpack.allowFilter=true",
972 // Enable push options
973 "-c", "receive.advertisePushOptions=true",
974+ // Disable LFS filters
975+ "-c", "filter.lfs.required=", "-c", "filter.lfs.smudge=", "-c", "filter.lfs.clean=",
976 svc.Name(),
977 }...)
978 if len(scmd.Args) > 0 {
979diff --git a/server/lfs/basic_transfer.go b/server/lfs/basic_transfer.go
980new file mode 100644
981index 0000000000000000000000000000000000000000..609197c1035c176f6d31495d3a8cf57de7f5830c
982--- /dev/null
983+++ b/server/lfs/basic_transfer.go
984@@ -0,0 +1,124 @@
985+package lfs
986+
987+import (
988+ "bytes"
989+ "context"
990+ "encoding/json"
991+ "errors"
992+ "fmt"
993+ "io"
994+ "net/http"
995+
996+ "github.com/charmbracelet/log"
997+)
998+
999+// BasicTransferAdapter implements the "basic" adapter
1000+type BasicTransferAdapter struct {
1001+ client *http.Client
1002+}
1003+
1004+// Name returns the name of the adapter
1005+func (a *BasicTransferAdapter) Name() string {
1006+ return "basic"
1007+}
1008+
1009+// Download reads the download location and downloads the data
1010+func (a *BasicTransferAdapter) Download(ctx context.Context, _ Pointer, l *Link) (io.ReadCloser, error) {
1011+ resp, err := a.performRequest(ctx, "GET", l, nil, nil)
1012+ if err != nil {
1013+ return nil, err
1014+ }
1015+ return resp.Body, nil
1016+}
1017+
1018+// Upload sends the content to the LFS server
1019+func (a *BasicTransferAdapter) Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error {
1020+ res, err := a.performRequest(ctx, "PUT", l, r, func(req *http.Request) {
1021+ if len(req.Header.Get("Content-Type")) == 0 {
1022+ req.Header.Set("Content-Type", "application/octet-stream")
1023+ }
1024+
1025+ if req.Header.Get("Transfer-Encoding") == "chunked" {
1026+ req.TransferEncoding = []string{"chunked"}
1027+ }
1028+
1029+ req.ContentLength = p.Size
1030+ })
1031+ if err != nil {
1032+ return err
1033+ }
1034+ return res.Body.Close()
1035+}
1036+
1037+// Verify calls the verify handler on the LFS server
1038+func (a *BasicTransferAdapter) Verify(ctx context.Context, p Pointer, l *Link) error {
1039+ logger := log.FromContext(ctx).WithPrefix("lfs")
1040+ b, err := json.Marshal(p)
1041+ if err != nil {
1042+ logger.Errorf("Error encoding json: %v", err)
1043+ return err
1044+ }
1045+
1046+ res, err := a.performRequest(ctx, "POST", l, bytes.NewReader(b), func(req *http.Request) {
1047+ req.Header.Set("Content-Type", MediaType)
1048+ })
1049+ if err != nil {
1050+ return err
1051+ }
1052+ return res.Body.Close()
1053+}
1054+
1055+func (a *BasicTransferAdapter) performRequest(ctx context.Context, method string, l *Link, body io.Reader, callback func(*http.Request)) (*http.Response, error) {
1056+ logger := log.FromContext(ctx).WithPrefix("lfs")
1057+ logger.Debugf("Calling: %s %s", method, l.Href)
1058+
1059+ req, err := http.NewRequestWithContext(ctx, method, l.Href, body)
1060+ if err != nil {
1061+ logger.Errorf("Error creating request: %v", err)
1062+ return nil, err
1063+ }
1064+ for key, value := range l.Header {
1065+ req.Header.Set(key, value)
1066+ }
1067+ req.Header.Set("Accept", MediaType)
1068+
1069+ if callback != nil {
1070+ callback(req)
1071+ }
1072+
1073+ res, err := a.client.Do(req)
1074+ if err != nil {
1075+ select {
1076+ case <-ctx.Done():
1077+ return res, ctx.Err()
1078+ default:
1079+ }
1080+ logger.Errorf("Error while processing request: %v", err)
1081+ return res, err
1082+ }
1083+
1084diff --git a/server/lfs/client.go b/server/lfs/client.go
1085new file mode 100644
1086index 0000000000000000000000000000000000000000..9cc9da0a15a115f82373fa8da50e8da2a504b1dc
1087--- /dev/null
1088+++ b/server/lfs/client.go
1089@@ -0,0 +1,27 @@
1090+package lfs
1091+
1092+import (
1093+ "context"
1094+ "io"
1095+)
1096+
1097+// DownloadCallback gets called for every requested LFS object to process its content
1098+type DownloadCallback func(p Pointer, content io.ReadCloser, objectError error) error
1099+
1100+// UploadCallback gets called for every requested LFS object to provide its content
1101+type UploadCallback func(p Pointer, objectError error) (io.ReadCloser, error)
1102+
1103+// Client is a Git LFS client to communicate with a LFS source API.
1104+type Client interface {
1105+ Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error
1106+ Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error
1107+}
1108+
1109+// NewClient returns a new Git LFS client.
1110+func NewClient(e Endpoint) Client {
1111+ if e.Scheme == "http" || e.Scheme == "https" {
1112+ return newHTTPClient(e)
1113+ }
1114+ // TODO: support ssh client
1115+ return nil
1116+}
1117diff --git a/server/lfs/common.go b/server/lfs/common.go
1118new file mode 100644
1119index 0000000000000000000000000000000000000000..1bd2473068ab09f69b829ce5b292c50d9dd08097
1120--- /dev/null
1121+++ b/server/lfs/common.go
1122@@ -0,0 +1,88 @@
1123+package lfs
1124+
1125+import "time"
1126+
1127+const (
1128+ // MediaType contains the media type for LFS server requests.
1129+ MediaType = "application/vnd.git-lfs+json"
1130+
1131+ // OperationDownload is the operation name for a download request.
1132+ OperationDownload = "download"
1133+
1134+ // OperationUpload is the operation name for an upload request.
1135+ OperationUpload = "upload"
1136+
1137+ // ActionDownload is the action name for a download request.
1138+ ActionDownload = OperationDownload
1139+
1140+ // ActionUpload is the action name for an upload request.
1141+ ActionUpload = OperationUpload
1142+
1143+ // ActionVerify is the action name for a verify request.
1144+ ActionVerify = "verify"
1145+)
1146+
1147+// Pointer contains LFS pointer data
1148+type Pointer struct {
1149+ Oid string `json:"oid"`
1150+ Size int64 `json:"size"`
1151+}
1152+
1153+// PointerBlob associates a Git blob with a Pointer.
1154+type PointerBlob struct {
1155+ Hash string
1156+ Pointer
1157+}
1158+
1159+// ErrorResponse describes the error to the client.
1160+type ErrorResponse struct {
1161+ Message string `json:"message,omitempty"`
1162+ DocumentationURL string `json:"documentation_url,omitempty"`
1163+ RequestID string `json:"request_id,omitempty"`
1164+}
1165+
1166+// BatchResponse contains multiple object metadata Representation structures
1167+// for use with the batch API.
1168+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#successful-responses
1169+type BatchResponse struct {
1170+ Transfer string `json:"transfer,omitempty"`
1171+ Objects []*ObjectResponse `json:"objects"`
1172+ HashAlgo string `json:"hash_algo,omitempty"`
1173+}
1174+
1175+// ObjectResponse is object metadata as seen by clients of the LFS server.
1176+type ObjectResponse struct {
1177+ Pointer
1178+ Actions map[string]*Link `json:"actions,omitempty"`
1179+ Error *ObjectError `json:"error,omitempty"`
1180+}
1181+
1182+// Link provides a structure with information about how to access a object.
1183+type Link struct {
1184+ Href string `json:"href"`
1185+ Header map[string]string `json:"header,omitempty"`
1186+ ExpiresAt *time.Time `json:"expires_at,omitempty"`
1187+ ExpiresIn *time.Duration `json:"expires_in,omitempty"`
1188+}
1189+
1190+// ObjectError defines the JSON structure returned to the client in case of an error.
1191+type ObjectError struct {
1192+ Code int `json:"code"`
1193+ Message string `json:"message"`
1194+}
1195+
1196+// BatchRequest contains multiple requests processed in one batch operation.
1197+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#requests
1198+type BatchRequest struct {
1199+ Operation string `json:"operation"`
1200+ Transfers []string `json:"transfers,omitempty"`
1201+ Ref *Reference `json:"ref,omitempty"`
1202+ Objects []Pointer `json:"objects"`
1203+ HashAlgo string `json:"hash_algo,omitempty"`
1204+}
1205+
1206+// Reference contains a git reference.
1207+// https://github.com/git-lfs/git-lfs/blob/main/docs/api/batch.md#ref-property
1208+type Reference struct {
1209+ Name string `json:"name"`
1210+}
1211diff --git a/server/lfs/endpoint.go b/server/lfs/endpoint.go
1212new file mode 100644
1213index 0000000000000000000000000000000000000000..53e89e1703649fa137c017c72ddb1273c9b66026
1214--- /dev/null
1215+++ b/server/lfs/endpoint.go
1216@@ -0,0 +1,70 @@
1217+package lfs
1218+
1219+import (
1220+ "fmt"
1221+ "net/url"
1222+ "strings"
1223+)
1224+
1225+// Endpoint is a Git LFS endpoint.
1226+type Endpoint = *url.URL
1227+
1228+// NewEndpoint returns a new Git LFS endpoint.
1229+func NewEndpoint(rawurl string) (Endpoint, error) {
1230+ u, err := url.Parse(rawurl)
1231+ if err != nil {
1232+ e, err := endpointFromBareSSH(rawurl)
1233+ if err != nil {
1234+ return nil, err
1235+ }
1236+ u = e
1237+ }
1238+
1239+ u.Path = strings.TrimSuffix(u.Path, "/")
1240+
1241+ switch u.Scheme {
1242+ case "git":
1243+ // Use https for git:// URLs and strip the port if it exists.
1244+ u.Scheme = "https"
1245+ if u.Port() != "" {
1246+ u.Host = u.Hostname()
1247+ }
1248+ fallthrough
1249+ case "http", "https":
1250+ if strings.HasSuffix(u.Path, ".git") {
1251+ u.Path += "/info/lfs"
1252+ } else {
1253+ u.Path += ".git/info/lfs"
1254+ }
1255+ case "ssh", "git+ssh", "ssh+git":
1256+ default:
1257+ return nil, fmt.Errorf("unknown url: %s", rawurl)
1258+ }
1259+
1260+ return u, nil
1261+}
1262+
1263+// endpointFromBareSSH creates a new endpoint from a bare ssh repo.
1264+//
1265+// user@host.com:path/to/repo.git or
1266+// [user@host.com:port]:path/to/repo.git
1267+func endpointFromBareSSH(rawurl string) (*url.URL, error) {
1268+ parts := strings.Split(rawurl, ":")
1269+ partsLen := len(parts)
1270+ if partsLen < 2 {
1271+ return url.Parse(rawurl)
1272+ }
1273+
1274+ // Treat presence of ':' as a bare URL
1275+ var newPath string
1276+ if len(parts) > 2 { // port included; really should only ever be 3 parts
1277+ // Correctly handle [host:port]:path URLs
1278+ parts[0] = strings.TrimPrefix(parts[0], "[")
1279+ parts[1] = strings.TrimSuffix(parts[1], "]")
1280+ newPath = fmt.Sprintf("%v:%v", parts[0], strings.Join(parts[1:], "/"))
1281+ } else {
1282+ newPath = strings.Join(parts, "/")
1283+ }
1284+ newrawurl := fmt.Sprintf("ssh://%v", newPath)
1285+ return url.Parse(newrawurl)
1286+}
1287diff --git a/server/lfs/http_client.go b/server/lfs/http_client.go
1288new file mode 100644
1289index 0000000000000000000000000000000000000000..a8b55031f083d1fd5e4b9aaccd896fb50013f41c
1290--- /dev/null
1291+++ b/server/lfs/http_client.go
1292@@ -0,0 +1,196 @@
1293+package lfs
1294+
1295+import (
1296+ "bytes"
1297+ "context"
1298+ "encoding/json"
1299+ "errors"
1300+ "fmt"
1301+ "net/http"
1302+
1303+ "github.com/charmbracelet/log"
1304+)
1305+
1306+// httpClient is a Git LFS client to communicate with a LFS source API.
1307+type httpClient struct {
1308+ client *http.Client
1309+ endpoint Endpoint
1310+ transfers map[string]TransferAdapter
1311+}
1312+
1313+var _ Client = (*httpClient)(nil)
1314+
1315+// newHTTPClient returns a new Git LFS client.
1316+func newHTTPClient(endpoint Endpoint) *httpClient {
1317+ return &httpClient{
1318+ client: http.DefaultClient,
1319+ endpoint: endpoint,
1320+ transfers: map[string]TransferAdapter{
1321+ TransferBasic: &BasicTransferAdapter{http.DefaultClient},
1322+ },
1323+ }
1324+}
1325+
1326+// Download implements Client.
1327+func (c *httpClient) Download(ctx context.Context, objects []Pointer, callback DownloadCallback) error {
1328+ return c.performOperation(ctx, objects, callback, nil)
1329+}
1330+
1331+// Upload implements Client.
1332+func (c *httpClient) Upload(ctx context.Context, objects []Pointer, callback UploadCallback) error {
1333+ return c.performOperation(ctx, objects, nil, callback)
1334+}
1335+
1336+func (c *httpClient) transferNames() []string {
1337+ names := make([]string, len(c.transfers))
1338+ i := 0
1339+ for name := range c.transfers {
1340+ names[i] = name
1341+ i++
1342+ }
1343+ return names
1344+}
1345+
1346+// batch performs a batch request to the LFS server.
1347+func (c *httpClient) batch(ctx context.Context, operation string, objects []Pointer) (*BatchResponse, error) {
1348+ logger := log.FromContext(ctx).WithPrefix("lfs")
1349+ url := fmt.Sprintf("%s/objects/batch", c.endpoint.String())
1350+
1351+ // TODO: support ref
1352+ request := &BatchRequest{operation, c.transferNames(), nil, objects, HashAlgorithmSHA256}
1353+
1354+ payload := new(bytes.Buffer)
1355+ err := json.NewEncoder(payload).Encode(request)
1356+ if err != nil {
1357+ logger.Errorf("Error encoding json: %v", err)
1358+ return nil, err
1359+ }
1360+
1361+ logger.Debugf("Calling: %s", url)
1362+
1363+ req, err := http.NewRequestWithContext(ctx, "POST", url, payload)
1364+ if err != nil {
1365+ logger.Errorf("Error creating request: %v", err)
1366+ return nil, err
1367+ }
1368+ req.Header.Set("Content-type", MediaType)
1369+ req.Header.Set("Accept", MediaType)
1370+
1371+ res, err := c.client.Do(req)
1372+ if err != nil {
1373+ select {
1374+ case <-ctx.Done():
1375+ return nil, ctx.Err()
1376+ default:
1377+ }
1378+ logger.Errorf("Error while processing request: %v", err)
1379+ return nil, err
1380+ }
1381+ defer res.Body.Close() // nolint: errcheck
1382+
1383+ if res.StatusCode != http.StatusOK {
1384+ return nil, fmt.Errorf("Unexpected server response: %s", res.Status)
1385+ }
1386+
1387+ var response BatchResponse
1388+ err = json.NewDecoder(res.Body).Decode(&response)
1389+ if err != nil {
1390+ logger.Errorf("Error decoding json: %v", err)
1391+ return nil, err
1392diff --git a/server/lfs/pointer.go b/server/lfs/pointer.go
1393new file mode 100644
1394index 0000000000000000000000000000000000000000..b38d04ce59b67ac7e5021a0d8bd33a5cc04b077e
1395--- /dev/null
1396+++ b/server/lfs/pointer.go
1397@@ -0,0 +1,122 @@
1398+package lfs
1399+
1400+import (
1401+ "crypto/sha256"
1402+ "encoding/hex"
1403+ "errors"
1404+ "fmt"
1405+ "io"
1406+ "path"
1407+ "regexp"
1408+ "strconv"
1409+ "strings"
1410+)
1411+
1412+const (
1413+ blobSizeCutoff = 1024
1414+
1415+ // HashAlgorithmSHA256 is the hash algorithm used for Git LFS.
1416+ HashAlgorithmSHA256 = "sha256"
1417+
1418+ // MetaFileIdentifier is the string appearing at the first line of LFS pointer files.
1419+ // https://github.com/git-lfs/git-lfs/blob/master/docs/spec.md
1420+ MetaFileIdentifier = "version https://git-lfs.github.com/spec/v1"
1421+
1422+ // MetaFileOidPrefix appears in LFS pointer files on a line before the sha256 hash.
1423+ MetaFileOidPrefix = "oid " + HashAlgorithmSHA256 + ":"
1424+)
1425+
1426+var (
1427+ // ErrMissingPrefix occurs if the content lacks the LFS prefix
1428+ ErrMissingPrefix = errors.New("Content lacks the LFS prefix")
1429+
1430+ // ErrInvalidStructure occurs if the content has an invalid structure
1431+ ErrInvalidStructure = errors.New("Content has an invalid structure")
1432+
1433+ // ErrInvalidOIDFormat occurs if the oid has an invalid format
1434+ ErrInvalidOIDFormat = errors.New("OID has an invalid format")
1435+)
1436+
1437+// ReadPointer tries to read LFS pointer data from the reader
1438+func ReadPointer(reader io.Reader) (Pointer, error) {
1439+ buf := make([]byte, blobSizeCutoff)
1440+ n, err := io.ReadFull(reader, buf)
1441+ if err != nil && err != io.ErrUnexpectedEOF {
1442+ return Pointer{}, err
1443+ }
1444+ buf = buf[:n]
1445+
1446+ return ReadPointerFromBuffer(buf)
1447+}
1448+
1449+var oidPattern = regexp.MustCompile(`^[a-f\d]{64}$`)
1450+
1451+// ReadPointerFromBuffer will return a pointer if the provided byte slice is a pointer file or an error otherwise.
1452+func ReadPointerFromBuffer(buf []byte) (Pointer, error) {
1453+ var p Pointer
1454+
1455+ headString := string(buf)
1456+ if !strings.HasPrefix(headString, MetaFileIdentifier) {
1457+ return p, ErrMissingPrefix
1458+ }
1459+
1460+ splitLines := strings.Split(headString, "\n")
1461+ if len(splitLines) < 3 {
1462+ return p, ErrInvalidStructure
1463+ }
1464+
1465+ oid := strings.TrimPrefix(splitLines[1], MetaFileOidPrefix)
1466+ if len(oid) != 64 || !oidPattern.MatchString(oid) {
1467+ return p, ErrInvalidOIDFormat
1468+ }
1469+ size, err := strconv.ParseInt(strings.TrimPrefix(splitLines[2], "size "), 10, 64)
1470+ if err != nil {
1471+ return p, err
1472+ }
1473+
1474+ p.Oid = oid
1475+ p.Size = size
1476+
1477+ return p, nil
1478+}
1479+
1480+// IsValid checks if the pointer has a valid structure.
1481+// It doesn't check if the pointed-to-content exists.
1482+func (p Pointer) IsValid() bool {
1483+ if len(p.Oid) != 64 {
1484+ return false
1485+ }
1486+ if !oidPattern.MatchString(p.Oid) {
1487+ return false
1488+ }
1489+ if p.Size < 0 {
1490+ return false
1491+ }
1492+ return true
1493+}
1494+
1495+// String returns the string representation of the pointer
1496+// https://github.com/git-lfs/git-lfs/blob/main/docs/spec.md#the-pointer
1497diff --git a/server/lfs/scanner.go b/server/lfs/scanner.go
1498new file mode 100644
1499index 0000000000000000000000000000000000000000..1eba4a4d31dfc224728c977efa3f7c4533b95f56
1500--- /dev/null
1501+++ b/server/lfs/scanner.go
1502@@ -0,0 +1,210 @@
1503+package lfs
1504+
1505+import (
1506+ "bufio"
1507+ "bytes"
1508+ "context"
1509+ "fmt"
1510+ "io"
1511+ "strconv"
1512+ "strings"
1513+ "sync"
1514+
1515+ "github.com/charmbracelet/soft-serve/git"
1516+ gitm "github.com/gogs/git-module"
1517+)
1518+
1519+// SearchPointerBlobs scans the whole repository for LFS pointer files
1520+func SearchPointerBlobs(ctx context.Context, repo *git.Repository, pointerChan chan<- PointerBlob, errChan chan<- error) {
1521+ basePath := repo.Path
1522+
1523+ catFileCheckReader, catFileCheckWriter := io.Pipe()
1524+ shasToBatchReader, shasToBatchWriter := io.Pipe()
1525+ catFileBatchReader, catFileBatchWriter := io.Pipe()
1526+
1527+ wg := sync.WaitGroup{}
1528+ wg.Add(6)
1529+
1530+ // Create the go-routines in reverse order.
1531+
1532+ // 4. Take the output of cat-file --batch and check if each file in turn
1533+ // to see if they're pointers to files in the LFS store
1534+ go createPointerResultsFromCatFileBatch(ctx, catFileBatchReader, &wg, pointerChan)
1535+
1536+ // 3. Take the shas of the blobs and batch read them
1537+ go catFileBatch(ctx, shasToBatchReader, catFileBatchWriter, &wg, basePath)
1538+
1539+ // 2. From the provided objects restrict to blobs <=1k
1540+ go blobsLessThan1024FromCatFileBatchCheck(catFileCheckReader, shasToBatchWriter, &wg)
1541+
1542+ // 1. Run batch-check on all objects in the repository
1543+ revListReader, revListWriter := io.Pipe()
1544+ shasToCheckReader, shasToCheckWriter := io.Pipe()
1545+ go catFileBatchCheck(ctx, shasToCheckReader, catFileCheckWriter, &wg, basePath)
1546+ go blobsFromRevListObjects(revListReader, shasToCheckWriter, &wg)
1547+ go revListAllObjects(ctx, revListWriter, &wg, basePath, errChan)
1548+ wg.Wait()
1549+
1550+ close(pointerChan)
1551+ close(errChan)
1552+}
1553+
1554+func createPointerResultsFromCatFileBatch(ctx context.Context, catFileBatchReader *io.PipeReader, wg *sync.WaitGroup, pointerChan chan<- PointerBlob) {
1555+ defer wg.Done()
1556+ defer catFileBatchReader.Close() // nolint: errcheck
1557+
1558+ bufferedReader := bufio.NewReader(catFileBatchReader)
1559+ buf := make([]byte, 1025)
1560+
1561+loop:
1562+ for {
1563+ select {
1564+ case <-ctx.Done():
1565+ break loop
1566+ default:
1567+ }
1568+
1569+ // File descriptor line: sha
1570+ sha, err := bufferedReader.ReadString(' ')
1571+ if err != nil {
1572+ _ = catFileBatchReader.CloseWithError(err)
1573+ break
1574+ }
1575+ sha = strings.TrimSpace(sha)
1576+ // Throw away the blob
1577+ if _, err := bufferedReader.ReadString(' '); err != nil {
1578+ _ = catFileBatchReader.CloseWithError(err)
1579+ break
1580+ }
1581+ sizeStr, err := bufferedReader.ReadString('\n')
1582+ if err != nil {
1583+ _ = catFileBatchReader.CloseWithError(err)
1584+ break
1585+ }
1586+ size, err := strconv.Atoi(sizeStr[:len(sizeStr)-1])
1587+ if err != nil {
1588+ _ = catFileBatchReader.CloseWithError(err)
1589+ break
1590+ }
1591+ pointerBuf := buf[:size+1]
1592+ if _, err := io.ReadFull(bufferedReader, pointerBuf); err != nil {
1593+ _ = catFileBatchReader.CloseWithError(err)
1594+ break
1595+ }
1596+ pointerBuf = pointerBuf[:size]
1597+ // Now we need to check if the pointerBuf is an LFS pointer
1598+ pointer, _ := ReadPointerFromBuffer(pointerBuf)
1599+ if !pointer.IsValid() {
1600+ continue
1601+ }
1602diff --git a/server/lfs/ssh_client.go b/server/lfs/ssh_client.go
1603new file mode 100644
1604index 0000000000000000000000000000000000000000..ba3e2471078f11c9a38fecb1d4511b5f754e3e3e
1605--- /dev/null
1606+++ b/server/lfs/ssh_client.go
1607@@ -0,0 +1,3 @@
1608+package lfs
1609+
1610+// TODO: implement Git LFS SSH client.
1611diff --git a/server/lfs/transfer.go b/server/lfs/transfer.go
1612new file mode 100644
1613index 0000000000000000000000000000000000000000..478568836acf352bf5556532ce9b9e95479c1003
1614--- /dev/null
1615+++ b/server/lfs/transfer.go
1616@@ -0,0 +1,17 @@
1617+package lfs
1618+
1619+import (
1620+ "context"
1621+ "io"
1622+)
1623+
1624+// TransferBasic is the name of the Git LFS basic transfer protocol.
1625+const TransferBasic = "basic"
1626+
1627+// TransferAdapter represents an adapter for downloading/uploading LFS objects
1628+type TransferAdapter interface {
1629+ Name() string
1630+ Download(ctx context.Context, p Pointer, l *Link) (io.ReadCloser, error)
1631+ Upload(ctx context.Context, p Pointer, r io.Reader, l *Link) error
1632+ Verify(ctx context.Context, p Pointer, l *Link) error
1633+}
1634diff --git a/server/proto/repo.go b/server/proto/repo.go
1635index 68d88741bce6cadc07ba2c4b685e364c53d03ff6..e721d44a2b115165d13502992a04b8287a1a7b26 100644
1636--- a/server/proto/repo.go
1637+++ b/server/proto/repo.go
1638@@ -6,8 +6,13 @@ import (
1639 "github.com/charmbracelet/soft-serve/git"
1640 )
1641
1642+// ContextKeyRepository is the context key for the repository.
1643+var ContextKeyRepository = &struct{ string }{"repository"}
1644+
1645 // Repository is a Git repository interface.
1646 type Repository interface {
1647+ // ID returns the repository's ID.
1648+ ID() int64
1649 // Name returns the repository's name.
1650 Name() string
1651 // ProjectName returns the repository's project name.
1652diff --git a/server/proto/user.go b/server/proto/user.go
1653index 6276a14b7fe13497a3b802dcccca677eebd19c97..f8fd65a1479ccbd9736e9c4af353b303a74402eb 100644
1654--- a/server/proto/user.go
1655+++ b/server/proto/user.go
1656@@ -2,8 +2,13 @@ package proto
1657
1658 import "golang.org/x/crypto/ssh"
1659
1660+// ContextKeyUser is the context key for the user.
1661+var ContextKeyUser = &struct{ string }{"user"}
1662+
1663 // User is an interface representing a user.
1664 type User interface {
1665+ // ID returns the user's ID.
1666+ ID() int64
1667 // Username returns the user's username.
1668 Username() string
1669 // IsAdmin returns whether the user is an admin.
1670diff --git a/server/ssh/cmd/delete.go b/server/ssh/cmd/delete.go
1671index 02dff775d469753744f051c89c2887ce3bb3d367..b719ff53721f6cdd4a4cce0d3a4aab17ff8c99d5 100644
1672--- a/server/ssh/cmd/delete.go
1673+++ b/server/ssh/cmd/delete.go
1674@@ -6,6 +6,8 @@ import (
1675 )
1676
1677 func deleteCommand() *cobra.Command {
1678+ var lfs bool
1679+
1680 cmd := &cobra.Command{
1681 Use: "delete REPOSITORY",
1682 Aliases: []string{"del", "remove", "rm"},
1683@@ -17,8 +19,11 @@ func deleteCommand() *cobra.Command {
1684 be := backend.FromContext(ctx)
1685 name := args[0]
1686
1687- return be.DeleteRepository(ctx, name)
1688+ return be.DeleteRepository(ctx, name, lfs)
1689 },
1690 }
1691+
1692+ cmd.Flags().BoolVarP(&lfs, "lfs", "", false, "Delete LFS objects")
1693+
1694 return cmd
1695 }
1696diff --git a/server/ssh/git.go b/server/ssh/git.go
1697index 051dc25921ad137d89dacdc44501ab8e70637369..d2a030f9ded5d6ef644a363b1c84fcb1e56bd835 100644
1698--- a/server/ssh/git.go
1699+++ b/server/ssh/git.go
1700@@ -10,6 +10,7 @@ import (
1701 "github.com/charmbracelet/soft-serve/server/backend"
1702 "github.com/charmbracelet/soft-serve/server/config"
1703 "github.com/charmbracelet/soft-serve/server/git"
1704+ "github.com/charmbracelet/soft-serve/server/lfs"
1705 "github.com/charmbracelet/soft-serve/server/proto"
1706 "github.com/charmbracelet/soft-serve/server/sshutils"
1707 "github.com/charmbracelet/soft-serve/server/utils"
1708@@ -24,11 +25,17 @@ func handleGit(s ssh.Session) {
1709 cmdLine := s.Command()
1710 start := time.Now()
1711
1712+ var username string
1713+ user := ctx.Value(proto.ContextKeyUser).(proto.User)
1714+ if user != nil {
1715+ username = user.Username()
1716+ }
1717+
1718 // repo should be in the form of "repo.git"
1719 name := utils.SanitizeRepo(cmdLine[1])
1720 pk := s.PublicKey()
1721 ak := sshutils.MarshalAuthorizedKey(pk)
1722- accessLevel := be.AccessLevelByPublicKey(ctx, name, pk)
1723+ accessLevel := be.AccessLevelForUser(ctx, name, user)
1724 // git bare repositories should end in ".git"
1725 // https://git-scm.com/docs/gitrepository-layout
1726 repo := name + ".git"
1727@@ -43,7 +50,7 @@ func handleGit(s ssh.Session) {
1728 "SOFT_SERVE_REPO_NAME=" + name,
1729 "SOFT_SERVE_REPO_PATH=" + filepath.Join(reposDir, repo),
1730 "SOFT_SERVE_PUBLIC_KEY=" + ak,
1731- "SOFT_SERVE_USERNAME=" + s.User(),
1732+ "SOFT_SERVE_USERNAME=" + username,
1733 "SOFT_SERVE_LOG_PATH=" + filepath.Join(cfg.DataPath, "log", "hooks.log"),
1734 }
1735
1736@@ -120,5 +127,27 @@ func handleGit(s ssh.Session) {
1737 logger.Error("git middleware", "err", err)
1738 sshFatal(s, git.ErrSystemMalfunction)
1739 }
1740+ case git.LFSTransferService:
1741+ if accessLevel < access.ReadWriteAccess {
1742+ sshFatal(s, git.ErrNotAuthed)
1743+ return
1744+ }
1745+
1746+ if len(cmdLine) != 3 ||
1747+ (cmdLine[2] != lfs.OperationDownload && cmdLine[2] != lfs.OperationUpload) {
1748+ sshFatal(s, git.ErrInvalidRequest)
1749+ return
1750+ }
1751+
1752+ cmd.Args = []string{
1753+ name,
1754+ cmdLine[2],
1755+ }
1756+
1757+ if err := git.LFSTransfer(ctx, cmd); err != nil {
1758+ logger.Error("git middleware", "err", err)
1759+ sshFatal(s, git.ErrSystemMalfunction)
1760+ return
1761+ }
1762 }
1763 }
1764diff --git a/server/ssh/middleware.go b/server/ssh/middleware.go
1765index 7fe749185220ab637efd089149ccd7fa78170942..909c499d4ddffa5f2454c10185e7057e9283bb74 100644
1766--- a/server/ssh/middleware.go
1767+++ b/server/ssh/middleware.go
1768@@ -6,14 +6,18 @@ import (
1769 "github.com/charmbracelet/log"
1770 "github.com/charmbracelet/soft-serve/server/backend"
1771 "github.com/charmbracelet/soft-serve/server/config"
1772+ "github.com/charmbracelet/soft-serve/server/db"
1773+ "github.com/charmbracelet/soft-serve/server/store"
1774 "github.com/charmbracelet/ssh"
1775 )
1776
1777 // ContextMiddleware adds the config, backend, and logger to the session context.
1778-func ContextMiddleware(cfg *config.Config, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
1779+func ContextMiddleware(cfg *config.Config, dbx *db.DB, datastore store.Store, be *backend.Backend, logger *log.Logger) func(ssh.Handler) ssh.Handler {
1780 return func(sh ssh.Handler) ssh.Handler {
1781 return func(s ssh.Session) {
1782 s.Context().SetValue(config.ContextKey, cfg)
1783+ s.Context().SetValue(db.ContextKey, dbx)
1784+ s.Context().SetValue(store.ContextKey, datastore)
1785 s.Context().SetValue(backend.ContextKey, be)
1786 s.Context().SetValue(log.ContextKey, logger.WithPrefix("ssh"))
1787 sh(s)
1788diff --git a/server/ssh/session_test.go b/server/ssh/session_test.go
1789index 104c92ad14095a4b2d851c40e959c05137e2d5f8..17cbc5c08487e89dc3be91c596c68fda06fccf89 100644
1790--- a/server/ssh/session_test.go
1791+++ b/server/ssh/session_test.go
1792@@ -13,6 +13,8 @@ import (
1793 "github.com/charmbracelet/soft-serve/server/config"
1794 "github.com/charmbracelet/soft-serve/server/db"
1795 "github.com/charmbracelet/soft-serve/server/db/migrate"
1796+ "github.com/charmbracelet/soft-serve/server/store"
1797+ "github.com/charmbracelet/soft-serve/server/store/database"
1798 "github.com/charmbracelet/soft-serve/server/test"
1799 "github.com/charmbracelet/ssh"
1800 bm "github.com/charmbracelet/wish/bubbletea"
1801@@ -65,22 +67,24 @@ func setup(tb testing.TB) (*gossh.Session, func() error) {
1802 log.Fatal(err)
1803 }
1804 ctx = config.WithContext(ctx, cfg)
1805- db, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
1806+ dbx, err := db.Open(ctx, cfg.DB.Driver, cfg.DB.DataSource)
1807 if err != nil {
1808 tb.Fatal(err)
1809 }
1810- if err := migrate.Migrate(ctx, db); err != nil {
1811+ if err := migrate.Migrate(ctx, dbx); err != nil {
1812 tb.Fatal(err)
1813 }
1814- be := backend.New(ctx, cfg, db)
1815+ dbstore := database.New(ctx, dbx)
1816+ ctx = store.WithContext(ctx, dbstore)
1817+ be := backend.New(ctx, cfg, dbx)
1818 ctx = backend.WithContext(ctx, be)
1819 return testsession.New(tb, &ssh.Server{
1820- Handler: ContextMiddleware(cfg, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) {
1821+ Handler: ContextMiddleware(cfg, dbx, dbstore, be, log.Default())(bm.MiddlewareWithProgramHandler(SessionHandler, termenv.ANSI256)(func(s ssh.Session) {
1822 _, _, active := s.Pty()
1823 if !active {
1824 os.Exit(1)
1825 }
1826 s.Exit(0)
1827 })),
1828- }, nil), db.Close
1829+ }, nil), dbx.Close
1830 }
1831diff --git a/server/ssh/ssh.go b/server/ssh/ssh.go
1832index 39f300e58fff5352243fefdb8b85c434582e0d96..fe6854507ac8fe6b53bf3c6a6a08b588c660ec8d 100644
1833--- a/server/ssh/ssh.go
1834+++ b/server/ssh/ssh.go
1835@@ -13,8 +13,11 @@ import (
1836 "github.com/charmbracelet/soft-serve/server/access"
1837 "github.com/charmbracelet/soft-serve/server/backend"
1838 "github.com/charmbracelet/soft-serve/server/config"
1839+ "github.com/charmbracelet/soft-serve/server/db"
1840 "github.com/charmbracelet/soft-serve/server/git"
1841+ "github.com/charmbracelet/soft-serve/server/proto"
1842 "github.com/charmbracelet/soft-serve/server/sshutils"
1843+ "github.com/charmbracelet/soft-serve/server/store"
1844 "github.com/charmbracelet/ssh"
1845 "github.com/charmbracelet/wish"
1846 bm "github.com/charmbracelet/wish/bubbletea"
1847@@ -104,6 +107,8 @@ type SSHServer struct { // nolint: revive
1848 func NewSSHServer(ctx context.Context) (*SSHServer, error) {
1849 cfg := config.FromContext(ctx)
1850 logger := log.FromContext(ctx).WithPrefix("ssh")
1851+ dbx := db.FromContext(ctx)
1852+ datastore := store.FromContext(ctx)
1853 be := backend.FromContext(ctx)
1854
1855 var err error
1856@@ -122,7 +127,7 @@ func NewSSHServer(ctx context.Context) (*SSHServer, error) {
1857 // CLI middleware.
1858 CommandMiddleware,
1859 // Context middleware.
1860- ContextMiddleware(cfg, be, logger),
1861+ ContextMiddleware(cfg, dbx, datastore, be, logger),
1862 // Logging middleware.
1863 lm.MiddlewareWithLogger(
1864 &loggerAdapter{logger, log.DebugLevel},
1865@@ -191,7 +196,10 @@ func (s *SSHServer) PublicKeyHandler(ctx ssh.Context, pk ssh.PublicKey) (allowed
1866 publicKeyCounter.WithLabelValues(strconv.FormatBool(*allowed)).Inc()
1867 }(&allowed)
1868
1869- ac := s.be.AccessLevelByPublicKey(ctx, "", pk)
1870+ user, _ := s.be.UserByPublicKey(ctx, pk)
1871+ ctx.SetValue(proto.ContextKeyUser, user)
1872+
1873+ ac := s.be.AccessLevelForUser(ctx, "", user)
1874 s.logger.Debugf("access level for %q: %s", ak, ac)
1875 allowed = ac >= access.ReadWriteAccess
1876 return
1877diff --git a/server/storage/local.go b/server/storage/local.go
1878new file mode 100644
1879index 0000000000000000000000000000000000000000..8a51157af6e9a796921225f142083a6243aa1720
1880--- /dev/null
1881+++ b/server/storage/local.go
1882@@ -0,0 +1,91 @@
1883+package storage
1884+
1885+import (
1886+ "errors"
1887+ "io"
1888+ "io/fs"
1889+ "os"
1890+ "path/filepath"
1891+ "strings"
1892+)
1893+
1894+// LocalStorage is a storage implementation that stores objects on the local
1895+// filesystem.
1896+type LocalStorage struct {
1897+ root string
1898+}
1899+
1900+var _ Storage = (*LocalStorage)(nil)
1901+
1902+// NewLocalStorage creates a new LocalStorage.
1903+func NewLocalStorage(root string) *LocalStorage {
1904+ return &LocalStorage{root: root}
1905+}
1906+
1907+// Delete implements Storage.
1908+func (l *LocalStorage) Delete(name string) error {
1909+ name = l.fixPath(name)
1910+ return os.Remove(name)
1911+}
1912+
1913+// Open implements Storage.
1914+func (l *LocalStorage) Open(name string) (Object, error) {
1915+ name = l.fixPath(name)
1916+ return os.Open(name)
1917+}
1918+
1919+// Stat implements Storage.
1920+func (l *LocalStorage) Stat(name string) (fs.FileInfo, error) {
1921+ name = l.fixPath(name)
1922+ return os.Stat(name)
1923+}
1924+
1925+// Put implements Storage.
1926+func (l *LocalStorage) Put(name string, r io.Reader) error {
1927+ name = l.fixPath(name)
1928+ if err := os.MkdirAll(filepath.Dir(name), os.ModePerm); err != nil {
1929+ return err
1930+ }
1931+
1932+ f, err := os.Create(name)
1933+ if err != nil {
1934+ return err
1935+ }
1936+ defer f.Close() // nolint: errcheck
1937+ _, err = io.Copy(f, r)
1938+ return err
1939+}
1940+
1941+// Exists implements Storage.
1942+func (l *LocalStorage) Exists(name string) (bool, error) {
1943+ name = l.fixPath(name)
1944+ _, err := os.Stat(name)
1945+ if err == nil {
1946+ return true, nil
1947+ }
1948+ if errors.Is(err, fs.ErrNotExist) {
1949+ return false, nil
1950+ }
1951+ return false, err
1952+}
1953+
1954+// Rename implements Storage.
1955+func (l *LocalStorage) Rename(oldName, newName string) error {
1956+ oldName = l.fixPath(oldName)
1957+ newName = l.fixPath(newName)
1958+ if err := os.MkdirAll(filepath.Dir(newName), os.ModePerm); err != nil {
1959+ return err
1960+ }
1961+
1962+ return os.Rename(oldName, newName)
1963+}
1964+
1965+// Replace all slashes with the OS-specific separator
1966+func (l LocalStorage) fixPath(path string) string {
1967+ path = strings.ReplaceAll(path, "/", string(os.PathSeparator))
1968+ if !filepath.IsAbs(path) {
1969+ return filepath.Join(l.root, path)
1970+ }
1971+
1972+ return path
1973+}
1974diff --git a/server/storage/storage.go b/server/storage/storage.go
1975new file mode 100644
1976index 0000000000000000000000000000000000000000..dc435dbbbd8b39b6a1e0ac69cc1269276fab32e6
1977--- /dev/null
1978+++ b/server/storage/storage.go
1979@@ -0,0 +1,23 @@
1980+package storage
1981+
1982+import (
1983+ "io"
1984+ "io/fs"
1985+)
1986+
1987+// Object is an interface for objects that can be stored.
1988+type Object interface {
1989+ io.Seeker
1990+ fs.File
1991+ Name() string
1992+}
1993+
1994+// Storage is an interface for storing and retrieving objects.
1995+type Storage interface {
1996+ Open(name string) (Object, error)
1997+ Stat(name string) (fs.FileInfo, error)
1998+ Put(name string, r io.Reader) error
1999+ Delete(name string) error
2000+ Exists(name string) (bool, error)
2001+ Rename(oldName, newName string) error
2002+}
2003diff --git a/server/store/context.go b/server/store/context.go
2004new file mode 100644
2005index 0000000000000000000000000000000000000000..938c7dca07457ce15448c9d513234b92b2c19082
2006--- /dev/null
2007+++ b/server/store/context.go
2008@@ -0,0 +1,20 @@
2009+package store
2010+
2011+import "context"
2012+
2013+// ContextKey is the store context key.
2014+var ContextKey = &struct{ string }{"store"}
2015+
2016+// FromContext returns the store from the given context.
2017+func FromContext(ctx context.Context) Store {
2018+ if s, ok := ctx.Value(ContextKey).(Store); ok {
2019+ return s
2020+ }
2021+
2022+ return nil
2023+}
2024+
2025+// WithContext returns a new context with the given store.
2026+func WithContext(ctx context.Context, s Store) context.Context {
2027+ return context.WithValue(ctx, ContextKey, s)
2028+}
2029diff --git a/server/store/database/collab.go b/server/store/database/collab.go
2030index 50424445e13ede0006e81d7e25aca255ff139ff9..e290593c2d6f5dbef4461daf147a72b9eaafb924 100644
2031--- a/server/store/database/collab.go
2032+++ b/server/store/database/collab.go
2033@@ -15,7 +15,7 @@ type collabStore struct{}
2034 var _ store.CollaboratorStore = (*collabStore)(nil)
2035
2036 // AddCollabByUsernameAndRepo implements store.CollaboratorStore.
2037-func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error {
2038+func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {
2039 username = strings.ToLower(username)
2040 if err := utils.ValidateUsername(username); err != nil {
2041 return err
2042@@ -38,7 +38,7 @@ func (*collabStore) AddCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, u
2043 }
2044
2045 // GetCollabByUsernameAndRepo implements store.CollaboratorStore.
2046-func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) (models.Collab, error) {
2047+func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) (models.Collab, error) {
2048 var m models.Collab
2049
2050 username = strings.ToLower(username)
2051@@ -63,7 +63,7 @@ func (*collabStore) GetCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, u
2052 }
2053
2054 // ListCollabsByRepo implements store.CollaboratorStore.
2055-func (*collabStore) ListCollabsByRepo(ctx context.Context, tx *db.Tx, repo string) ([]models.Collab, error) {
2056+func (*collabStore) ListCollabsByRepo(ctx context.Context, tx db.Handler, repo string) ([]models.Collab, error) {
2057 var m []models.Collab
2058
2059 repo = utils.SanitizeRepo(repo)
2060@@ -82,7 +82,7 @@ func (*collabStore) ListCollabsByRepo(ctx context.Context, tx *db.Tx, repo strin
2061 }
2062
2063 // ListCollabsByRepoAsUsers implements store.CollaboratorStore.
2064-func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx *db.Tx, repo string) ([]models.User, error) {
2065+func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx db.Handler, repo string) ([]models.User, error) {
2066 var m []models.User
2067
2068 repo = utils.SanitizeRepo(repo)
2069@@ -102,7 +102,7 @@ func (*collabStore) ListCollabsByRepoAsUsers(ctx context.Context, tx *db.Tx, rep
2070 }
2071
2072 // RemoveCollabByUsernameAndRepo implements store.CollaboratorStore.
2073-func (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error {
2074+func (*collabStore) RemoveCollabByUsernameAndRepo(ctx context.Context, tx db.Handler, username string, repo string) error {
2075 username = strings.ToLower(username)
2076 if err := utils.ValidateUsername(username); err != nil {
2077 return err
2078diff --git a/server/store/database/database.go b/server/store/database/database.go
2079index 4960f68537f5d18b6c2e56d636e5d2f634cd8bae..c63d70992eb4665e8dd1ecf6bf007c5eb4653c41 100644
2080--- a/server/store/database/database.go
2081+++ b/server/store/database/database.go
2082@@ -19,6 +19,7 @@ type datastore struct {
2083 *repoStore
2084 *userStore
2085 *collabStore
2086+ *lfsStore
2087 }
2088
2089 // New returns a new store.Store database.
2090@@ -36,6 +37,7 @@ func New(ctx context.Context, db *db.DB) store.Store {
2091 repoStore: &repoStore{},
2092 userStore: &userStore{},
2093 collabStore: &collabStore{},
2094+ lfsStore: &lfsStore{},
2095 }
2096
2097 return s
2098diff --git a/server/store/database/lfs.go b/server/store/database/lfs.go
2099new file mode 100644
2100index 0000000000000000000000000000000000000000..0233dec7a0ffc48da9bca2004938d4bccb987560
2101--- /dev/null
2102+++ b/server/store/database/lfs.go
2103@@ -0,0 +1,179 @@
2104+package database
2105+
2106+import (
2107+ "context"
2108+ "strconv"
2109+ "strings"
2110+
2111+ "github.com/charmbracelet/soft-serve/server/db"
2112+ "github.com/charmbracelet/soft-serve/server/db/models"
2113+ "github.com/charmbracelet/soft-serve/server/store"
2114+)
2115+
2116+type lfsStore struct{}
2117+
2118+var _ store.LFSStore = (*lfsStore)(nil)
2119+
2120+func sanitizePath(path string) string {
2121+ path = strings.TrimSpace(path)
2122+ path = strings.TrimPrefix(path, "/")
2123+ return path
2124+}
2125+
2126+// CreateLFSLockForUser implements store.LFSStore.
2127+func (*lfsStore) CreateLFSLockForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string, refname string) error {
2128+ path = sanitizePath(path)
2129+ query := tx.Rebind(`INSERT INTO lfs_locks (repo_id, user_id, path, refname, updated_at)
2130+ VALUES (
2131+ ?,
2132+ ?,
2133+ ?,
2134+ ?,
2135+ CURRENT_TIMESTAMP
2136+ );
2137+ `)
2138+ _, err := tx.ExecContext(ctx, query, repoID, userID, path, refname)
2139+ return db.WrapError(err)
2140+}
2141+
2142+// GetLFSLocks implements store.LFSStore.
2143+func (*lfsStore) GetLFSLocks(ctx context.Context, tx db.Handler, repoID int64) ([]models.LFSLock, error) {
2144+ var locks []models.LFSLock
2145+ query := tx.Rebind(`
2146+ SELECT *
2147+ FROM lfs_locks
2148+ WHERE repo_id = ?;
2149+ `)
2150+ err := tx.SelectContext(ctx, &locks, query, repoID)
2151+ return locks, db.WrapError(err)
2152+}
2153+
2154+// GetLFSLocksForUser implements store.LFSStore.
2155+func (*lfsStore) GetLFSLocksForUser(ctx context.Context, tx db.Handler, repoID int64, userID int64) ([]models.LFSLock, error) {
2156+ var locks []models.LFSLock
2157+ query := tx.Rebind(`
2158+ SELECT *
2159+ FROM lfs_locks
2160+ WHERE repo_id = ? AND user_id = ?;
2161+ `)
2162+ err := tx.SelectContext(ctx, &locks, query, repoID, userID)
2163+ return locks, db.WrapError(err)
2164+}
2165+
2166+// GetLFSLocksForPath implements store.LFSStore.
2167+func (*lfsStore) GetLFSLocksForPath(ctx context.Context, tx db.Handler, repoID int64, path string) ([]models.LFSLock, error) {
2168+ path = sanitizePath(path)
2169+ var locks []models.LFSLock
2170+ query := tx.Rebind(`
2171+ SELECT *
2172+ FROM lfs_locks
2173+ WHERE repo_id = ? AND path = ?;
2174+ `)
2175+ err := tx.SelectContext(ctx, &locks, query, repoID, path)
2176+ return locks, db.WrapError(err)
2177+}
2178+
2179+// GetLFSLockForUserPath implements store.LFSStore.
2180+func (*lfsStore) GetLFSLockForUserPath(ctx context.Context, tx db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error) {
2181+ path = sanitizePath(path)
2182+ var lock models.LFSLock
2183+ query := tx.Rebind(`
2184+ SELECT *
2185+ FROM lfs_locks
2186+ WHERE repo_id = ? AND user_id = ? AND path = ?;
2187+ `)
2188+ err := tx.GetContext(ctx, &lock, query, repoID, userID, path)
2189+ return lock, db.WrapError(err)
2190+}
2191+
2192+// GetLFSLockByID implements store.LFSStore.
2193+func (*lfsStore) GetLFSLockByID(ctx context.Context, tx db.Handler, id string) (models.LFSLock, error) {
2194+ iid, err := strconv.Atoi(id)
2195+ if err != nil {
2196+ return models.LFSLock{}, err
2197+ }
2198+
2199+ var lock models.LFSLock
2200+ query := tx.Rebind(`
2201+ SELECT *
2202+ FROM lfs_locks
2203diff --git a/server/store/database/repo.go b/server/store/database/repo.go
2204index 76436b9bc4393cf4a146559a9efe43417add6feb..06ea92b492c32cfaf4c1020611daebf5ea7a8bbb 100644
2205--- a/server/store/database/repo.go
2206+++ b/server/store/database/repo.go
2207@@ -14,7 +14,7 @@ type repoStore struct{}
2208 var _ store.RepositoryStore = (*repoStore)(nil)
2209
2210 // CreateRepo implements store.RepositoryStore.
2211-func (*repoStore) CreateRepo(ctx context.Context, tx *db.Tx, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
2212+func (*repoStore) CreateRepo(ctx context.Context, tx db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error {
2213 name = utils.SanitizeRepo(name)
2214 query := tx.Rebind(`INSERT INTO repos (name, project_name, description, private, mirror, hidden, updated_at)
2215 VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP);`)
2216@@ -24,7 +24,7 @@ func (*repoStore) CreateRepo(ctx context.Context, tx *db.Tx, name string, projec
2217 }
2218
2219 // DeleteRepoByName implements store.RepositoryStore.
2220-func (*repoStore) DeleteRepoByName(ctx context.Context, tx *db.Tx, name string) error {
2221+func (*repoStore) DeleteRepoByName(ctx context.Context, tx db.Handler, name string) error {
2222 name = utils.SanitizeRepo(name)
2223 query := tx.Rebind("DELETE FROM repos WHERE name = ?;")
2224 _, err := tx.ExecContext(ctx, query, name)
2225@@ -32,7 +32,7 @@ func (*repoStore) DeleteRepoByName(ctx context.Context, tx *db.Tx, name string)
2226 }
2227
2228 // GetAllRepos implements store.RepositoryStore.
2229-func (*repoStore) GetAllRepos(ctx context.Context, tx *db.Tx) ([]models.Repo, error) {
2230+func (*repoStore) GetAllRepos(ctx context.Context, tx db.Handler) ([]models.Repo, error) {
2231 var repos []models.Repo
2232 query := tx.Rebind("SELECT * FROM repos;")
2233 err := tx.SelectContext(ctx, &repos, query)
2234@@ -40,7 +40,7 @@ func (*repoStore) GetAllRepos(ctx context.Context, tx *db.Tx) ([]models.Repo, er
2235 }
2236
2237 // GetRepoByName implements store.RepositoryStore.
2238-func (*repoStore) GetRepoByName(ctx context.Context, tx *db.Tx, name string) (models.Repo, error) {
2239+func (*repoStore) GetRepoByName(ctx context.Context, tx db.Handler, name string) (models.Repo, error) {
2240 var repo models.Repo
2241 name = utils.SanitizeRepo(name)
2242 query := tx.Rebind("SELECT * FROM repos WHERE name = ?;")
2243@@ -49,7 +49,7 @@ func (*repoStore) GetRepoByName(ctx context.Context, tx *db.Tx, name string) (mo
2244 }
2245
2246 // GetRepoDescriptionByName implements store.RepositoryStore.
2247-func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string) (string, error) {
2248+func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string) (string, error) {
2249 var description string
2250 name = utils.SanitizeRepo(name)
2251 query := tx.Rebind("SELECT description FROM repos WHERE name = ?;")
2252@@ -58,7 +58,7 @@ func (*repoStore) GetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name
2253 }
2254
2255 // GetRepoIsHiddenByName implements store.RepositoryStore.
2256-func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string) (bool, error) {
2257+func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
2258 var isHidden bool
2259 name = utils.SanitizeRepo(name)
2260 query := tx.Rebind("SELECT hidden FROM repos WHERE name = ?;")
2261@@ -67,7 +67,7 @@ func (*repoStore) GetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name str
2262 }
2263
2264 // GetRepoIsMirrorByName implements store.RepositoryStore.
2265-func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx *db.Tx, name string) (bool, error) {
2266+func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
2267 var isMirror bool
2268 name = utils.SanitizeRepo(name)
2269 query := tx.Rebind("SELECT mirror FROM repos WHERE name = ?;")
2270@@ -76,7 +76,7 @@ func (*repoStore) GetRepoIsMirrorByName(ctx context.Context, tx *db.Tx, name str
2271 }
2272
2273 // GetRepoIsPrivateByName implements store.RepositoryStore.
2274-func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name string) (bool, error) {
2275+func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx db.Handler, name string) (bool, error) {
2276 var isPrivate bool
2277 name = utils.SanitizeRepo(name)
2278 query := tx.Rebind("SELECT private FROM repos WHERE name = ?;")
2279@@ -85,7 +85,7 @@ func (*repoStore) GetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name st
2280 }
2281
2282 // GetRepoProjectNameByName implements store.RepositoryStore.
2283-func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name string) (string, error) {
2284+func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx db.Handler, name string) (string, error) {
2285 var pname string
2286 name = utils.SanitizeRepo(name)
2287 query := tx.Rebind("SELECT project_name FROM repos WHERE name = ?;")
2288@@ -94,7 +94,7 @@ func (*repoStore) GetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name
2289 }
2290
2291 // SetRepoDescriptionByName implements store.RepositoryStore.
2292-func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string, description string) error {
2293+func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx db.Handler, name string, description string) error {
2294 name = utils.SanitizeRepo(name)
2295 query := tx.Rebind("UPDATE repos SET description = ? WHERE name = ?;")
2296 _, err := tx.ExecContext(ctx, query, description, name)
2297@@ -102,7 +102,7 @@ func (*repoStore) SetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name
2298 }
2299
2300 // SetRepoIsHiddenByName implements store.RepositoryStore.
2301-func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string, isHidden bool) error {
2302+func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx db.Handler, name string, isHidden bool) error {
2303 name = utils.SanitizeRepo(name)
2304 query := tx.Rebind("UPDATE repos SET hidden = ? WHERE name = ?;")
2305 _, err := tx.ExecContext(ctx, query, isHidden, name)
2306@@ -110,7 +110,7 @@ func (*repoStore) SetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name str
2307diff --git a/server/store/database/settings.go b/server/store/database/settings.go
2308index bb653a7ff188f2812e4003c846a3f16b5bab7d22..ec63eae01f9a8a2743a83da4c817b0e03c3771af 100644
2309--- a/server/store/database/settings.go
2310+++ b/server/store/database/settings.go
2311@@ -13,7 +13,7 @@ type settingsStore struct{}
2312 var _ store.SettingStore = (*settingsStore)(nil)
2313
2314 // GetAllowKeylessAccess implements store.SettingStore.
2315-func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx *db.Tx) (bool, error) {
2316+func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx db.Handler) (bool, error) {
2317 var allow bool
2318 query := tx.Rebind(`SELECT value FROM settings WHERE key = "allow_keyless"`)
2319 if err := tx.GetContext(ctx, &allow, query); err != nil {
2320@@ -23,7 +23,7 @@ func (*settingsStore) GetAllowKeylessAccess(ctx context.Context, tx *db.Tx) (boo
2321 }
2322
2323 // GetAnonAccess implements store.SettingStore.
2324-func (*settingsStore) GetAnonAccess(ctx context.Context, tx *db.Tx) (access.AccessLevel, error) {
2325+func (*settingsStore) GetAnonAccess(ctx context.Context, tx db.Handler) (access.AccessLevel, error) {
2326 var level string
2327 query := tx.Rebind(`SELECT value FROM settings WHERE key = "anon_access"`)
2328 if err := tx.GetContext(ctx, &level, query); err != nil {
2329@@ -33,14 +33,14 @@ func (*settingsStore) GetAnonAccess(ctx context.Context, tx *db.Tx) (access.Acce
2330 }
2331
2332 // SetAllowKeylessAccess implements store.SettingStore.
2333-func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx *db.Tx, allow bool) error {
2334+func (*settingsStore) SetAllowKeylessAccess(ctx context.Context, tx db.Handler, allow bool) error {
2335 query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = "allow_keyless"`)
2336 _, err := tx.ExecContext(ctx, query, allow)
2337 return db.WrapError(err)
2338 }
2339
2340 // SetAnonAccess implements store.SettingStore.
2341-func (*settingsStore) SetAnonAccess(ctx context.Context, tx *db.Tx, level access.AccessLevel) error {
2342+func (*settingsStore) SetAnonAccess(ctx context.Context, tx db.Handler, level access.AccessLevel) error {
2343 query := tx.Rebind(`UPDATE settings SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE key = "anon_access"`)
2344 _, err := tx.ExecContext(ctx, query, level.String())
2345 return db.WrapError(err)
2346diff --git a/server/store/database/user.go b/server/store/database/user.go
2347index 2e3a70beb45b9fb3625f98bbcf0d78604e190863..9ca824e62bfde8cdb9a2c2d77c6eecd442c59183 100644
2348--- a/server/store/database/user.go
2349+++ b/server/store/database/user.go
2350@@ -17,7 +17,7 @@ type userStore struct{}
2351 var _ store.UserStore = (*userStore)(nil)
2352
2353 // AddPublicKeyByUsername implements store.UserStore.
2354-func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error {
2355+func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error {
2356 username = strings.ToLower(username)
2357 if err := utils.ValidateUsername(username); err != nil {
2358 return err
2359@@ -37,7 +37,7 @@ func (*userStore) AddPublicKeyByUsername(ctx context.Context, tx *db.Tx, usernam
2360 }
2361
2362 // CreateUser implements store.UserStore.
2363-func (*userStore) CreateUser(ctx context.Context, tx *db.Tx, username string, isAdmin bool, pks []ssh.PublicKey) error {
2364+func (*userStore) CreateUser(ctx context.Context, tx db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error {
2365 username = strings.ToLower(username)
2366 if err := utils.ValidateUsername(username); err != nil {
2367 return err
2368@@ -69,7 +69,7 @@ func (*userStore) CreateUser(ctx context.Context, tx *db.Tx, username string, is
2369 }
2370
2371 // DeleteUserByUsername implements store.UserStore.
2372-func (*userStore) DeleteUserByUsername(ctx context.Context, tx *db.Tx, username string) error {
2373+func (*userStore) DeleteUserByUsername(ctx context.Context, tx db.Handler, username string) error {
2374 username = strings.ToLower(username)
2375 if err := utils.ValidateUsername(username); err != nil {
2376 return err
2377@@ -80,8 +80,16 @@ func (*userStore) DeleteUserByUsername(ctx context.Context, tx *db.Tx, username
2378 return err
2379 }
2380
2381+// GetUserByID implements store.UserStore.
2382+func (*userStore) GetUserByID(ctx context.Context, tx db.Handler, id int64) (models.User, error) {
2383+ var m models.User
2384+ query := tx.Rebind(`SELECT * FROM users WHERE id = ?;`)
2385+ err := tx.GetContext(ctx, &m, query, id)
2386+ return m, err
2387+}
2388+
2389 // FindUserByPublicKey implements store.UserStore.
2390-func (*userStore) FindUserByPublicKey(ctx context.Context, tx *db.Tx, pk ssh.PublicKey) (models.User, error) {
2391+func (*userStore) FindUserByPublicKey(ctx context.Context, tx db.Handler, pk ssh.PublicKey) (models.User, error) {
2392 var m models.User
2393 query := tx.Rebind(`SELECT users.*
2394 FROM users
2395@@ -92,7 +100,7 @@ func (*userStore) FindUserByPublicKey(ctx context.Context, tx *db.Tx, pk ssh.Pub
2396 }
2397
2398 // FindUserByUsername implements store.UserStore.
2399-func (*userStore) FindUserByUsername(ctx context.Context, tx *db.Tx, username string) (models.User, error) {
2400+func (*userStore) FindUserByUsername(ctx context.Context, tx db.Handler, username string) (models.User, error) {
2401 username = strings.ToLower(username)
2402 if err := utils.ValidateUsername(username); err != nil {
2403 return models.User{}, err
2404@@ -105,7 +113,7 @@ func (*userStore) FindUserByUsername(ctx context.Context, tx *db.Tx, username st
2405 }
2406
2407 // GetAllUsers implements store.UserStore.
2408-func (*userStore) GetAllUsers(ctx context.Context, tx *db.Tx) ([]models.User, error) {
2409+func (*userStore) GetAllUsers(ctx context.Context, tx db.Handler) ([]models.User, error) {
2410 var ms []models.User
2411 query := tx.Rebind(`SELECT * FROM users;`)
2412 err := tx.SelectContext(ctx, &ms, query)
2413@@ -113,7 +121,7 @@ func (*userStore) GetAllUsers(ctx context.Context, tx *db.Tx) ([]models.User, er
2414 }
2415
2416 // ListPublicKeysByUserID implements store.UserStore..
2417-func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx *db.Tx, id int64) ([]ssh.PublicKey, error) {
2418+func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx db.Handler, id int64) ([]ssh.PublicKey, error) {
2419 var aks []string
2420 query := tx.Rebind(`SELECT public_key FROM public_keys
2421 WHERE user_id = ?
2422@@ -136,7 +144,7 @@ func (*userStore) ListPublicKeysByUserID(ctx context.Context, tx *db.Tx, id int6
2423 }
2424
2425 // ListPublicKeysByUsername implements store.UserStore.
2426-func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx *db.Tx, username string) ([]ssh.PublicKey, error) {
2427+func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx db.Handler, username string) ([]ssh.PublicKey, error) {
2428 username = strings.ToLower(username)
2429 if err := utils.ValidateUsername(username); err != nil {
2430 return nil, err
2431@@ -165,7 +173,7 @@ func (*userStore) ListPublicKeysByUsername(ctx context.Context, tx *db.Tx, usern
2432 }
2433
2434 // RemovePublicKeyByUsername implements store.UserStore.
2435-func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error {
2436+func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx db.Handler, username string, pk ssh.PublicKey) error {
2437 username = strings.ToLower(username)
2438 if err := utils.ValidateUsername(username); err != nil {
2439 return err
2440@@ -179,7 +187,7 @@ func (*userStore) RemovePublicKeyByUsername(ctx context.Context, tx *db.Tx, user
2441 }
2442
2443 // SetAdminByUsername implements store.UserStore.
2444-func (*userStore) SetAdminByUsername(ctx context.Context, tx *db.Tx, username string, isAdmin bool) error {
2445+func (*userStore) SetAdminByUsername(ctx context.Context, tx db.Handler, username string, isAdmin bool) error {
2446 username = strings.ToLower(username)
2447 if err := utils.ValidateUsername(username); err != nil {
2448 return err
2449@@ -191,7 +199,7 @@ func (*userStore) SetAdminByUsername(ctx context.Context, tx *db.Tx, username st
2450diff --git a/server/store/lfs.go b/server/store/lfs.go
2451new file mode 100644
2452index 0000000000000000000000000000000000000000..7632d2472bd7b65c43141e46dab7d49eb0741735
2453--- /dev/null
2454+++ b/server/store/lfs.go
2455@@ -0,0 +1,26 @@
2456+package store
2457+
2458+import (
2459+ "context"
2460+
2461+ "github.com/charmbracelet/soft-serve/server/db"
2462+ "github.com/charmbracelet/soft-serve/server/db/models"
2463+)
2464+
2465+// LFSStore is the interface for the LFS store.
2466+type LFSStore interface {
2467+ CreateLFSObject(ctx context.Context, h db.Handler, repoID int64, oid string, size int64) error
2468+ GetLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) (models.LFSObject, error)
2469+ GetLFSObjects(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSObject, error)
2470+ GetLFSObjectsByName(ctx context.Context, h db.Handler, name string) ([]models.LFSObject, error)
2471+ DeleteLFSObjectByOid(ctx context.Context, h db.Handler, repoID int64, oid string) error
2472+
2473+ CreateLFSLockForUser(ctx context.Context, h db.Handler, repoID int64, userID int64, path string, refname string) error
2474+ GetLFSLocks(ctx context.Context, h db.Handler, repoID int64) ([]models.LFSLock, error)
2475+ GetLFSLocksForUser(ctx context.Context, h db.Handler, repoID int64, userID int64) ([]models.LFSLock, error)
2476+ GetLFSLocksForPath(ctx context.Context, h db.Handler, repoID int64, path string) ([]models.LFSLock, error)
2477+ GetLFSLockForUserPath(ctx context.Context, h db.Handler, repoID int64, userID int64, path string) (models.LFSLock, error)
2478+ GetLFSLockByID(ctx context.Context, h db.Handler, id string) (models.LFSLock, error)
2479+ GetLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) (models.LFSLock, error)
2480+ DeleteLFSLockForUserByID(ctx context.Context, h db.Handler, userID int64, id string) error
2481+}
2482diff --git a/server/store/store.go b/server/store/store.go
2483index d933dfb7d38ad643d472e72808a8aba438ab241b..dcaa3165e4cd1821dbf402612388ef4fb4821c27 100644
2484--- a/server/store/store.go
2485+++ b/server/store/store.go
2486@@ -11,53 +11,54 @@ import (
2487
2488 // SettingStore is an interface for managing settings.
2489 type SettingStore interface {
2490- GetAnonAccess(ctx context.Context, tx *db.Tx) (access.AccessLevel, error)
2491- SetAnonAccess(ctx context.Context, tx *db.Tx, level access.AccessLevel) error
2492- GetAllowKeylessAccess(ctx context.Context, tx *db.Tx) (bool, error)
2493- SetAllowKeylessAccess(ctx context.Context, tx *db.Tx, allow bool) error
2494+ GetAnonAccess(ctx context.Context, h db.Handler) (access.AccessLevel, error)
2495+ SetAnonAccess(ctx context.Context, h db.Handler, level access.AccessLevel) error
2496+ GetAllowKeylessAccess(ctx context.Context, h db.Handler) (bool, error)
2497+ SetAllowKeylessAccess(ctx context.Context, h db.Handler, allow bool) error
2498 }
2499
2500 // RepositoryStore is an interface for managing repositories.
2501 type RepositoryStore interface {
2502- GetRepoByName(ctx context.Context, tx *db.Tx, name string) (models.Repo, error)
2503- GetAllRepos(ctx context.Context, tx *db.Tx) ([]models.Repo, error)
2504- CreateRepo(ctx context.Context, tx *db.Tx, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
2505- DeleteRepoByName(ctx context.Context, tx *db.Tx, name string) error
2506- SetRepoNameByName(ctx context.Context, tx *db.Tx, name string, newName string) error
2507+ GetRepoByName(ctx context.Context, h db.Handler, name string) (models.Repo, error)
2508+ GetAllRepos(ctx context.Context, h db.Handler) ([]models.Repo, error)
2509+ CreateRepo(ctx context.Context, h db.Handler, name string, projectName string, description string, isPrivate bool, isHidden bool, isMirror bool) error
2510+ DeleteRepoByName(ctx context.Context, h db.Handler, name string) error
2511+ SetRepoNameByName(ctx context.Context, h db.Handler, name string, newName string) error
2512
2513- GetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name string) (string, error)
2514- SetRepoProjectNameByName(ctx context.Context, tx *db.Tx, name string, projectName string) error
2515- GetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string) (string, error)
2516- SetRepoDescriptionByName(ctx context.Context, tx *db.Tx, name string, description string) error
2517- GetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name string) (bool, error)
2518- SetRepoIsPrivateByName(ctx context.Context, tx *db.Tx, name string, isPrivate bool) error
2519- GetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string) (bool, error)
2520- SetRepoIsHiddenByName(ctx context.Context, tx *db.Tx, name string, isHidden bool) error
2521- GetRepoIsMirrorByName(ctx context.Context, tx *db.Tx, name string) (bool, error)
2522+ GetRepoProjectNameByName(ctx context.Context, h db.Handler, name string) (string, error)
2523+ SetRepoProjectNameByName(ctx context.Context, h db.Handler, name string, projectName string) error
2524+ GetRepoDescriptionByName(ctx context.Context, h db.Handler, name string) (string, error)
2525+ SetRepoDescriptionByName(ctx context.Context, h db.Handler, name string, description string) error
2526+ GetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string) (bool, error)
2527+ SetRepoIsPrivateByName(ctx context.Context, h db.Handler, name string, isPrivate bool) error
2528+ GetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string) (bool, error)
2529+ SetRepoIsHiddenByName(ctx context.Context, h db.Handler, name string, isHidden bool) error
2530+ GetRepoIsMirrorByName(ctx context.Context, h db.Handler, name string) (bool, error)
2531 }
2532
2533 // UserStore is an interface for managing users.
2534 type UserStore interface {
2535- FindUserByUsername(ctx context.Context, tx *db.Tx, username string) (models.User, error)
2536- FindUserByPublicKey(ctx context.Context, tx *db.Tx, pk ssh.PublicKey) (models.User, error)
2537- GetAllUsers(ctx context.Context, tx *db.Tx) ([]models.User, error)
2538- CreateUser(ctx context.Context, tx *db.Tx, username string, isAdmin bool, pks []ssh.PublicKey) error
2539- DeleteUserByUsername(ctx context.Context, tx *db.Tx, username string) error
2540- SetUsernameByUsername(ctx context.Context, tx *db.Tx, username string, newUsername string) error
2541- SetAdminByUsername(ctx context.Context, tx *db.Tx, username string, isAdmin bool) error
2542- AddPublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error
2543- RemovePublicKeyByUsername(ctx context.Context, tx *db.Tx, username string, pk ssh.PublicKey) error
2544- ListPublicKeysByUserID(ctx context.Context, tx *db.Tx, id int64) ([]ssh.PublicKey, error)
2545- ListPublicKeysByUsername(ctx context.Context, tx *db.Tx, username string) ([]ssh.PublicKey, error)
2546+ GetUserByID(ctx context.Context, h db.Handler, id int64) (models.User, error)
2547+ FindUserByUsername(ctx context.Context, h db.Handler, username string) (models.User, error)
2548+ FindUserByPublicKey(ctx context.Context, h db.Handler, pk ssh.PublicKey) (models.User, error)
2549+ GetAllUsers(ctx context.Context, h db.Handler) ([]models.User, error)
2550+ CreateUser(ctx context.Context, h db.Handler, username string, isAdmin bool, pks []ssh.PublicKey) error
2551+ DeleteUserByUsername(ctx context.Context, h db.Handler, username string) error
2552+ SetUsernameByUsername(ctx context.Context, h db.Handler, username string, newUsername string) error
2553+ SetAdminByUsername(ctx context.Context, h db.Handler, username string, isAdmin bool) error
2554+ AddPublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
2555+ RemovePublicKeyByUsername(ctx context.Context, h db.Handler, username string, pk ssh.PublicKey) error
2556+ ListPublicKeysByUserID(ctx context.Context, h db.Handler, id int64) ([]ssh.PublicKey, error)
2557+ ListPublicKeysByUsername(ctx context.Context, h db.Handler, username string) ([]ssh.PublicKey, error)
2558 }
2559
2560 // CollaboratorStore is an interface for managing collaborators.
2561 type CollaboratorStore interface {
2562- GetCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) (models.Collab, error)
2563- AddCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error
2564- RemoveCollabByUsernameAndRepo(ctx context.Context, tx *db.Tx, username string, repo string) error
2565- ListCollabsByRepo(ctx context.Context, tx *db.Tx, repo string) ([]models.Collab, error)
2566- ListCollabsByRepoAsUsers(ctx context.Context, tx *db.Tx, repo string) ([]models.User, error)
2567+ GetCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) (models.Collab, error)
2568+ AddCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
2569+ RemoveCollabByUsernameAndRepo(ctx context.Context, h db.Handler, username string, repo string) error
2570+ ListCollabsByRepo(ctx context.Context, h db.Handler, repo string) ([]models.Collab, error)
2571+ ListCollabsByRepoAsUsers(ctx context.Context, h db.Handler, repo string) ([]models.User, error)
2572 }
2573
2574 // Store is an interface for managing repositories, users, and settings.
2575@@ -66,4 +67,5 @@ type Store interface {
2576 UserStore
2577 CollaboratorStore
2578 SettingStore
2579+ LFSStore
2580 }
2581diff --git a/testscript/script_test.go b/testscript/script_test.go
2582index dbb8b956f53b173c38df509acfb379bb600bca23..d16c19f3fe304bc64febe5c42fe8008ef7ce8d0a 100644
2583--- a/testscript/script_test.go
2584+++ b/testscript/script_test.go
2585@@ -19,6 +19,8 @@ import (
2586 "github.com/charmbracelet/soft-serve/server/config"
2587 "github.com/charmbracelet/soft-serve/server/db"
2588 "github.com/charmbracelet/soft-serve/server/db/migrate"
2589+ "github.com/charmbracelet/soft-serve/server/store"
2590+ "github.com/charmbracelet/soft-serve/server/store/database"
2591 "github.com/charmbracelet/soft-serve/server/test"
2592 "github.com/rogpeppe/go-internal/testscript"
2593 "golang.org/x/crypto/ssh"
2594@@ -105,6 +107,8 @@ func TestScript(t *testing.T) {
2595 }
2596
2597 ctx = db.WithContext(ctx, dbx)
2598+ datastore := database.New(ctx, dbx)
2599+ ctx = store.WithContext(ctx, datastore)
2600 be := backend.New(ctx, cfg, dbx)
2601 ctx = backend.WithContext(ctx, be)
2602