Packages
Multi-surface application runtime for Elixir. One TEA module renders to terminal, browser (LiveView), SSH, and MCP (agents). 30+ widgets, flexbox + CSS grid, AI agent runtime, distributed swarm with CRDTs, time-travel debugging, session recording, sandboxed REPL, and agentic commerce.
Current section
Files
Jump to
Current section
Files
lib/raxol/terminal/input/file_drop_handler.ex
defmodule Raxol.Terminal.Input.FileDropHandler do
alias Raxol.Core.Runtime.Log
import Bitwise
@moduledoc """
Handles file drag-and-drop operations for the terminal emulator.
Processes file URIs from drag-and-drop events and provides secure
file access with permission controls and type validation.
## Features
- File URI parsing and validation
- Multiple file drop support
- MIME type detection
- File size and permission checking
- Security controls and sandbox validation
- Integration with system file watchers
## Supported Protocols
- `file://` - Local file system paths
- `content://` - Android content URIs (if applicable)
- Data URLs for small embedded content
## Security
- File access is restricted to allowed directories
- File size limits prevent memory exhaustion
- MIME type validation prevents execution of dangerous files
- Symlink resolution with loop detection
"""
@type file_info :: %{
path: String.t(),
name: String.t(),
size: non_neg_integer(),
mime_type: String.t(),
permissions: map(),
last_modified: DateTime.t()
}
@type drop_event :: %{
files: [file_info()],
position: {non_neg_integer(), non_neg_integer()},
modifiers: map(),
timestamp: non_neg_integer()
}
@type drop_options :: %{
optional(:max_files) => non_neg_integer(),
optional(:max_file_size) => non_neg_integer(),
optional(:allowed_mime_types) => [String.t()],
optional(:allowed_extensions) => [String.t()],
optional(:allowed_directories) => [String.t()],
optional(:resolve_symlinks) => boolean(),
optional(:validate_permissions) => boolean()
}
# Configuration constants
@default_max_files 10
# 50MB
@default_max_file_size 50 * 1024 * 1024
@max_symlink_depth 10
# Common MIME type mappings
@mime_types %{
".txt" => "text/plain",
".md" => "text/markdown",
".json" => "application/json",
".xml" => "application/xml",
".csv" => "text/csv",
".log" => "text/plain",
".png" => "image/png",
".jpg" => "image/jpeg",
".jpeg" => "image/jpeg",
".gif" => "image/gif",
".svg" => "image/svg+xml",
".pdf" => "application/pdf",
".zip" => "application/zip",
".tar" => "application/x-tar",
".gz" => "application/gzip"
}
@doc """
Processes a file drop event from the terminal.
Takes raw file URIs from a drag-and-drop operation and converts them
into validated file information structures.
## Parameters
- `file_uris` - List of file URIs from the drop event
- `position` - {x, y} coordinates where files were dropped
- `options` - Configuration options for validation and security
## Returns
- `{:ok, drop_event}` - Successfully processed drop event
- `{:error, reason}` - Error with validation failure details
## Examples
iex> FileDropHandler.process_drop_event([
...> "file:///home/user/document.txt",
...> "file:///home/user/image.png"
...> ], {100, 200})
{:ok, %{files: [...], position: {100, 200}, ...}}
"""
@spec process_drop_event(
[String.t()],
{non_neg_integer(), non_neg_integer()},
drop_options()
) ::
{:ok, drop_event()} | {:error, term()}
def process_drop_event(file_uris, position, options \\ %{})
when is_list(file_uris) and is_tuple(position) do
config = build_config(options)
with :ok <- validate_drop_constraints(file_uris, config),
{:ok, file_infos} <- process_file_uris(file_uris, config) do
drop_event = %{
files: file_infos,
position: position,
modifiers: Map.get(options, :modifiers, %{}),
timestamp: System.monotonic_time(:millisecond)
}
{:ok, drop_event}
end
end
@doc """
Parses a file URI and extracts the local file path.
Supports various URI schemes and handles URL decoding properly.
## Parameters
- `uri` - File URI string (e.g., "file:///path/to/file.txt")
## Returns
- `{:ok, path}` - Successfully extracted file path
- `{:error, reason}` - Error parsing URI
## Examples
iex> FileDropHandler.parse_file_uri("file:///home/user/test%20file.txt")
{:ok, "/home/user/test file.txt"}
iex> FileDropHandler.parse_file_uri("http://example.com/file.txt")
{:error, :unsupported_scheme}
"""
@spec parse_file_uri(String.t()) :: {:ok, String.t()} | {:error, term()}
def parse_file_uri(uri) when is_binary(uri) do
case URI.parse(uri) do
%URI{scheme: "file", path: path} when is_binary(path) ->
decoded_path = URI.decode(path)
{:ok, decoded_path}
%URI{scheme: "data"} ->
# Handle data URLs for embedded content
parse_data_uri(uri)
%URI{scheme: nil, path: path} when is_binary(path) ->
# Assume it's already a local path
{:ok, path}
%URI{scheme: scheme} when scheme not in ["file", "data"] ->
{:error, {:unsupported_scheme, scheme}}
_ ->
{:error, :invalid_uri}
end
end
@doc """
Gets detailed information about a file.
Retrieves file metadata including size, permissions, MIME type, and timestamps.
## Parameters
- `file_path` - Path to the file
- `options` - Options for information gathering
## Returns
- `{:ok, file_info}` - File information structure
- `{:error, reason}` - Error accessing file
"""
@spec get_file_info(String.t(), map()) ::
{:ok, file_info()} | {:error, term()}
def get_file_info(file_path, options \\ %{}) when is_binary(file_path) do
resolve_symlinks = Map.get(options, :resolve_symlinks, true)
validate_permissions = Map.get(options, :validate_permissions, true)
with {:ok, resolved_path} <- resolve_file_path(file_path, resolve_symlinks),
{:ok, stat} <- File.stat(resolved_path),
{:ok, permissions} <-
get_file_permissions(resolved_path, validate_permissions) do
file_info = %{
path: resolved_path,
name: Path.basename(resolved_path),
size: stat.size,
mime_type: detect_mime_type(resolved_path),
permissions: permissions,
last_modified: stat.mtime
}
{:ok, file_info}
end
end
@doc """
Validates that a file drop operation meets security and size constraints.
## Parameters
- `files` - List of file information structures
- `options` - Validation options
## Returns
- `:ok` - All files pass validation
- `{:error, reason}` - Validation failure with details
"""
@spec validate_files([file_info()], drop_options()) :: :ok | {:error, term()}
def validate_files(files, options \\ %{}) when is_list(files) do
config = build_config(options)
with :ok <- validate_file_count(files, config),
:ok <- validate_file_sizes(files, config),
:ok <- validate_mime_types(files, config),
:ok <- validate_extensions(files, config),
:ok <- validate_directories(files, config) do
:ok
end
end
@doc """
Watches dropped files for changes and executes callbacks.
Sets up file system watchers on dropped files to detect modifications,
deletions, or other changes.
## Parameters
- `files` - List of files to watch
- `callbacks` - Map of callback functions for different events
## Returns
- `{:ok, watcher_pid}` - File watcher process ID
- `{:error, reason}` - Error setting up file watching
"""
@spec watch_dropped_files([file_info()], map()) ::
{:ok, pid()} | {:error, term()}
def watch_dropped_files(files, callbacks \\ %{}) when is_list(files) do
file_paths = Enum.map(files, & &1.path)
case start_file_watcher(file_paths, callbacks) do
{:ok, watcher_pid} ->
Log.info("Started file watcher for #{length(files)} dropped files")
{:ok, watcher_pid}
{:error, reason} ->
Log.warning("Failed to start file watcher: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Creates a temporary copy of dropped files in a secure location.
Useful for processing files without affecting the originals or when
working with files from untrusted sources.
## Parameters
- `files` - List of files to copy
- `options` - Copy options including temporary directory
## Returns
- `{:ok, copied_files}` - List of temporary file paths
- `{:error, reason}` - Error during copying
"""
@spec create_temporary_copies([file_info()], map()) ::
{:ok, [String.t()]} | {:error, term()}
def create_temporary_copies(files, options \\ %{}) when is_list(files) do
temp_dir = Map.get(options, :temp_dir, System.tmp_dir!())
preserve_names = Map.get(options, :preserve_names, true)
results =
Enum.map(files, fn file ->
temp_name =
if preserve_names do
Path.basename(file.path)
else
generate_temp_filename(file)
end
temp_path = Path.join(temp_dir, temp_name)
case File.cp(file.path, temp_path) do
:ok -> {:ok, temp_path}
{:error, reason} -> {:error, {file.path, reason}}
end
end)
case Enum.find(results, &match?({:error, _}, &1)) do
nil ->
copied_paths = Enum.map(results, fn {:ok, path} -> path end)
{:ok, copied_paths}
{:error, reason} ->
# Clean up any successfully copied files
results
|> Enum.filter(&match?({:ok, _}, &1))
|> Enum.each(fn {:ok, path} -> File.rm(path) end)
{:error, reason}
end
end
# Private functions
defp build_config(options) do
Map.merge(
%{
max_files: @default_max_files,
max_file_size: @default_max_file_size,
allowed_mime_types: nil,
allowed_extensions: nil,
allowed_directories: nil,
resolve_symlinks: true,
validate_permissions: true
},
options
)
end
defp validate_drop_constraints(file_uris, config) do
if length(file_uris) > config.max_files do
{:error, {:too_many_files, length(file_uris), config.max_files}}
else
:ok
end
end
defp process_file_uris(file_uris, config) do
results =
Enum.map(file_uris, fn uri ->
with {:ok, path} <- parse_file_uri(uri),
{:ok, file_info} <- get_file_info(path, config) do
{:ok, file_info}
end
end)
case Enum.find(results, &match?({:error, _}, &1)) do
nil ->
file_infos = Enum.map(results, fn {:ok, info} -> info end)
{:ok, file_infos}
{:error, reason} ->
{:error, reason}
end
end
defp parse_data_uri(uri) do
# Handle data: URIs for embedded content
# data:[<mediatype>][;base64],<data>
case String.split(uri, ",", parts: 2) do
[header, data] ->
if String.contains?(header, "base64") do
case Base.decode64(data) do
{:ok, decoded_data} -> {:ok, {:data, decoded_data, header}}
:error -> {:error, :invalid_base64_data}
end
else
{:ok, {:data, URI.decode(data), header}}
end
_ ->
{:error, :invalid_data_uri}
end
end
defp resolve_file_path(path, resolve_symlinks) do
if resolve_symlinks do
resolve_symlinks_recursive(path, 0)
else
{:ok, path}
end
end
defp resolve_symlinks_recursive(path, depth)
when depth > @max_symlink_depth do
{:error, {:symlink_loop, path}}
end
defp resolve_symlinks_recursive(path, depth) do
case File.read_link(path) do
{:ok, target} ->
# Resolve relative symlinks
resolved_target =
if Path.type(target) == :absolute do
target
else
Path.join(Path.dirname(path), target)
end
resolve_symlinks_recursive(resolved_target, depth + 1)
{:error, :enoent} ->
{:error, :file_not_found}
{:error, _} ->
# Not a symlink, return the original path
{:ok, path}
end
end
defp get_file_permissions(path, validate) do
if validate do
case File.stat(path) do
{:ok, %File.Stat{mode: mode}} ->
permissions = %{
readable: (mode &&& 0o444) != 0,
writable: (mode &&& 0o222) != 0,
executable: (mode &&& 0o111) != 0,
mode: mode
}
{:ok, permissions}
{:error, reason} ->
{:error, reason}
end
else
{:ok, %{}}
end
end
defp detect_mime_type(path) do
extension = Path.extname(path) |> String.downcase()
Map.get(@mime_types, extension, "application/octet-stream")
end
defp validate_file_count(files, config) do
if length(files) > config.max_files do
{:error, {:too_many_files, length(files), config.max_files}}
else
:ok
end
end
defp validate_file_sizes(files, config) do
oversized_files =
Enum.filter(files, fn file ->
file.size > config.max_file_size
end)
case oversized_files do
[] ->
:ok
[file | _] ->
{:error, {:file_too_large, file.path, file.size, config.max_file_size}}
end
end
defp validate_mime_types(files, config) do
case config.allowed_mime_types do
nil ->
:ok
allowed_types when is_list(allowed_types) ->
invalid_files =
Enum.filter(files, fn file ->
file.mime_type not in allowed_types
end)
case invalid_files do
[] ->
:ok
[file | _] ->
{:error, {:invalid_mime_type, file.path, file.mime_type}}
end
end
end
defp validate_extensions(files, config) do
case config.allowed_extensions do
nil ->
:ok
allowed_extensions when is_list(allowed_extensions) ->
invalid_files =
Enum.filter(files, fn file ->
extension = Path.extname(file.path) |> String.downcase()
extension not in allowed_extensions
end)
case invalid_files do
[] -> :ok
[file | _] -> {:error, {:invalid_extension, file.path}}
end
end
end
defp validate_directories(files, config) do
case config.allowed_directories do
nil ->
:ok
allowed_dirs when is_list(allowed_dirs) ->
invalid_files =
Enum.filter(files, fn file ->
not Enum.any?(allowed_dirs, fn allowed_dir ->
String.starts_with?(file.path, allowed_dir)
end)
end)
case invalid_files do
[] -> :ok
[file | _] -> {:error, {:directory_not_allowed, file.path}}
end
end
end
defp start_file_watcher(file_paths, callbacks) do
# This would integrate with a file watcher library like FileSystem
# For now, return a mock implementation
file_watcher_available? =
Application.get_env(:raxol, :file_watcher_available, false)
if file_watcher_available? do
# In a real implementation, this would start FileSystem.Watcher
# and set up event handlers for the callbacks
spawn_link(fn ->
file_watcher_loop(file_paths, callbacks)
end)
|> then(&{:ok, &1})
else
{:error, :file_watcher_not_available}
end
end
defp file_watcher_loop(file_paths, callbacks) do
# Mock file watcher implementation
# In a real implementation, this would use FileSystem events
receive do
{:file_event, path, event} ->
handle_file_event(path, event, callbacks)
file_watcher_loop(file_paths, callbacks)
:stop ->
:ok
after
30_000 ->
# Check files every 30 seconds as a fallback
check_files_manually(file_paths, callbacks)
file_watcher_loop(file_paths, callbacks)
end
end
defp handle_file_event(path, event, callbacks) do
callback =
case event do
:modified -> callbacks[:on_modified]
:deleted -> callbacks[:on_deleted]
:moved -> callbacks[:on_moved]
_ -> nil
end
if is_function(callback, 1) do
try do
callback.(path)
rescue
error ->
Log.warning("File watcher callback error: #{inspect(error)}")
end
end
end
defp check_files_manually(file_paths, callbacks) do
# Fallback manual file checking
Enum.each(file_paths, fn path ->
case File.stat(path) do
# File still exists
{:ok, _stat} ->
:ok
{:error, :enoent} ->
handle_file_event(path, :deleted, callbacks)
{:error, _} ->
:ok
end
end)
end
defp generate_temp_filename(file) do
extension = Path.extname(file.path)
timestamp = System.system_time(:microsecond)
"raxol_temp_#{timestamp}#{extension}"
end
end