Git-Repository für bestehenden archivdms-Code initialisiert, Branch-/Commit-Konvention (feature/<ticket>-<slug>-Branches, Ticket-Prefix in Commit-Nachricht) etabliert.
134 lines
4.0 KiB
Go
134 lines
4.0 KiB
Go
package sftpserver
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/pkg/sftp"
|
|
)
|
|
|
|
// tenantFS implements the four github.com/pkg/sftp request-server
|
|
// interfaces (FileReader/FileWriter/FileCmder/FileLister) on top of a single
|
|
// real directory (root) — the authenticated tenant's
|
|
// inbox/<tenant_id>/ folder.
|
|
//
|
|
// This is the "virtual chroot": every incoming SFTP path is resolved
|
|
// relative to root and validated to never escape it (no "..", no absolute
|
|
// paths pointing elsewhere). v1 intentionally supports only a flat
|
|
// directory — no subfolder create/navigate/delete — which keeps the path
|
|
// validation trivial: a request path may only name a direct child of root.
|
|
type tenantFS struct {
|
|
root string
|
|
}
|
|
|
|
// resolve maps a virtual SFTP path ("/", "/foo.pdf", ...) onto a real path
|
|
// under fs.root, rejecting anything that isn't a direct child of the root
|
|
// (blocks path traversal and subfolder use in one check).
|
|
func (fs *tenantFS) resolve(virtual string) (string, error) {
|
|
clean := filepath.Clean("/" + virtual)
|
|
if clean == "/" {
|
|
return fs.root, nil
|
|
}
|
|
clean = strings.TrimPrefix(clean, "/")
|
|
if strings.Contains(clean, "/") || clean == ".." || clean == "." {
|
|
return "", errors.New("sftpserver: path escapes tenant root or is not a direct child")
|
|
}
|
|
return filepath.Join(fs.root, clean), nil
|
|
}
|
|
|
|
// Fileread implements sftp.FileReader (GET). Reading back an already
|
|
// uploaded-but-not-yet-processed file is allowed (harmless), but there is
|
|
// nothing to read once the watcher has moved the file into store/ (by
|
|
// design — inbox/ is a transient staging area, not a browsable archive).
|
|
func (fs *tenantFS) Fileread(r *sftp.Request) (io.ReaderAt, error) {
|
|
path, err := fs.resolve(r.Filepath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Filewrite implements sftp.FileWriter (PUT). New files are created at the
|
|
// root of the tenant's inbox only; existing files may not be overwritten
|
|
// (O_EXCL) to avoid a client silently clobbering a file the watcher hasn't
|
|
// picked up yet.
|
|
func (fs *tenantFS) Filewrite(r *sftp.Request) (io.WriterAt, error) {
|
|
path, err := fs.resolve(r.Filepath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o640)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
// Filecmd implements sftp.FileCmder for out-of-band filesystem operations
|
|
// (Remove, Rename, Mkdir, Setstat, ...). v1 deliberately supports none of
|
|
// these beyond what's needed for a plain "put a file" workflow — clients
|
|
// get a clean permission error rather than silently succeeding.
|
|
func (fs *tenantFS) Filecmd(r *sftp.Request) error {
|
|
return errors.New("sftpserver: operation not permitted (only uploading new files is supported)")
|
|
}
|
|
|
|
// Filelist implements sftp.FileLister (LIST/STAT). Listing the root shows
|
|
// the tenant's pending (not-yet-watched) inbox files; anything else is
|
|
// rejected by resolve.
|
|
func (fs *tenantFS) Filelist(r *sftp.Request) (sftp.ListerAt, error) {
|
|
switch r.Method {
|
|
case "List":
|
|
path, err := fs.resolve(r.Filepath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := os.ReadDir(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
infos := make([]os.FileInfo, 0, len(entries))
|
|
for _, e := range entries {
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
infos = append(infos, info)
|
|
}
|
|
return listerAt(infos), nil
|
|
case "Stat", "Lstat":
|
|
path, err := fs.resolve(r.Filepath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return listerAt([]os.FileInfo{info}), nil
|
|
default:
|
|
return nil, errors.New("sftpserver: unsupported list method " + r.Method)
|
|
}
|
|
}
|
|
|
|
// listerAt is the minimal []os.FileInfo -> sftp.ListerAt adapter expected by
|
|
// github.com/pkg/sftp's request server.
|
|
type listerAt []os.FileInfo
|
|
|
|
func (l listerAt) ListAt(dst []os.FileInfo, offset int64) (int, error) {
|
|
if offset >= int64(len(l)) {
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(dst, l[offset:])
|
|
if n < len(dst) {
|
|
return n, io.EOF
|
|
}
|
|
return n, nil
|
|
}
|