Current section

Files

Jump to
ftpasta src ftpasta.gleam
Raw

src/ftpasta.gleam

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/regexp
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)
}
}
/// Compare two remote files by name.
fn compare_listings(a: Listing, b: Listing) {
string.compare(a.name, b.name)
}
// FTP configuration:
/// Configuration struct for building FTP connections.
pub type Config {
Config(
host: String,
port: Int,
tls: TLSConfig,
verify: Bool,
username: String,
password: String,
)
}
/// Start building an FTP connection.
pub fn new(host) {
Config(
host: host,
port: 21,
tls: tls_config.Plaintext,
verify: True,
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,
config.verify,
))
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:)
}
/// How to verify certificates.
pub fn verification(config: Config, enabled: Bool) {
Config(..config, verify: enabled)
}
// 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 items <- result.try(list(connection, remote_dir))
use _ <- result.try(
list.try_map(items, fn(item) {
let path = remote_dir <> "/" <> item.name
case item {
File(..) | Link(..) -> delete_file(connection, path)
Directory(..) -> delete_recursive(connection, path)
}
}),
)
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) = regexp.from_string("[\\r\\n]+")
let arg = case remote_dir {
"" -> "-Al"
dir -> "-Al " <> dir
}
use text <- result.try(internal.ls(connection.pid, arg))
regexp.split(re, text)
|> list.filter(fn(s) { !string.is_empty(s) })
|> list.try_map(parse_ls_line)
|> result.map(list.sort(_, compare_listings))
}
/// 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) = regexp.from_string(" +")
let assert Ok(filename) = regexp.from_string("(?:\\S+ +){8}(.+)")
use name <- result.try(case regexp.scan(filename, text) {
[regexp.Match(submatches: [option.Some(x)], ..)] -> Ok(x)
_ -> Error(DirParseError)
})
case regexp.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))
}
}