defmodule Supabase.Storage.File do @moduledoc """ Represents an Object within a Supabase Storage Bucket. This module encapsulates the structure and operations related to an object or file stored within a Supabase Storage bucket. """ use Ecto.Schema import Ecto.Changeset alias Supabase.Storage alias Supabase.Storage.FileHandler alias Supabase.Storage.FileOptions alias Supabase.Storage.ListV2Options alias Supabase.Storage.SearchOptions alias Supabase.Storage.TransformOptions @typedoc """ An `Object` has the following attributes: - `id`: The unique identifier for the object. - `path`: The path to the object within its storage bucket. - `bucket_id`: The ID of the bucket that houses this object. - `name`: The name or title of the object. - `owner`: The owner or uploader of the object. - `metadata`: A map containing meta-information about the object (e.g., file type, size). - `created_at`: Timestamp indicating when the object was first uploaded or created. - `updated_at`: Timestamp indicating the last time the object was updated. - `last_accessed_at`: Timestamp of when the object was last accessed or retrieved. """ @type t :: %__MODULE__{ id: String.t(), path: Path.t(), name: String.t(), owner: String.t(), metadata: map(), created_at: NaiveDateTime.t(), updated_at: NaiveDateTime.t(), last_accessed_at: NaiveDateTime.t(), bucket_id: String.t() | nil, bucket: Storage.Bucket.t() | nil } @fields ~w(id path bucket_id name owner created_at updated_at metadata last_accessed_at)a @primary_key false embedded_schema do field(:path, :string) field(:id, :string) field(:name, :string) field(:owner, :string) field(:metadata, :map) field(:created_at, :naive_datetime) field(:updated_at, :naive_datetime) field(:last_accessed_at, :naive_datetime) belongs_to(:bucket, Storage.Bucket) end @spec parse(map | list(map)) :: {:ok, t | list(t)} | {:error, Ecto.Changeset.t()} @doc false def parse(attrs) when is_list(attrs) do Enum.reduce_while(attrs, [], fn attr, acc -> case parse(attr) do {:ok, data} -> {:cont, acc ++ [data]} {:error, changeset} -> {:halt, changeset} end end) |> then(fn data when is_list(data) -> {:ok, data} changeset -> {:error, changeset} end) end def parse(attrs) do %__MODULE__{} |> cast(attrs, @fields) |> validate_required([:id]) |> apply_action(:parse) end @doc """ Uploads a file to an existing bucket. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `file_path`: The **local** filesystem path, to upload from. - `object_path`: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. - `options`: Optional `Enumerable` that represents the `Supabase.Storage.FileOptions`. """ @spec upload(Storage.t(), file_path, object_path, options) :: Supabase.result(map) when file_path: Path.t(), object_path: Path.t(), options: Enumerable.t() def upload(%Storage{} = s, file_path, object_path, opts \\ %{}) when is_binary(file_path) and is_binary(object_path) do {:ok, opts} = FileOptions.parse(opts) clean_path = object_path |> String.replace(~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/") with {:ok, %{body: body}} <- FileHandler.create_file(s.client, s.bucket_id, clean_path, file_path, opts) do {:ok, %{path: clean_path, id: body["Id"], key: body["Key"]}} end end @doc """ Update a file in the storage bucket. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `file_path`: The **local** filesystem path, to upload from. - `object_path`: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. - `options`: Optional `Enumerable` that represents the `Supabase.Storage.FileOptions`. """ @spec update(Storage.t(), file_path, object_path, options) :: Supabase.result(map) when file_path: Path.t(), object_path: Path.t(), options: Enumerable.t() def update(%Storage{} = s, file_path, object_path, opts \\ %{}) do {:ok, opts} = FileOptions.parse(opts) clean_path = object_path |> String.replace(~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/") with {:ok, %{body: body}} <- FileHandler.update_file(s.client, s.bucket_id, clean_path, file_path, opts) do {:ok, %{path: clean_path, id: body["Id"], key: body["Key"]}} end end @doc """ Upload a file with a token generated from `create_signed_upload_url/3`. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `token`: token The token generated from `create_signed_upload_url/3`. - `file_path`: The **local** filesystem path, to upload from. - `object_path`: The file path, including the file name. Should be of the format `folder/subfolder/filename.png`. The bucket must already exist before attempting to upload. - `options`: Optional `Enumerable` that represents the `Supabase.Storage.FileOptions`. """ @spec upload_to_signed_url(Storage.t(), token, file_path, object_path, options) :: Supabase.result(map) when file_path: Path.t(), object_path: Path.t(), options: Enumerable.t(), token: String.t() def upload_to_signed_url(%Storage{} = s, token, file_path, object_path, opts \\ %{}) when is_binary(token) and is_binary(file_path) and is_binary(object_path) do {:ok, opts} = FileOptions.parse(opts) clean_path = object_path |> String.replace(~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/") with {:ok, %{body: body}} <- FileHandler.create_file_to_url( s.client, s.bucket_id, token, clean_path, file_path, opts ) do {:ok, %{path: clean_path, full_path: body["Key"]}} end end @doc """ Creates a signed upload URL. Signed upload URLs can be used to upload files to the bucket without further authentication. They are valid for 2 hours. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `obejct_path`: The file path, including the current file name. For example `folder/image.png`. - `options.upsert`: If set to true, allows the file to be overwritten if it already exists. """ @spec create_signed_upload_url(Storage.t(), Path.t(), list({:upsert, boolean})) :: Supabase.result(%{signed_url: String.t(), token: String.t(), path: Path.t()}) def create_signed_upload_url(%Storage{} = s, object_path, opts \\ []) when is_binary(object_path) and is_list(opts) do clean_path = object_path |> String.replace(~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/") with {:ok, %{body: body}} <- FileHandler.create_upload_signed_url(s.client, s.bucket_id, clean_path, opts) do uri = URI.parse(Path.join(s.client.storage_url, body["url"])) token = URI.decode_query(uri.query)["token"] {:ok, %{signed_url: to_string(uri), token: token, path: clean_path}} end end @doc """ Moves an existing file to a new path in the same bucket. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `options.from`: The original file path, including the current file name. For example `folder/image.png`. - `options.to`: The new file path, including the new file name. For example `folder/image-new.png`. - `options.destination_bucket`: The destination bucket ID to move the file if the `options.to` param refers to another bucket. """ @spec move(Storage.t(), options) :: Supabase.result(:moved) when options: list({:from, Path.t()} | {:to, Path.t()} | {:destination_bucket, String.t() | nil}) def move(%Storage{} = s, opts \\ []) when is_list(opts) do from = Keyword.fetch!(opts, :from) to = Keyword.fetch!(opts, :to) dest = Keyword.get(opts, :destination_bucket) with {:ok, _} <- FileHandler.move(s.client, s.bucket_id, from, to, dest) do {:ok, :moved} end end @doc """ Copies an existing file to a new path in the same bucket. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `options.from`: The original file path, including the current file name. For example `folder/image.png`. - `options.to`: The new file path, including the new file name. For example `folder/image-new.png`. - `options.destination_bucket`: The destination bucket ID to move the file if the `options.to` param refers to another bucket. """ @spec copy(Storage.t(), options) :: Supabase.result(:moved) when options: list({:from, Path.t()} | {:to, Path.t()} | {:destination_bucket, String.t() | nil}) def copy(%Storage{} = s, opts \\ []) when is_list(opts) do from = Keyword.fetch!(opts, :from) to = Keyword.fetch!(opts, :to) dest = Keyword.get(opts, :destination_bucket) with {:ok, _} <- FileHandler.copy(s.client, s.bucket_id, from, to, dest) do {:ok, :moved} end end @doc """ Creates a signed URL. Use a signed URL to share a file for a fixed amount of time. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `object_path`:The file path, including the current file name. For example `folder/image.png`. - `options.expires_in`: The number of seconds until the signed URL expires. For example, `60` for a URL which is valid for one minute. - `options.download`: Triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. - `options.transform`: An `Enumerable` that represents the `Supabase.Storage.TransformOptions` to transform the asset before serving it to the client. """ @spec create_signed_url(Storage.t(), object_path, options) :: Supabase.result(String.t()) when object_path: Path.t(), options: list( {:download, boolean | String.t() | nil} | {:transform, Enumerable.t() | nil} | {:expires_in, integer} ) def create_signed_url(%Storage{} = s, path, opts \\ []) do _ = Keyword.fetch!(opts, :expires_in) download = Keyword.get(opts, :download) clean_path = path |> String.replace(~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/") with {:ok, resp} <- FileHandler.create_signed_url(s.client, s.bucket_id, clean_path, opts) do uri = URI.parse(Path.join(s.client.storage_url, resp.body["signedURL"])) if is_nil(download) do {:ok, to_string(uri)} else query = URI.encode_query(%{"download" => if(download === true, do: "", else: download)}) uri = URI.append_query(uri, query) {:ok, to_string(uri)} end end end @doc """ Creates signed URLs for multiple files within a bucket. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `paths`: A list of file paths to create signed URLs for. - `opts`: A keyword list of options. ## Options - `:download`: A boolean or string indicating whether to download the file. - `:transform`: An enumerable of transformations to apply to the file. - `:expires_in`: An integer indicating the expiration time in seconds. ## Returns A `Supabase.result` containing a list of signed URLs. """ @spec create_signed_urls(Storage.t(), list(object_path), options) :: Supabase.result(list(result)) when object_path: Path.t(), result: %{path: String.t(), signed_url: String.t(), error: term | nil}, options: list( {:download, boolean | String.t() | nil} | {:transform, Enumerable.t() | nil} | {:expires_in, integer} ) def create_signed_urls(%Storage{} = s, paths, opts \\ []) when is_list(paths) do _ = Keyword.fetch!(opts, :expires_in) download = Keyword.get(opts, :download) clean_paths = Enum.map(paths, &(String.replace(&1, ~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/"))) with {:ok, resp} <- FileHandler.create_signed_urls(s.client, s.bucket_id, clean_paths, opts) do items = Enum.map(resp.body, &%{path: &1["path"], signed_url: &1["signedURL"], error: &1["error"]}) {:ok, for item <- items do uri = URI.parse(Path.join(s.client.storage_url, item.signed_url)) if is_nil(download) do %{item | signed_url: to_string(uri)} else query = URI.encode_query(%{"download" => if(download === true, do: "", else: download)}) uri = URI.append_query(uri, query) %{item | signed_url: to_string(uri)} end end} end end @doc """ Lists all the files within a bucket. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `prefix`: The folder path. - `options`: An `Enumerable` that represents the `#{SearchOptions}`. """ @spec list(Storage.t(), Path.t() | nil, options :: Enumerable.t()) :: Supabase.result(list(t)) def list(%Storage{} = s, prefix \\ nil, opts \\ %{}) do {:ok, opts} = SearchOptions.parse(opts) with {:ok, resp} <- FileHandler.list(s.client, s.bucket_id, prefix, opts) do {:ok, resp.body} end end @doc """ Lists files using cursor-based pagination (v2 API - Experimental). This method uses cursor-based pagination which is more efficient than offset-based pagination for large datasets. Cursor pagination has O(1) complexity regardless of the position in the dataset, while offset pagination becomes slower as the offset increases. ## Performance Comparison - Offset-based: `OFFSET 100000 LIMIT 100` - Very slow (must skip 100,000 records) - Cursor-based: Always fast regardless of position ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `prefix`: The folder path. - `options`: An `Enumerable` with the following keys: - `limit`: Number of results per page (default: 100) - `cursor`: Pagination cursor from previous response - `with_delimiter`: Enable folder hierarchy grouping (default: false) ## Returns A map with: - `has_next`: Boolean indicating if there are more pages - `cursor`: Cursor to use for the next page (if `has_next` is true) - `folders`: List of folder names (when `with_delimiter` is true) - `objects`: List of file objects ## Examples # First page {:ok, %{has_next: true, cursor: cursor, objects: files}} = Supabase.Storage.File.list_v2(storage, "public/", %{limit: 100}) # Next page using cursor {:ok, %{has_next: false, objects: more_files}} = Supabase.Storage.File.list_v2(storage, "public/", %{limit: 100, cursor: cursor}) # With folder hierarchy {:ok, %{folders: folders, objects: files}} = Supabase.Storage.File.list_v2(storage, "public/", %{with_delimiter: true}) """ @spec list_v2(Storage.t(), Path.t() | nil, options :: Enumerable.t()) :: Supabase.result(%{ has_next: boolean(), cursor: String.t() | nil, folders: list(String.t()), objects: list(t) }) def list_v2(%Storage{} = s, prefix \\ nil, opts \\ %{}) do {:ok, opts} = ListV2Options.parse(opts) with {:ok, resp} <- FileHandler.list_v2(s.client, s.bucket_id, prefix, opts) do body = resp.body # Parse the objects if they exist objects = case body["objects"] do nil -> [] objs when is_list(objs) -> Enum.map(objs, &parse_object/1) end result = %{ has_next: body["hasNext"] || false, cursor: body["cursor"], folders: body["folders"] || [], objects: objects } {:ok, result} end end defp parse_object(obj) when is_map(obj) do case __MODULE__.parse(obj) do {:ok, file} -> file {:error, _} -> obj end end @doc """ Deletes files within the same bucket ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `paths`: An array of files to delete, including the path and file name. For example `["folder/image.png"]`. """ @spec remove(Storage.t(), to_remove :: list(Path.t()) | Path.t()) :: Supabase.result(list(t) | t) def remove(%Storage{} = s, to_remove) when is_list(to_remove) do with {:ok, resp} <- FileHandler.remove_list(s.client, s.bucket_id, to_remove) do {:ok, resp.body} end end def remove(%Storage{} = s, to_remove) do remove(s, [to_remove]) end @doc """ A simple convenience function to get the URL for an asset in a public bucket. If you do not want to use this function, you can construct the public URL by concatenating the bucket URL with the path to the asset. This function does not verify if the bucket is public. If a public URL is created for a bucket which is not public, you will not be able to download the asset. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `path`: The path and name of the file to generate the public URL for. For example `folder/image.png`. - `options.download`: Triggers the file as a download if set to true. Set this parameter as the name of the file if you want to trigger the download with a different filename. - `options.transform`: `Supabase.Storage.TransformOptions` the asset before serving it to the client, as an `Enumerable`. """ @spec get_public_url(Storage.t(), object_path, options) :: Supabase.result(String.t()) when object_path: Path.t(), options: list( {:download, boolean | String.t() | nil} | {:transform, Enumerable.t() | nil} ) def get_public_url(%Storage{} = s, path, opts \\ []) when is_binary(path) and is_list(opts) do download = Keyword.get(opts, :download) transform = Keyword.get(opts, :transform) {:ok, transform} = if transform, do: TransformOptions.parse(transform), else: {:ok, nil} clean_path = path |> String.replace(~r/^\/|\/$/, "") |> String.replace(~r/\/+/, "/") render_path = if transform, do: "render/image", else: "object" uri = [s.client.storage_url, render_path, "public", s.bucket_id, clean_path] |> Path.join() |> URI.parse() transform_query = if is_nil(transform), do: "", else: to_string(transform) if is_nil(download) do {:ok, to_string(URI.append_query(uri, transform_query))} else query = URI.encode_query(%{"download" => if(download === true, do: "", else: download)}) uri = URI.append_query(uri, query) |> URI.append_query(transform_query) {:ok, to_string(uri)} end end @doc """ Checks the existence of a file. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `path` """ @spec exists?(Storage.t(), path :: Path.t()) :: boolean def exists?(%Storage{} = s, path) when is_binary(path) do case FileHandler.exists(s.client, s.bucket_id, path) do {:ok, _} -> true _ -> false end end @doc """ Retrieves the details of an existing file. ## Params - `storage`: The `Supabase.Storage` instance created with `Supabase.Storage.from/2`. - `path` """ @spec info(Storage.t(), path :: Path.t()) :: Supabase.result(t) def info(%Storage{} = s, path) when is_binary(path) do with {:ok, resp} <- FileHandler.get_info(s.client, s.bucket_id, path) do {:ok, resp.body} end end @doc """ Downloads a file from a private bucket. For public buckets, make a request to the URL returned from `getPublicUrl` instead. ## Params - `path`: The full path and file name of the file to be downloaded. For example `folder/image.png`. - `options.transform`: Transform the asset before serving it to the client. """ @spec download(Storage.t(), path :: Path.t(), options) :: Supabase.result(binary) when options: list({:transform, Enumerable.t() | nil}) def download(%Storage{} = s, path, opts \\ []) when is_binary(path) and is_list(opts) do transform = Keyword.get(opts, :transform) with {:ok, resp} <- FileHandler.get(s.client, s.bucket_id, path, transform) do {:ok, resp.body} end end @doc """ Downloads a file from a private bucket, lazily. For public buckets, make a request to the URL returned from `getPublicUrl` instead. ## Params - `path`: The full path and file name of the file to be downloaded. For example `folder/image.png`. - `options.transform`: Transform the asset before serving it to the client. """ @spec download_lazy(Storage.t(), path :: Path.t(), on_response, options) :: Supabase.result(binary) when options: list({:transform, Enumerable.t() | nil}), on_response: ({Supabase.Fetcher.status(), Supabase.Fetcher.headers(), binary} -> Supabase.result(Supabase.Fetcher.Response.t())) def download_lazy(%Storage{} = s, path, on_response, opts \\ []) when is_binary(path) and is_list(opts) do transform = Keyword.get(opts, :transform) with {:ok, resp} <- FileHandler.get_lazy(s.client, s.bucket_id, path, transform, on_response) do {:ok, resp.body} end end end