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}