import filepath import ftpasta/ftp_error.{type FTPError, DirParseError, LocalFileError} import ftpasta/internal import ftpasta/tls_config.{type TLSConfig} import gleam/erlang/process.{type Pid} import gleam/int import gleam/list import gleam/option import gleam/regex import gleam/result import gleam/string import simplifile /// Type representing an FTP connection. pub opaque type Connection { Connection(pid: Pid) } /// Remote file type. pub type Listing { Directory(name: String) File(name: String, size: Int) Link(name: String, target: String) } // Misc functions: /// Convert an FTPError into a descriptive string. pub fn describe_error(error) { case error { DirParseError -> "Parsing directory contents failed." LocalFileError(e) -> "Local file error: " <> simplifile.describe_error(e) _ -> internal.formaterror(error) } } // FTP configuration: /// Configuration struct for building FTP connections. pub type Config { Config( host: String, port: Int, tls: TLSConfig, username: String, password: String, ) } /// Start building an FTP connection. pub fn new(host) { Config( host: host, port: 21, tls: tls_config.Plaintext, username: "anonymous", password: "", ) } /// Connect to the server. pub fn connect(config: Config) -> Result(Connection, FTPError) { use pid <- result.try(internal.open(config.host, config.port, config.tls)) use _ <- result.map(internal.user(pid, config.username, config.password)) Connection(pid) } /// Provide a username and password. pub fn auth(config: Config, username, password) { Config(..config, username:, password:) } /// Use a non-default port. pub fn port(config: Config, port) { Config(..config, port:) } /// Use encryption. pub fn encryption(config: Config, tls: TLSConfig) { Config(..config, tls:) } // FTP operations /// Append some data onto a file on the server. pub fn append_bytes(connection: Connection, data, remote_file) { internal.append_bin(connection.pid, data, remote_file) } /// Append a file to another file on the server. pub fn append_file(connection: Connection, local_file, remote_file) { use _ <- result.try(reset_local_dir(connection)) internal.append(connection.pid, local_file, remote_file) } /// Change directories on the remote server. pub fn change_working_dir(connection: Connection, remote_dir) { internal.cd(connection.pid, remote_dir) } /// Close the connection. pub fn close(connection: Connection) { internal.close(connection.pid) } /// Create a new directory on the server. pub fn create_dir(connection: Connection, remote_dir) { internal.mkdir(connection.pid, remote_dir) } /// Remove an empty directory from the server. pub fn delete_dir(connection: Connection, remote_dir) { internal.rmdir(connection.pid, remote_dir) } /// Remove a file from the server. pub fn delete_file(connection: Connection, remote_file) { internal.delete(connection.pid, remote_file) } /// Remove a directory and all contents from the server. pub fn delete_recursive(connection: Connection, remote_dir) { use current_dir <- result.try(get_working_dir(connection)) use _ <- result.try(change_working_dir(connection, remote_dir)) use items <- result.try(list(connection, "")) use _ <- result.try( list.try_map(items, fn(item) { case item { File(name, ..) | Link(name, ..) -> delete_file(connection, name) Directory(name) -> delete_recursive(connection, name) } }), ) use _ <- result.try(change_working_dir(connection, current_dir)) delete_dir(connection, remote_dir) } /// Fetch a file from the server, and return it as a BitArray. pub fn download_bytes(connection: Connection, remote_file) { internal.recv_bin(connection.pid, remote_file) } /// Fetch a file from the server. pub fn download_file(connection: Connection, remote_file, local_file) { use _ <- result.try(reset_local_dir(connection)) internal.recv(connection.pid, remote_file, local_file) } /// Fetch a directory and all contents recursively from the server. pub fn download_recursive(connection: Connection, remote_dir, local_dir) { use _ <- result.try( simplifile.create_directory(local_dir) |> result.map_error(LocalFileError), ) use items <- result.try(list(connection, remote_dir)) list.try_map(items, fn(item) { let local_path = filepath.join(local_dir, item.name) let remote_path = remote_dir <> "/" <> item.name case item { File(..) | Link(..) -> download_file(connection, remote_path, local_path) Directory(..) -> download_recursive(connection, remote_path, local_path) } }) |> result.replace(Nil) } /// Get the current remote path used for the connection. pub fn get_working_dir(connection: Connection) { internal.pwd(connection.pid) } /// List all files, symlinks, and subdirectories in a directory. pub fn list( connection: Connection, remote_dir, ) -> Result(List(Listing), FTPError) { let assert Ok(re) = regex.from_string("[\\r\\n]+") let arg = case remote_dir { "" -> "-Al" dir -> "-Al " <> dir } use text <- result.try(internal.ls(connection.pid, arg)) regex.split(re, text) |> list.filter(fn(s) { !string.is_empty(s) }) |> list.try_map(parse_ls_line) } /// Rename a file or directory on the server. pub fn rename(connection: Connection, old_path, new_path) { internal.rename(connection.pid, old_path, new_path) } /// Send a data in memory to the server. pub fn upload_bytes(connection: Connection, data, remote_file) { internal.send_bin(connection.pid, data, remote_file) } /// Send a file to the server. pub fn upload_file(connection: Connection, local_file, remote_file) { use _ <- result.try(reset_local_dir(connection)) internal.send(connection.pid, local_file, remote_file) } /// Send a directory and all contents to the server. pub fn upload_recursive(connection: Connection, local_dir, remote_dir) { use _ <- result.try(create_dir(connection, remote_dir)) use items <- result.try( simplifile.read_directory(local_dir) |> result.map_error(LocalFileError), ) list.try_map(items, fn(name) { let local_path = filepath.join(local_dir, name) let remote_path = remote_dir <> "/" <> name case simplifile.is_file(local_path) { Ok(True) -> upload_file(connection, local_path, remote_path) Ok(False) -> upload_recursive(connection, local_path, remote_path) Error(e) -> Error(LocalFileError(e)) } }) |> result.replace(Nil) } // Other functions /// Parse a line from the output of "ls". /// This is obviously gross, but FTP lacks better options without extensions. fn parse_ls_line(text) -> Result(Listing, FTPError) { let assert Ok(whitespace) = regex.from_string(" +") let assert Ok(filename) = regex.from_string("(?:\\S+ +){8}(.+)") use name <- result.try(case regex.scan(filename, text) { [regex.Match(submatches: [option.Some(x)], ..)] -> Ok(x) _ -> Error(DirParseError) }) case regex.split(whitespace, text) { ["d" <> _, ..] -> { Ok(Directory(name:)) } ["-" <> _, _, _, _, size_text, ..] -> { case int.parse(size_text) { Ok(size) -> Ok(File(name:, size:)) Error(_) -> Error(DirParseError) } } ["l" <> _, ..] -> { // This fails on links with " -> " in their name. case string.split_once(name, " -> ") { Ok(#(name, target)) -> Ok(Link(name:, target:)) Error(_) -> Error(DirParseError) } } _ -> Error(DirParseError) } } /// Update the local path stored in the FTP library. fn reset_local_dir(connection: Connection) { case simplifile.current_directory() { Ok(path) -> internal.lcd(connection.pid, path) Error(e) -> Error(LocalFileError(e)) } }