feat: initial FTP Server for Windows with web admin panel
This commit is contained in:
+701
@@ -0,0 +1,701 @@
|
||||
package ftp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ftp-server/config"
|
||||
)
|
||||
|
||||
// Server represents an FTP server
|
||||
type Server struct {
|
||||
config *config.Config
|
||||
configPath string
|
||||
listener net.Listener
|
||||
clients map[net.Conn]*clientConn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
type clientConn struct {
|
||||
conn net.Conn
|
||||
server *Server
|
||||
reader *bufio.Reader
|
||||
username string
|
||||
authenticated bool
|
||||
cwd string
|
||||
dataHost string
|
||||
dataPort int
|
||||
pasvMode bool
|
||||
pasvListener net.Listener
|
||||
transferType string
|
||||
renameFrom string
|
||||
}
|
||||
|
||||
// NewServer creates a new FTP server
|
||||
func NewServer(cfg *config.Config, configPath string) *Server {
|
||||
return &Server{
|
||||
config: cfg,
|
||||
configPath: configPath,
|
||||
clients: make(map[net.Conn]*clientConn),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the FTP server
|
||||
func (s *Server) Start() error {
|
||||
addr := fmt.Sprintf("%s:%d", s.config.FTP.Host, s.config.FTP.Port)
|
||||
listener, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("FTP server listen error: %v", err)
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
// Ensure root directory exists
|
||||
rootDir := s.config.FTP.RootDir
|
||||
if err := os.MkdirAll(rootDir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create root directory: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("FTP server listening on %s, root dir: %s", addr, rootDir)
|
||||
|
||||
go s.acceptLoop()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the FTP server
|
||||
func (s *Server) Stop() {
|
||||
if s.listener != nil {
|
||||
s.listener.Close()
|
||||
}
|
||||
s.mu.Lock()
|
||||
for conn, client := range s.clients {
|
||||
client.pasvListener.Close()
|
||||
conn.Close()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
log.Printf("FTP accept error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &clientConn{
|
||||
conn: conn,
|
||||
server: s,
|
||||
reader: bufio.NewReader(conn),
|
||||
cwd: "/",
|
||||
transferType: "ASCII",
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.clients[conn] = client
|
||||
s.mu.Unlock()
|
||||
|
||||
log.Printf("FTP client connected: %s", conn.RemoteAddr())
|
||||
client.sendResponse(220, "FTP Server Ready")
|
||||
|
||||
go client.handle()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientConn) sendResponse(code int, message string) {
|
||||
fmt.Fprintf(c.conn, "%d %s\r\n", code, message)
|
||||
}
|
||||
|
||||
func (c *clientConn) sendMultiResponse(code int, lines []string) {
|
||||
for i, line := range lines {
|
||||
if i == len(lines)-1 {
|
||||
fmt.Fprintf(c.conn, "%d %s\r\n", code, line)
|
||||
} else {
|
||||
fmt.Fprintf(c.conn, "%d-%s\r\n", code, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientConn) handle() {
|
||||
defer func() {
|
||||
c.conn.Close()
|
||||
if c.pasvListener != nil {
|
||||
c.pasvListener.Close()
|
||||
}
|
||||
c.server.mu.Lock()
|
||||
delete(c.server.clients, c.conn)
|
||||
c.server.mu.Unlock()
|
||||
log.Printf("FTP client disconnected: %s", c.conn.RemoteAddr())
|
||||
}()
|
||||
|
||||
for {
|
||||
c.conn.SetDeadline(time.Now().Add(5 * time.Minute))
|
||||
line, err := c.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("FTP read error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
log.Printf("FTP [%s] %s", c.conn.RemoteAddr(), line)
|
||||
c.processCommand(line)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientConn) processCommand(line string) {
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
var args string
|
||||
if len(parts) > 1 {
|
||||
args = parts[1]
|
||||
}
|
||||
|
||||
switch cmd {
|
||||
case "USER":
|
||||
c.handleUSER(args)
|
||||
case "PASS":
|
||||
c.handlePASS(args)
|
||||
case "QUIT":
|
||||
c.handleQUIT()
|
||||
case "PWD":
|
||||
c.handlePWD()
|
||||
case "CWD":
|
||||
c.handleCWD(args)
|
||||
case "CDUP":
|
||||
c.handleCDUP()
|
||||
case "TYPE":
|
||||
c.handleTYPE(args)
|
||||
case "PASV":
|
||||
c.handlePASV()
|
||||
case "PORT":
|
||||
c.handlePORT(args)
|
||||
case "LIST":
|
||||
c.handleLIST(args)
|
||||
case "NLST":
|
||||
c.handleNLST(args)
|
||||
case "RETR":
|
||||
c.handleRETR(args)
|
||||
case "STOR":
|
||||
c.handleSTOR(args)
|
||||
case "DELE":
|
||||
c.handleDELE(args)
|
||||
case "MKD":
|
||||
c.handleMKD(args)
|
||||
case "RMD":
|
||||
c.handleRMD(args)
|
||||
case "RNFR":
|
||||
c.handleRNFR(args)
|
||||
case "RNTO":
|
||||
c.handleRNTO(args)
|
||||
case "SIZE":
|
||||
c.handleSIZE(args)
|
||||
case "SYST":
|
||||
c.sendResponse(215, "UNIX Type: L8")
|
||||
case "FEAT":
|
||||
c.handleFEAT()
|
||||
case "NOOP":
|
||||
c.sendResponse(200, "OK")
|
||||
case "OPTS":
|
||||
c.handleOPTS(args)
|
||||
default:
|
||||
c.sendResponse(502, fmt.Sprintf("Command %s not implemented", cmd))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientConn) handleUSER(args string) {
|
||||
if args == "" {
|
||||
c.sendResponse(501, "Syntax error: USER <username>")
|
||||
return
|
||||
}
|
||||
c.username = args
|
||||
c.authenticated = false
|
||||
c.sendResponse(331, "Password required")
|
||||
}
|
||||
|
||||
func (c *clientConn) handlePASS(args string) {
|
||||
if c.username == "" {
|
||||
c.sendResponse(503, "Login with USER first")
|
||||
return
|
||||
}
|
||||
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
if user != nil && user.Password == args {
|
||||
c.authenticated = true
|
||||
// Set home directory as CWD
|
||||
homeDir := user.HomeDir
|
||||
if homeDir == "" {
|
||||
homeDir = c.server.config.FTP.RootDir
|
||||
}
|
||||
c.cwd = "/"
|
||||
c.sendResponse(230, "Login successful")
|
||||
log.Printf("FTP user '%s' logged in from %s", c.username, c.conn.RemoteAddr())
|
||||
} else {
|
||||
c.username = ""
|
||||
c.sendResponse(530, "Login incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientConn) requireAuth() bool {
|
||||
if !c.authenticated {
|
||||
c.sendResponse(530, "Please login first")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *clientConn) handleQUIT() {
|
||||
c.sendResponse(221, "Goodbye")
|
||||
c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *clientConn) handlePWD() {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
c.sendResponse(257, fmt.Sprintf("\"%s\" is current directory", c.cwd))
|
||||
}
|
||||
|
||||
func (c *clientConn) handleCWD(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
newPath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(newPath)
|
||||
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil || !info.IsDir() {
|
||||
c.sendResponse(550, "Directory not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.cwd = newPath
|
||||
c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd))
|
||||
}
|
||||
|
||||
func (c *clientConn) handleCDUP() {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
if c.cwd != "/" {
|
||||
c.cwd = filepath.Dir(c.cwd)
|
||||
if c.cwd == "" {
|
||||
c.cwd = "/"
|
||||
}
|
||||
}
|
||||
c.sendResponse(250, fmt.Sprintf("Directory changed to %s", c.cwd))
|
||||
}
|
||||
|
||||
func (c *clientConn) handleTYPE(args string) {
|
||||
args = strings.ToUpper(args)
|
||||
switch args {
|
||||
case "A", "A N":
|
||||
c.transferType = "ASCII"
|
||||
c.sendResponse(200, "Type set to ASCII")
|
||||
case "I", "L 8":
|
||||
c.transferType = "BINARY"
|
||||
c.sendResponse(200, "Type set to Binary")
|
||||
default:
|
||||
c.sendResponse(504, "Type not supported")
|
||||
}
|
||||
}
|
||||
|
||||
func (c *clientConn) handlePASV() {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
if c.pasvListener != nil {
|
||||
c.pasvListener.Close()
|
||||
}
|
||||
|
||||
listener, err := net.Listen("tcp", "0.0.0.0:0")
|
||||
if err != nil {
|
||||
c.sendResponse(425, "Cannot open passive connection")
|
||||
return
|
||||
}
|
||||
c.pasvListener = listener
|
||||
c.pasvMode = true
|
||||
|
||||
addr := listener.Addr().(*net.TCPAddr)
|
||||
hostParts := strings.Split(c.conn.LocalAddr().(*net.TCPAddr).IP.String(), ".")
|
||||
p1 := addr.Port / 256
|
||||
p2 := addr.Port % 256
|
||||
|
||||
c.sendResponse(227, fmt.Sprintf("Entering Passive Mode (%s,%s,%s,%s,%d,%d)",
|
||||
hostParts[0], hostParts[1], hostParts[2], hostParts[3], p1, p2))
|
||||
}
|
||||
|
||||
func (c *clientConn) handlePORT(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(args, ",")
|
||||
if len(parts) != 6 {
|
||||
c.sendResponse(501, "Syntax error in PORT command")
|
||||
return
|
||||
}
|
||||
|
||||
host := strings.Join(parts[0:4], ".")
|
||||
p1, _ := strconv.Atoi(parts[4])
|
||||
p2, _ := strconv.Atoi(parts[5])
|
||||
c.dataHost = host
|
||||
c.dataPort = p1*256 + p2
|
||||
c.pasvMode = false
|
||||
|
||||
c.sendResponse(200, "PORT command successful")
|
||||
}
|
||||
|
||||
func (c *clientConn) getDataConn() (net.Conn, error) {
|
||||
if c.pasvMode && c.pasvListener != nil {
|
||||
conn, err := c.pasvListener.Accept()
|
||||
c.pasvListener.Close()
|
||||
c.pasvListener = nil
|
||||
return conn, err
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", c.dataHost, c.dataPort)
|
||||
return net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
}
|
||||
|
||||
func (c *clientConn) handleLIST(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := args
|
||||
if targetPath == "" || targetPath == "-a" || targetPath == "-la" || targetPath == "-al" {
|
||||
targetPath = c.cwd
|
||||
} else if !strings.HasPrefix(targetPath, "/") {
|
||||
targetPath = c.resolvePath(targetPath)
|
||||
}
|
||||
|
||||
realPath := c.toRealPath(targetPath)
|
||||
|
||||
entries, err := os.ReadDir(realPath)
|
||||
if err != nil {
|
||||
c.sendResponse(550, "Directory listing failed")
|
||||
return
|
||||
}
|
||||
|
||||
dataConn, err := c.getDataConn()
|
||||
if err != nil {
|
||||
c.sendResponse(425, "Cannot open data connection")
|
||||
return
|
||||
}
|
||||
defer dataConn.Close()
|
||||
|
||||
c.sendResponse(150, "Opening data connection for directory listing")
|
||||
|
||||
var lines []string
|
||||
for _, entry := range entries {
|
||||
info, _ := entry.Info()
|
||||
modTime := info.ModTime()
|
||||
perm := "-rwxr-xr-x"
|
||||
if info.IsDir() {
|
||||
perm = "drwxr-xr-x"
|
||||
}
|
||||
line := fmt.Sprintf("%s 1 ftp ftp %12d %s %s",
|
||||
perm,
|
||||
info.Size(),
|
||||
modTime.Format("Jan 02 15:04"),
|
||||
entry.Name(),
|
||||
)
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
if len(lines) == 0 {
|
||||
dataConn.Write([]byte(""))
|
||||
} else {
|
||||
dataConn.Write([]byte(strings.Join(lines, "\r\n") + "\r\n"))
|
||||
}
|
||||
|
||||
c.sendResponse(226, "Transfer complete")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleNLST(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
targetPath := args
|
||||
if targetPath == "" {
|
||||
targetPath = c.cwd
|
||||
}
|
||||
|
||||
realPath := c.toRealPath(c.resolvePath(targetPath))
|
||||
|
||||
entries, err := os.ReadDir(realPath)
|
||||
if err != nil {
|
||||
c.sendResponse(550, "Directory listing failed")
|
||||
return
|
||||
}
|
||||
|
||||
dataConn, err := c.getDataConn()
|
||||
if err != nil {
|
||||
c.sendResponse(425, "Cannot open data connection")
|
||||
return
|
||||
}
|
||||
defer dataConn.Close()
|
||||
|
||||
c.sendResponse(150, "Opening data connection")
|
||||
|
||||
var names []string
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
dataConn.Write([]byte(strings.Join(names, "\r\n") + "\r\n"))
|
||||
|
||||
c.sendResponse(226, "Transfer complete")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleRETR(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(filePath)
|
||||
|
||||
file, err := os.Open(realPath)
|
||||
if err != nil {
|
||||
c.sendResponse(550, "File not found")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dataConn, err := c.getDataConn()
|
||||
if err != nil {
|
||||
c.sendResponse(425, "Cannot open data connection")
|
||||
return
|
||||
}
|
||||
defer dataConn.Close()
|
||||
|
||||
c.sendResponse(150, "Opening data connection")
|
||||
|
||||
written, err := io.Copy(dataConn, file)
|
||||
if err != nil {
|
||||
c.sendResponse(426, "Transfer aborted")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("FTP RETR %s (%d bytes)", filePath, written)
|
||||
c.sendResponse(226, "Transfer complete")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleSTOR(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
// Check write permission
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
if user == nil || !user.Write {
|
||||
c.sendResponse(550, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(filePath)
|
||||
|
||||
// Ensure parent directory exists
|
||||
os.MkdirAll(filepath.Dir(realPath), 0755)
|
||||
|
||||
file, err := os.Create(realPath)
|
||||
if err != nil {
|
||||
c.sendResponse(550, "Cannot create file")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
dataConn, err := c.getDataConn()
|
||||
if err != nil {
|
||||
c.sendResponse(425, "Cannot open data connection")
|
||||
return
|
||||
}
|
||||
defer dataConn.Close()
|
||||
|
||||
c.sendResponse(150, "Opening data connection")
|
||||
|
||||
written, err := io.Copy(file, dataConn)
|
||||
if err != nil {
|
||||
c.sendResponse(426, "Transfer aborted")
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("FTP STOR %s (%d bytes)", filePath, written)
|
||||
c.sendResponse(226, "Transfer complete")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleDELE(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
if user == nil || !user.Write {
|
||||
c.sendResponse(550, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(filePath)
|
||||
|
||||
if err := os.Remove(realPath); err != nil {
|
||||
c.sendResponse(550, "Delete failed")
|
||||
return
|
||||
}
|
||||
c.sendResponse(250, "File deleted")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleMKD(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
if user == nil || !user.Write {
|
||||
c.sendResponse(550, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
dirPath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(dirPath)
|
||||
|
||||
if err := os.MkdirAll(realPath, 0755); err != nil {
|
||||
c.sendResponse(550, "Cannot create directory")
|
||||
return
|
||||
}
|
||||
c.sendResponse(257, fmt.Sprintf("\"%s\" created", dirPath))
|
||||
}
|
||||
|
||||
func (c *clientConn) handleRMD(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
if user == nil || !user.Write {
|
||||
c.sendResponse(550, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
dirPath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(dirPath)
|
||||
|
||||
if err := os.RemoveAll(realPath); err != nil {
|
||||
c.sendResponse(550, "Cannot remove directory")
|
||||
return
|
||||
}
|
||||
c.sendResponse(250, "Directory removed")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleRNFR(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
if user == nil || !user.Write {
|
||||
c.sendResponse(550, "Permission denied")
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(filePath)
|
||||
|
||||
if _, err := os.Stat(realPath); err != nil {
|
||||
c.sendResponse(550, "File not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.renameFrom = filePath
|
||||
c.sendResponse(350, "Ready for RNTO")
|
||||
}
|
||||
|
||||
func (c *clientConn) handleRNTO(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
if c.renameFrom == "" {
|
||||
c.sendResponse(503, "RNFR required first")
|
||||
return
|
||||
}
|
||||
|
||||
fromPath := c.toRealPath(c.renameFrom)
|
||||
toPath := c.toRealPath(c.resolvePath(args))
|
||||
|
||||
if err := os.Rename(fromPath, toPath); err != nil {
|
||||
c.sendResponse(550, "Rename failed")
|
||||
return
|
||||
}
|
||||
|
||||
c.sendResponse(250, "Renamed")
|
||||
c.renameFrom = ""
|
||||
}
|
||||
|
||||
func (c *clientConn) handleSIZE(args string) {
|
||||
if !c.requireAuth() {
|
||||
return
|
||||
}
|
||||
|
||||
filePath := c.resolvePath(args)
|
||||
realPath := c.toRealPath(filePath)
|
||||
|
||||
info, err := os.Stat(realPath)
|
||||
if err != nil {
|
||||
c.sendResponse(550, "File not found")
|
||||
return
|
||||
}
|
||||
|
||||
c.sendResponse(213, fmt.Sprintf("%d", info.Size()))
|
||||
}
|
||||
|
||||
func (c *clientConn) handleFEAT() {
|
||||
features := []string{
|
||||
"Features:",
|
||||
" PASV",
|
||||
" UTF8",
|
||||
" SIZE",
|
||||
}
|
||||
c.sendMultiResponse(211, features)
|
||||
}
|
||||
|
||||
func (c *clientConn) handleOPTS(args string) {
|
||||
if strings.ToUpper(args) == "UTF8 ON" {
|
||||
c.sendResponse(200, "UTF8 set to on")
|
||||
} else {
|
||||
c.sendResponse(501, "Option not understood")
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePath resolves a relative path to an absolute path within the FTP root
|
||||
func (c *clientConn) resolvePath(path string) string {
|
||||
if strings.HasPrefix(path, "/") {
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
return filepath.Clean(filepath.Join(c.cwd, path))
|
||||
}
|
||||
|
||||
// toRealPath converts an FTP virtual path to a real filesystem path
|
||||
func (c *clientConn) toRealPath(ftpPath string) string {
|
||||
user := c.server.config.GetFTPUser(c.username)
|
||||
rootDir := c.server.config.FTP.RootDir
|
||||
if user != nil && user.HomeDir != "" {
|
||||
rootDir = user.HomeDir
|
||||
}
|
||||
|
||||
// Remove leading slash
|
||||
cleanPath := strings.TrimPrefix(ftpPath, "/")
|
||||
return filepath.Join(rootDir, cleanPath)
|
||||
}
|
||||
Reference in New Issue
Block a user