GitRoot
Craft your forge, Build your project, Grow your community freely
1// SPDX-FileCopyrightText: 2026 Romain Maneschi <romain@gitroot.dev>
2//
3// SPDX-License-Identifier: EUPL-1.2
4
5package main
6
7import (
8 "context"
9 "errors"
10 "io"
11 "os"
12 "strings"
13
14 "github.com/pkg/sftp"
15 "gitroot.dev/server/configuration"
16 "gitroot.dev/server/logger"
17 "gitroot.dev/server/repository"
18 "gitroot.dev/server/user"
19)
20
21type repoHandler struct {
22 ctx context.Context
23 logger *logger.Logger
24 conf *configuration.Configuration
25 repoManager *repository.Manager
26 user user.SimpleUser
27}
28
29func RepoHandler(ctx context.Context, conf *configuration.Configuration, repoManager *repository.Manager, user user.SimpleUser) sftp.Handlers {
30 repoHandler := &repoHandler{
31 ctx: ctx,
32 logger: logger.NewLogger(logger.SFTP),
33 conf: conf,
34 repoManager: repoManager,
35 user: user,
36 }
37 return sftp.Handlers{FileGet: repoHandler, FilePut: repoHandler, FileCmd: repoHandler, FileList: repoHandler}
38}
39
40func (rh *repoHandler) findRepoFile(r *sftp.Request) (*repository.GitRootRepository, string, error) {
41 repoPath := strings.Split(strings.TrimPrefix(r.Filepath, "/"), "/")
42 if len(repoPath) <= 0 {
43 return nil, "", errors.New("invalid path")
44 }
45 rh.logger.Debug("selecting repo", logger.NewLoggerPair("req", r.Filepath), logger.NewLoggerPair("repoPath", repoPath))
46 repo := rh.conf.RootRepositoryName
47 if repoPath[0] != "" {
48 repo = repoPath[0]
49 }
50 file := strings.Join(repoPath[1:], "/")
51 rh.logger.Debug("selecting repo", logger.NewLoggerPair("req", r.Filepath), logger.NewLoggerPair("repo", repo))
52 rg, err := rh.repoManager.Open(rh.ctx, repo)
53 if err != nil {
54 return nil, "", err
55 }
56 return rg, file, nil
57}
58
59func (rh *repoHandler) Fileread(r *sftp.Request) (io.ReaderAt, error) {
60 flags := r.Pflags()
61 if !flags.Read {
62 return nil, os.ErrInvalid
63 }
64
65 repo, file, err := rh.findRepoFile(r)
66 if err != nil {
67 return nil, err
68 }
69 defer repo.Close()
70
71 fullPath := repo.PathDataWeb(file)
72 f, err := os.Open(fullPath)
73 if err != nil {
74 return nil, err
75 }
76 return f, nil
77}
78
79func (rh *repoHandler) Filewrite(r *sftp.Request) (io.WriterAt, error) {
80 flags := r.Pflags()
81 if !flags.Write {
82 return nil, os.ErrInvalid
83 }
84
85 repo, file, err := rh.findRepoFile(r)
86 if err != nil {
87 return nil, err
88 }
89 defer repo.Close()
90
91 contentUser, err := repo.ContentUserAtDefaultBranch()
92 if err != nil {
93 return nil, err
94 }
95 user, err := user.FindUser(contentUser, rh.user.Ssh)
96 if err != nil {
97 return nil, err
98 }
99 defaultBranch, err := repo.DefaultBranch()
100 if err != nil {
101 return nil, err
102 }
103
104 if !user.CanWrite(defaultBranch.Short()) {
105 rh.logger.Debug("user can't write in default branch", logger.NewLoggerPair("user", user.PubKey))
106 return nil, os.ErrPermission
107 }
108
109 fullPath := repo.PathDataWeb(file)
110 f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0755)
111 if err != nil {
112 return nil, err
113 }
114 return f, nil
115}
116
117func (rh *repoHandler) Filecmd(r *sftp.Request) error {
118 // switch r.Method {
119 // case "Setstat":
120 // file, err := fs.openfile(r.Filepath, sshFxfWrite)
121 // if err != nil {
122 // return err
123 // }
124
125 // if r.AttrFlags().Size {
126 // return file.Truncate(int64(r.Attributes().Size))
127 // }
128
129 // return nil
130
131 // case "Rename":
132 // // SFTP-v2: "It is an error if there already exists a file with the name specified by newpath."
133 // // This varies from the POSIX specification, which allows limited replacement of target files.
134 // if fs.exists(r.Target) {
135 // return os.ErrExist
136 // }
137
138 // return fs.rename(r.Filepath, r.Target)
139
140 // case "Rmdir":
141 // return fs.rmdir(r.Filepath)
142
143 // case "Remove":
144 // // IEEE 1003.1 remove explicitly can unlink files and remove empty directories.
145 // // We use instead here the semantics of unlink, which is allowed to be restricted against directories.
146 // return fs.unlink(r.Filepath)
147
148 // case "Mkdir":
149 // return fs.mkdir(r.Filepath)
150
151 // case "Link":
152 // return fs.link(r.Filepath, r.Target)
153
154 // case "Symlink":
155 // // NOTE: r.Filepath is the target, and r.Target is the linkpath.
156 // return fs.symlink(r.Filepath, r.Target)
157 // }
158
159 rh.logger.Debug("unsupported method", logger.NewLoggerPair("method", r.Method))
160
161 return errors.New("unsupported")
162}
163
164func (rh *repoHandler) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
165 dirs := []os.FileInfo{}
166 if r.Filepath == "/" {
167 entries, err := os.ReadDir(rh.conf.PathRepositories())
168 if err != nil {
169 rh.logger.Debug("error / readdir", logger.NewLoggerPair("err", err.Error()))
170 return nil, err
171 }
172 for _, e := range entries {
173 info, err := e.Info()
174 if err != nil {
175 rh.logger.Debug("error / info", logger.NewLoggerPair("err", err.Error()))
176 return nil, err
177 }
178 dirs = append(dirs, info)
179 }
180 } else {
181 repo, path, err := rh.findRepoFile(r)
182 if err != nil {
183 rh.logger.Debug("error find repo", logger.NewLoggerPair("err", err.Error()))
184 return nil, err
185 }
186 defer repo.Close()
187 fullPath := repo.PathDataWeb(path)
188 rh.logger.Debug("open dir", logger.NewLoggerPair("dir", fullPath))
189 info, err := rh.findDirs(r.Method, fullPath)
190 if err != nil {
191 return nil, err
192 }
193 dirs = append(dirs, info...)
194 }
195 switch r.Method {
196 case "List":
197 return listerat(dirs), nil
198 case "Stat":
199 return listerat(dirs), nil
200 }
201
202 return nil, errors.New("unsupported")
203}
204
205func (rh *repoHandler) findDirs(method string, fullPath string) ([]os.FileInfo, error) {
206 dirs := []os.FileInfo{}
207 fileDesc, err := os.Open(fullPath)
208 if err != nil {
209 if os.IsNotExist(err) {
210 return dirs, nil
211 }
212 rh.logger.Debug("error find repo readdir", logger.NewLoggerPair("err", err.Error()))
213 return nil, err
214 }
215 defer fileDesc.Close()
216 fi, err := fileDesc.Stat()
217 if err != nil {
218 rh.logger.Debug("error find repo fileDesc Stat", logger.NewLoggerPair("err", err.Error()))
219 return nil, err
220 }
221 if method == "List" && fi.IsDir() {
222 rh.logger.Debug("fi isDir", logger.NewLoggerPair("path", fullPath))
223 entries, err := fileDesc.ReadDir(-1)
224 if err != nil {
225 rh.logger.Debug("error find repo fileDesc readdir", logger.NewLoggerPair("err", err.Error()))
226 return nil, err
227 }
228 rh.logger.Debug("fi isDir entries", logger.NewLoggerPair("nb", len(entries)))
229 for _, e := range entries {
230 info, err := e.Info()
231 rh.logger.Debug("fi isDir entrie info", logger.NewLoggerPair("info", info.Name()))
232 if err != nil {
233 rh.logger.Debug("error find repo info", logger.NewLoggerPair("err", err.Error()))
234 return nil, err
235 }
236 dirs = append(dirs, info)
237 }
238 } else {
239 rh.logger.Debug("fi is not a Dir", logger.NewLoggerPair("path", fullPath))
240 dirs = append(dirs, fi)
241 }
242 return dirs, nil
243}
244
245type listerat []os.FileInfo
246
247// Modeled after strings.Reader's ReadAt() implementation
248func (f listerat) ListAt(ls []os.FileInfo, offset int64) (int, error) {
249 var n int
250 if offset >= int64(len(f)) {
251 return 0, io.EOF
252 }
253 n = copy(ls, f[offset:])
254 if n < len(ls) {
255 return n, io.EOF
256 }
257 return n, nil
258}