package main import ( "database/sql" "errors" "fmt" "io" "log" "os" "path/filepath" "strconv" "strings" ftpserver "goftp.io/server/v2" ) func startProtocolServers(cfg Config, db *sql.DB) error { if cfg.SFTPEnabled { return fmt.Errorf("SFTP_ENABLED=true is configured, but SFTP server is not implemented") } if cfg.FTPEnabled { ftpSrv, err := buildFTPServer(cfg, db) if err != nil { return fmt.Errorf("build ftp server: %w", err) } go runFTPServer("FTP", ftpSrv) } if cfg.FTPSEnabled { ftpsSrv, err := buildFTPSServer(cfg, db) if err != nil { return fmt.Errorf("build ftps server: %w", err) } go runFTPServer("FTPS", ftpsSrv) } return nil } func runFTPServer(name string, srv *ftpserver.Server) { log.Printf("%s listening on %s:%d", name, srv.Hostname, srv.Port) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, ftpserver.ErrServerClosed) { log.Printf("%s server stopped: %v", name, err) } } type ftpUserDriver struct { db *sql.DB root string } func buildFTPServer(cfg Config, db *sql.DB) (*ftpserver.Server, error) { return buildFileZFTPServer(ftpServerOptions{ name: "FileZ FTP", host: cfg.FTPHost, port: cfg.FTPPort, publicIP: cfg.FTPPublicIP, passivePorts: cfg.FTPPassivePorts, tls: false, }, db, cfg.StorageRoot) } func buildFTPSServer(cfg Config, db *sql.DB) (*ftpserver.Server, error) { return buildFileZFTPServer(ftpServerOptions{ name: "FileZ FTPS", host: cfg.FTPSHost, port: cfg.FTPSPort, publicIP: cfg.FTPSPublicIP, passivePorts: cfg.FTPSPassivePorts, tls: true, certFile: cfg.FTPSCertFile, keyFile: cfg.FTPSKeyFile, explicitFTPS: cfg.FTPSExplicit, forceTLS: cfg.FTPSForceTLS, }, db, cfg.StorageRoot) } type ftpServerOptions struct { name string host string port int publicIP string passivePorts string tls bool certFile string keyFile string explicitFTPS bool forceTLS bool } func buildFileZFTPServer(opts ftpServerOptions, db *sql.DB, storageRoot string) (*ftpserver.Server, error) { drv := &ftpUserDriver{db: db, root: storageRoot} serverOpts := &ftpserver.Options{ Name: opts.name, Hostname: opts.host, Port: opts.port, PublicIP: opts.publicIP, PassivePorts: opts.passivePorts, WelcomeMessage: opts.name + " ready", Driver: drv, Auth: drv, Perm: ftpserver.NewSimplePerm("filez", "filez"), TLS: opts.tls, CertFile: opts.certFile, KeyFile: opts.keyFile, ExplicitFTPS: opts.explicitFTPS, ForceTLS: opts.forceTLS, } return ftpserver.NewServer(serverOpts) } func (d *ftpUserDriver) CheckPasswd(ctx *ftpserver.Context, username, password string) (bool, error) { username = strings.TrimSpace(username) if username == "" { return false, nil } var userID int64 var hash string err := d.db.QueryRow(`SELECT id, password_hash FROM users WHERE email = ?`, username).Scan(&userID, &hash) if err != nil { if errors.Is(err, sql.ErrNoRows) { return false, nil } return false, err } if !verifyPasswordHash(hash, password) { return false, nil } if err := os.MkdirAll(d.userRoot(userID), 0o755); err != nil { return false, err } d.setUserID(ctx, userID) return true, nil } func (d *ftpUserDriver) userRoot(userID int64) string { return filepath.Join(d.root, strconv.FormatInt(userID, 10)) } func (d *ftpUserDriver) setUserID(ctx *ftpserver.Context, userID int64) { if ctx == nil || ctx.Sess == nil { return } if ctx.Sess.Data == nil { ctx.Sess.Data = make(map[string]interface{}) } ctx.Sess.Data["filez_user_id"] = userID } func (d *ftpUserDriver) userIDFromCtx(ctx *ftpserver.Context) (int64, error) { if ctx == nil || ctx.Sess == nil { return 0, fmt.Errorf("missing ftp session") } if v, ok := ctx.Sess.Data["filez_user_id"]; ok { switch id := v.(type) { case int64: if id > 0 { return id, nil } case int: if id > 0 { return int64(id), nil } } } username := strings.TrimSpace(ctx.Sess.LoginUser()) if username == "" { return 0, fmt.Errorf("missing ftp username") } var userID int64 if err := d.db.QueryRow(`SELECT id FROM users WHERE email = ?`, username).Scan(&userID); err != nil { if errors.Is(err, sql.ErrNoRows) { return 0, fmt.Errorf("user not found") } return 0, err } d.setUserID(ctx, userID) return userID, nil } func (d *ftpUserDriver) fullPath(ctx *ftpserver.Context, rel string) (string, error) { userID, err := d.userIDFromCtx(ctx) if err != nil { return "", err } root := d.userRoot(userID) if err := os.MkdirAll(root, 0o755); err != nil { return "", err } clean := filepath.FromSlash(strings.TrimPrefix(normalizePath(rel), "/")) full := filepath.Clean(filepath.Join(root, clean)) if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) { return "", fmt.Errorf("invalid path") } return full, nil } func (d *ftpUserDriver) Stat(ctx *ftpserver.Context, rel string) (os.FileInfo, error) { full, err := d.fullPath(ctx, rel) if err != nil { return nil, err } return os.Stat(full) } func (d *ftpUserDriver) ListDir(ctx *ftpserver.Context, rel string, callback func(os.FileInfo) error) error { full, err := d.fullPath(ctx, rel) if err != nil { return err } entries, err := os.ReadDir(full) if err != nil { return err } for _, entry := range entries { info, err := entry.Info() if err != nil { continue } if err := callback(info); err != nil { return err } } return nil } func (d *ftpUserDriver) DeleteDir(ctx *ftpserver.Context, rel string) error { if normalizePath(rel) == "/" { return fmt.Errorf("cannot remove root directory") } full, err := d.fullPath(ctx, rel) if err != nil { return err } st, err := os.Stat(full) if err != nil { return err } if !st.IsDir() { return fmt.Errorf("not a directory") } return os.RemoveAll(full) } func (d *ftpUserDriver) DeleteFile(ctx *ftpserver.Context, rel string) error { full, err := d.fullPath(ctx, rel) if err != nil { return err } st, err := os.Stat(full) if err != nil { return err } if st.IsDir() { return fmt.Errorf("not a file") } return os.Remove(full) } func (d *ftpUserDriver) Rename(ctx *ftpserver.Context, fromPath, toPath string) error { if normalizePath(fromPath) == "/" { return fmt.Errorf("cannot rename root directory") } oldPath, err := d.fullPath(ctx, fromPath) if err != nil { return err } newPath, err := d.fullPath(ctx, toPath) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(newPath), 0o755); err != nil { return err } return os.Rename(oldPath, newPath) } func (d *ftpUserDriver) MakeDir(ctx *ftpserver.Context, rel string) error { full, err := d.fullPath(ctx, rel) if err != nil { return err } return os.MkdirAll(full, 0o755) } func (d *ftpUserDriver) GetFile(ctx *ftpserver.Context, rel string, offset int64) (int64, io.ReadCloser, error) { full, err := d.fullPath(ctx, rel) if err != nil { return 0, nil, err } f, err := os.Open(full) if err != nil { return 0, nil, err } defer func() { if err != nil { _ = f.Close() } }() st, err := f.Stat() if err != nil { return 0, nil, err } if offset < 0 { offset = 0 } if _, err := f.Seek(offset, io.SeekStart); err != nil { return 0, nil, err } sz := st.Size() - offset if sz < 0 { sz = 0 } return sz, f, nil } func (d *ftpUserDriver) PutFile(ctx *ftpserver.Context, rel string, data io.Reader, offset int64) (int64, error) { full, err := d.fullPath(ctx, rel) if err != nil { return 0, err } if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { return 0, err } if offset < 0 { f, err := os.Create(full) if err != nil { return 0, err } defer f.Close() return io.Copy(f, data) } flags := os.O_CREATE | os.O_WRONLY if offset == 0 { flags |= os.O_TRUNC } f, err := os.OpenFile(full, flags, 0o644) if err != nil { return 0, err } defer f.Close() if _, err := f.Seek(offset, io.SeekStart); err != nil { return 0, err } return io.Copy(f, data) }