{"id":28316733,"url":"https://github.com/camatcode/ex_ftp","last_synced_at":"2026-03-02T00:32:56.624Z","repository":{"id":291819481,"uuid":"978891067","full_name":"camatcode/ex_ftp","owner":"camatcode","description":"An extendable, lightweight FTP server with cloud integrations already built in","archived":false,"fork":false,"pushed_at":"2025-11-23T14:31:00.000Z","size":648,"stargazers_count":8,"open_issues_count":3,"forks_count":2,"subscribers_count":1,"default_branch":"main","last_synced_at":"2025-11-23T16:08:34.215Z","etag":null,"topics":["cloud","elixir","elixir-lang","elixir-library","ftp","ftp-server"],"latest_commit_sha":null,"homepage":"","language":"Elixir","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/camatcode.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":null,"license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-05-06T17:02:55.000Z","updated_at":"2025-11-23T14:31:03.000Z","dependencies_parsed_at":"2025-07-25T15:29:29.478Z","dependency_job_id":"3b4c8811-0a8c-4162-9600-73340a1c8948","html_url":"https://github.com/camatcode/ex_ftp","commit_stats":null,"previous_names":["camatcode/ftp_2_cloud","camatcode/ex_ftp"],"tags_count":10,"template":false,"template_full_name":null,"purl":"pkg:github/camatcode/ex_ftp","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/camatcode%2Fex_ftp","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/camatcode%2Fex_ftp/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/camatcode%2Fex_ftp/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/camatcode%2Fex_ftp/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/camatcode","download_url":"https://codeload.github.com/camatcode/ex_ftp/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/camatcode%2Fex_ftp/sbom","scorecard":{"id":283449,"data":{"date":"2025-08-17T14:59:12Z","repo":{"name":"github.com/camatcode/ex_ftp","commit":"414d5fdbbbb4a2334f5019a2a6666244aee4bdf7"},"scorecard":{"version":"v5.2.1","commit":"ab2f6e92482462fe66246d9e32f642855a691dc1"},"score":6.8,"checks":[{"name":"Packaging","score":-1,"reason":"packaging workflow not detected","details":["Warn: no GitHub/GitLab publishing workflow detected."],"documentation":{"short":"Determines if the project is published as a package that others can easily download, install, easily update, and uninstall.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#packaging"}},{"name":"Security-Policy","score":4,"reason":"security policy file detected","details":["Info: security policy file detected: SECURITY.md:1","Warn: no linked content found","Info: Found disclosure, vulnerability, and/or timelines in security policy: SECURITY.md:1","Info: Found text in security policy: SECURITY.md:1"],"documentation":{"short":"Determines if the project has published a security policy.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#security-policy"}},{"name":"Dependency-Update-Tool","score":10,"reason":"update tool detected","details":["Info: detected update tool: Dependabot: .github/dependabot.yml:1"],"documentation":{"short":"Determines if the project uses a dependency update tool.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#dependency-update-tool"}},{"name":"Binary-Artifacts","score":10,"reason":"no binaries found in the repo","details":null,"documentation":{"short":"Determines if the project has generated executable (binary) artifacts in the source repository.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#binary-artifacts"}},{"name":"Maintained","score":10,"reason":"30 commit(s) and 0 issue activity found in the last 90 days -- score normalized to 10","details":null,"documentation":{"short":"Determines if the project is \"actively maintained\".","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#maintained"}},{"name":"Code-Review","score":0,"reason":"Found 0/2 approved changesets -- score normalized to 0","details":null,"documentation":{"short":"Determines if the project requires human code review before pull requests (aka merge requests) are merged.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#code-review"}},{"name":"Token-Permissions","score":10,"reason":"GitHub workflow tokens follow principle of least privilege","details":["Info: topLevel 'contents' permission set to 'read': .github/workflows/ci.yml:5","Info: topLevel 'contents' permission set to 'read': .github/workflows/dependency-review.yml:13","Info: topLevel permissions set to 'read-all': .github/workflows/scorecard.yml:18","Info: no jobLevel write permissions found"],"documentation":{"short":"Determines if the project's workflows follow the principle of least privilege.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#token-permissions"}},{"name":"Pinned-Dependencies","score":10,"reason":"all dependencies are pinned","details":["Info:   7 out of   7 GitHub-owned GitHubAction dependencies pinned","Info:   6 out of   6 third-party GitHubAction dependencies pinned"],"documentation":{"short":"Determines if the project has declared and pinned the dependencies of its build process.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#pinned-dependencies"}},{"name":"Dangerous-Workflow","score":10,"reason":"no dangerous workflow patterns detected","details":null,"documentation":{"short":"Determines if the project's GitHub Action workflows avoid dangerous patterns.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#dangerous-workflow"}},{"name":"CII-Best-Practices","score":0,"reason":"no effort to earn an OpenSSF best practices badge detected","details":null,"documentation":{"short":"Determines if the project has an OpenSSF (formerly CII) Best Practices Badge.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#cii-best-practices"}},{"name":"Vulnerabilities","score":10,"reason":"0 existing vulnerabilities detected","details":null,"documentation":{"short":"Determines if the project has open, known unfixed vulnerabilities.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#vulnerabilities"}},{"name":"SAST","score":0,"reason":"SAST tool is not run on all commits -- score normalized to 0","details":["Warn: 0 commits out of 29 are checked with a SAST tool"],"documentation":{"short":"Determines if the project uses static code analysis.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#sast"}},{"name":"Signed-Releases","score":-1,"reason":"no releases found","details":null,"documentation":{"short":"Determines if the project cryptographically signs release artifacts.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#signed-releases"}},{"name":"License","score":10,"reason":"license file detected","details":["Info: project has a license file: LICENSE:0","Info: FSF or OSI recognized license: Apache License 2.0: LICENSE:0"],"documentation":{"short":"Determines if the project has defined a license.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#license"}},{"name":"Fuzzing","score":0,"reason":"project is not fuzzed","details":["Warn: no fuzzer integrations found"],"documentation":{"short":"Determines if the project uses fuzzing.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#fuzzing"}},{"name":"Branch-Protection","score":5,"reason":"branch protection is not maximal on development and all release branches","details":["Info: 'allow deletion' disabled on branch 'main'","Info: 'force pushes' disabled on branch 'main'","Warn: 'branch protection settings apply to administrators' is disabled on branch 'main'","Warn: 'stale review dismissal' is disabled on branch 'main'","Warn: required approving review count is 1 on branch 'main'","Warn: codeowners review is not required on branch 'main'","Warn: 'last push approval' is disabled on branch 'main'","Warn: no status checks found to merge onto branch 'main'","Info: PRs are required in order to make changes on branch 'main'"],"documentation":{"short":"Determines if the default and release branches are protected with GitHub's branch protection settings.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#branch-protection"}},{"name":"Contributors","score":0,"reason":"project has 0 contributing companies or organizations -- score normalized to 0","details":null,"documentation":{"short":"Determines if the project has a set of contributors from multiple organizations (e.g., companies).","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#contributors"}},{"name":"CI-Tests","score":10,"reason":"14 out of 14 merged PRs checked by a CI test -- score normalized to 10","details":null,"documentation":{"short":"Determines if the project runs tests before pull requests are merged.","url":"https://github.com/ossf/scorecard/blob/ab2f6e92482462fe66246d9e32f642855a691dc1/docs/checks.md#ci-tests"}}]},"last_synced_at":"2025-08-17T16:34:18.807Z","repository_id":291819481,"created_at":"2025-08-17T16:34:18.807Z","updated_at":"2025-08-17T16:34:18.807Z"},"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":29988049,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-03-01T22:42:38.399Z","status":"ssl_error","status_checked_at":"2026-03-01T22:41:51.863Z","response_time":124,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["cloud","elixir","elixir-lang","elixir-library","ftp","ftp-server"],"created_at":"2025-05-25T03:08:00.306Z","updated_at":"2026-03-02T00:32:56.587Z","avatar_url":"https://github.com/camatcode.png","language":"Elixir","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cpicture\u003e\n    \u003csource media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/camatcode/ex_ftp/refs/heads/main/assets/ex_ftp-logo-dark.png\"\u003e\n    \u003csource media=\"(prefers-color-scheme: light)\" srcset=\"https://raw.githubusercontent.com/camatcode/ex_ftp/refs/heads/main/assets/ex_ftp-logo-light.png\"\u003e\n    \u003cimg alt=\"ex_ftp logo\" src=\"https://raw.githubusercontent.com/camatcode/ex_ftp/refs/heads/main/assets/ex_ftp-logo-light.png\" width=\"320\"\u003e\n  \u003c/picture\u003e\n\u003c/p\u003e\n\n\u003cp align=\"center\" id=\"top\"\u003e\n  An extendable, lightweight FTP server with cloud integrations already built in\n\u003c/p\u003e\n\n\u003cp align=\"center\"\u003e\n  \u003ca href=\"https://hex.pm/packages/ex_ftp\"\u003e\n    \u003cimg alt=\"Hex Version\" src=\"https://img.shields.io/hexpm/v/ex_ftp.svg\"\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://hexdocs.pm/ex_ftp\"\u003e\n    \u003cimg alt=\"Hex Docs\" src=\"http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat\"\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://opensource.org/licenses/Apache-2.0\"\u003e\n    \u003cimg alt=\"Apache 2 License\" src=\"https://img.shields.io/hexpm/l/oban\"\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://github.com/camatcode/ex_ftp/actions?query=branch%3Amain++\"\u003e\n    \u003cimg alt=\"ci status\" src=\"https://github.com/camatcode/ex_ftp/workflows/ci/badge.svg\"\u003e\n  \u003c/a\u003e\n  \u003ca href='https://coveralls.io/github/camatcode/ex_ftp?branch=main'\u003e\n    \u003cimg src='https://coveralls.io/repos/github/camatcode/ex_ftp/badge.svg?branch=main' alt='Coverage Status' /\u003e\n  \u003c/a\u003e\n  \u003ca href=\"https://mastodon.social/@scrum_log\" target=\"_blank\" rel=\"noopener noreferrer\"\u003e\n    \u003cimg alt=\"Mastodon Follow\" src=\"https://img.shields.io/badge/mastodon-%40scrum__log%40mastodon.social-purple?color=6364ff\"\u003e\n  \u003c/a\u003e\n\u003c/p\u003e\n\n## Table of Contents\n\n- [Installation](#installation)\n- [Reckless Quick Start](#reckless-quick-start)\n- [Configuration](#configuration)\n  - [Server Config](#1-server-config)\n  - [Choosing an Authenticator](#2-choose-an-authenticator)\n  - [Choosing a Storage Connector](#3-choose-a-storage-connector)\n- [Authenticators](#authenticators)\n  - [No Auth](#authenticator-no-auth)\n  - [Passthrough Auth](#authenticator-passthrough-auth)\n  - [HTTP Basic Auth](#authenticator-http-basic-auth)\n  - [HTTP Digest Access Auth](#authenticator-http-digest-access-auth)\n  - [Bearer Token Auth](#authenticator-bearer-token-auth)\n  - [Webhook Auth](#authenticator-webhook-auth)\n  - [Custom Auth](#authenticator-custom-auth)\n- [Storage Connectors](#storage-connectors)\n  - [File](#storage-connector-file)\n  - [S3](#storage-connector-s3)\n    - [Using Minio or LocalStack](#using-minio-or-localstack)\n  - [Others through S3Proxy](#storage-connector-others-through-s3proxy)\n  - [Custom Storage Connector](#custom-storage-connector)\n- [Technical Details](#technical-details)\n  - [Supported Commands](#supported-commands)\n  - [Notes about Fly.io](#notes-about-flyio)\n- [Special Thanks](#special-thanks)\n\n## Installation\n\nAdd `:ex_ftp` to your list of deps in `mix.exs`:\n\n```elixir\n{:ex_ftp, \"~\u003e 1.0\"}\n```\n\nThen run `mix deps.get` to install ExFTP and its dependencies.\n\n## Reckless Quick Start\n\n* Configure ex_ftp \n  * to use the file system, \n  * start on port 4040, \n  * don't include auth\n\n```elixir\nconfig :ex_ftp,\n  ftp_port: \"FTP_PORT\" |\u003e System.get_env(\"4040\") |\u003e String.to_integer(),\n  min_passive_port: \"MIN_PASSIVE_PORT\" |\u003e System.get_env(\"40002\") |\u003e String.to_integer(),\n  max_passive_port: \"MAX_PASSIVE_PORT\" |\u003e System.get_env(\"40007\") |\u003e String.to_integer(),\n  authenticator: ExFTP.Auth.NoAuth,\n  authenticator_config: %{},\n  storage_connector: ExFTP.Storage.FileConnector\n```\n\n* Run `mix run --no-halt`\n\n```\n17:13:22.110 [info] Accepting connections on port 4040\n```\n\n* Connect using `ftp`\n\n```bash\n➜  ~ ftp localhost -p 4040      \n\nConnected to localhost.\n220 Hello from ExFTP.\nName (localhost:cam): \n331 User name okay, need password.\nPassword: \n502 Command not implemented.\nftp: Login failed\nftp\u003e ls\n229 Entering Extended Passive Mode (|||40002|)\n150 Here comes the directory listing.\nlr--r--r--    1 0        0               7 Feb 16  2024 bin -\u003e usr/bin\ndr--r--r--    1 0        0            4096 May 13  2025 boot\ndr--r--r--    1 0        0            4096 Feb 16  2024 cdrom\ndr--r--r--    1 0        0            4680 May 20  2025 dev\ndr--r--r--    1 0        0           12288 May 19  2025 etc\ndr--r--r--    1 0        0            4096 Mar 25  2025 home\nlr--r--r--    1 0        0               7 Feb 16  2024 lib -\u003e usr/lib\ndr--r--r--    1 0        0            4096 Feb 06  2025 lib32\nlr--r--r--    1 0        0               9 Feb 16  2024 lib64 -\u003e usr/lib64\ndr--r--r--    1 0        0            4096 Feb 06  2025 libx32\nd---r--r--    1 0        0           16384 Feb 16  2024 lost+found\ndr--r--r--    1 0        0            4096 Feb 29  2024 media\ndr--r--r--    1 0        0            4096 Jan 09  2024 mnt\ndrw-r--r--    1 0        0            4096 Apr 24  2025 opt\ndr--r--r--    1 0        0               0 May 02  2025 proc\nd---r--r--    1 0        0            4096 Mar 25  2025 root\ndr--r--r--    1 0        0            1580 May 17  2025 run\nlr--r--r--    1 0        0               8 Feb 16  2024 sbin -\u003e usr/sbin\ndr--r--r--    1 0        0            4096 Jan 09  2024 srv\n----r--r--    1 0        0      2147483648 Feb 16  2024 swapfile\ndr--r--r--    1 0        0               0 May 02  2025 sys\ndr--r--r--    1 0        0            4096 May 19  2025 timeshift\ndrw-r--r--    1 0        0           20480 May 20  2025 tmp\ndr--r--r--    1 0        0            4096 Apr 25  2025 usr\ndr--r--r--    1 0        0            4096 Mar 25  2025 var\n226 Directory send OK.\nftp\u003e ...\n```\n\n* Now, [properly configure it](#configuration).\n\n\n-------\n\n## Configuration\n\n### 1. Server Config\n\nHere is a detailed, example configuration.\n\n```elixir\nconfig :ex_ftp,\n  # port to run on\n  ftp_port: 21,\n  # optional, reports \"Hello from {server_name}\" on login\n  server_name: :ExFTP,\n  # the address this server binds to (default: 127.0.0.1)\n  ftp_addr: System.get_env(\"FTP_ADDR\", \"127.0.0.1\"),\n  # FTP uses temporary, negotiated ports for certain commands called passive ports\n  # Choose the min and max range for these ports\n  # This range would represent how many of these certain commands can run at the same time.\n  # Be aware, too few options could create bottlenecks\n  min_passive_port: System.get_env(\"MIN_PASSIVE_PORT\", \"40002\") |\u003e String.to_integer(),\n  max_passive_port: System.get_env(\"MAX_PASSIVE_PORT\", \"40012\") |\u003e String.to_integer(),\n  # See \"Choose an Authenticator\"\n  authenticator: ExFTP.Auth.BasicAuth,\n  authenticator_config: %{\n    # used to login\n    login_url: \"https://httpbin.dev/basic-auth/\",\n    login_method: :get,\n    # used to verify the user is still considered valid (optional)\n    authenticated_url: \"https://httpbin.dev/hidden-basic-auth/\",\n    authenticated_method: :get,\n    authenticated_ttl_ms: 1000 * 60 * 60\n  },\n  # See \"Choose a Storage Connector\"\n  storage_connector: ExFTP.Storage.FileConnector,\n  storage_config: %{}\n\n```\n\n\n### 2. Choose an Authenticator\n\nAn `ExFTP.Authenticator` validates credentials when an FTP client sends a `USER` and `PASS` command.\n\nEach authenticator is referenced in the `ex_ftp` config under the `authenticator` key. \n\nAdditionally, many require a map under `authenticator_config`.\n\n### 3. Choose a Storage Connector\n\nAn `ExFTP.StorageConnector` provides access to your chosen storage provider - with the FTP business abstracted away.\n\nEach storage connector is referenced in the `ex_ftp` config under the `storage_connector` key.\n\nAdditionally, many require a map under `storage_config`.\n\n-------\n\n## Authenticators\n\nBelow are all the included authenticators.\n\n### Authenticator: No Auth\n\n\u003e [!WARNING]\n\u003e This is not recommended for any production server.\n\nWhen **authenticator** is `ExFTP.Auth.NoAuth`, this authenticator will completely ignore any supplied credentials and \nassume everything is authenticated.\n\n\n```elixir\nconfig :ex_ftp,\n  #....\n  authenticator: ExFTP.Auth.NoAuth,\n  authenticator_config: %{}\n ```\n\n-------\n\n### Authenticator: Passthrough Auth\n\n\u003e [!WARNING] \n\u003e This is not recommended for any production server.\n\nWhen **authenticator** is `ExFTP.Auth.PassthroughAuth`, this authenticator will require credentials, \nbut accepts any user and password combination who isn't `root`.\n\n```elixir\nconfig :ex_ftp,\n  #....\n  authenticator: ExFTP.Auth.PassthroughAuth,\n  authenticator_config: %{}\n ```\n\n[^ top](#top)\n\n-------\n\n### Authenticator: HTTP Basic Auth\n\n\u003e [!WARNING]  \n\u003e This is not recommended for situations not protected by SSL.\n\nWhen **authenticator** is `ExFTP.Auth.BasicAuth`, this authenticator will call out to an HTTP endpoint that implements \n[HTTP Basic Auth](https://en.wikipedia.org/wiki/Basic_access_authentication) with the user's supplied credentials.\n\n\n```elixir\nconfig :ex_ftp,\n  #....\n  authenticator: ExFTP.Auth.BasicAuth,\n  authenticator_config: %{\n    # used to login\n    login_url: \"https://httpbin.dev/basic-auth/\",\n    login_method: :get,\n    # used to verify the user is still considered valid (optional)\n    authenticated_url: \"https://httpbin.dev/hidden-basic-auth/\",\n    authenticated_method: :get,\n    authenticated_ttl_ms: 1000 * 60 * 60\n  }\n ```\n\nIf the endpoint responds with **HTTP 200**, the user is considered authenticated.\n\nAdditionally, if configured, ex_ftp can call out to a separate endpoint that performs basic auth to check that a user\nis still considered valid.\n\n[^ top](#top)\n\n-------\n\n### Authenticator: HTTP Digest Access Auth\n\n\u003e [!NOTE]  \n\u003e This can be used in situations where SSL is not available, though be warned, Digest Access is considered\n\u003e an obsolete protocol.\n\nWhen **authenticator** is `ExFTP.Auth.DigestAuth`, this authenticator will call out to an HTTP endpoint that\nimplements [HTTP Digest Access Auth](https://en.wikipedia.org/wiki/Digest_access_authentication) with the user's\nsupplied credentials.\n\n\n```elixir\nconfig :ex_ftp,\n  # ... ,\n  authenticator: ExFTP.Auth.DigestAuth,\n  authenticator_config: %{\n    # used to login\n    login_url: \"https://httpbin.dev/digest-auth/auth/replace/me/MD5\",\n    login_method: :get,\n    # used to verify the user is still considered valid (optional)\n    authenticated_url: \"https://httpbin.dev/digest-auth/auth/replace/me/MD5\",\n    authenticated_method: :get,\n    authenticated_ttl_ms: 1000 * 60 * 60\n  }\n ```\n\nIf, after completing the full workflow, the endpoint responds with **HTTP 200**, the user is considered authenticated.\n\nAdditionally, if configured, ex_ftp can call out to a separate endpoint that performs digest auth to check that a user\nis still considered valid.\n\n[^ top](#top)\n\n-------\n\n### Authenticator: Bearer Token Auth\n\n\u003e [!NOTE]  \n\u003e This is helpful when the \"user\" is actually a system or process.\n\u003e\n\u003e `username` isn't important for a Bearer token; though a provided username is still held on to.\n\nWhen **authenticator** is `ExFTP.Auth.BearerAuth`, this authenticator will call out to an HTTP endpoint that implements\n[Bearer Tokens](https://swagger.io/docs/specification/v3_0/authentication/bearer-authentication/) with the user's \nsupplied credentials.\n\n```elixir\nconfig :ex_ftp,\n  #....\n  authenticator: ExFTP.Auth.BearerAuth,\n  authenticator_config: %{\n    # used to login\n    login_url: \"https://httpbin.dev/bearer\",\n    login_method: :post,\n    # used to verify the user is still considered valid (optional)\n    authenticated_url: \"https://httpbin.dev/bearer\",\n    authenticated_method: :post,\n    authenticated_ttl_ms: 1000 * 60 * 60\n  }\n ```\n\nIf the endpoint responds with **HTTP 200**, the user is considered authenticated.\n\nAdditionally, if configured, ex_ftp can call out to a separate endpoint that performs bearer auth to check that a user\nis still considered valid.\n\n\n[^ top](#top)\n\n-------\n\n### Authenticator: Webhook Auth\n\n\u003e [!NOTE]  \n\u003e `password_hash` is the hash of the supplied password using the hashing algorithm dictated by the config.\n\nWhen **authenticator** is `ExFTP.Auth.WebhookAuth`, this authenticator will call out to an HTTP endpoint that accepts\ntwo query parameters: `username` and/or `password_hash`.\n\n\n```elixir\nconfig :ex_ftp,\n  #....\n  authenticator: ExFTP.Auth.WebhookAuth,\n  authenticator_config: %{\n    # used to login\n    login_url: \"https://httpbin.dev/status/200\",\n    login_method: :post,\n    # affects the output of the `password_hash` query parameter\n    # accepts anything that :crypto can handle\n    password_hash_type: :sha256,\n    # used to verify the user is still considered valid (optional)\n    authenticated_url: \"https://httpbin.dev/status/200\",\n    authenticated_method: :post,\n    authenticated_ttl_ms: 1000 * 60 * 60\n  }\n ```\n\nIf the endpoint responds with **HTTP 200**, the user is considered authenticated.\n\nAdditionally, if configured, ex_ftp can call out to a separate endpoint that performs webhook auth to check that a user\nis still considered valid.\n\n[^ top](#top)\n\n-------\n\n### Authenticator: Custom Auth\n\nCreating your own Authenticator is simple - just implement the `ExFTP.Authenticator` behaviour.\n\n```elixir\n# SPDX-License-Identifier: Apache-2.0\ndefmodule MyCustomAuth do\n\n  alias ExFTP.Authenticator\n  @behaviour Authenticator\n\n  @impl Authenticator\n  @spec valid_user?(username :: Authenticator.username()) :: boolean\n  def valid_user?(username) do\n        # return true if the username is valid\n        # return false if invalid\n        # this short-circuits bad login requests,\n        # if it would take longer than 5 seconds to validate a username\n        #   then its best to just return true\n        #   as there wouldn't be a performance benefit\n  end\n\n  @impl Authenticator\n  @spec login(\n          password :: Authenticator.password(),\n          authenticator_state :: Authenticator.authenticator_state()\n        ) :: {:ok, Authenticator.authenticator_state()} | {:error, term()}\n  def login(password, authenticator_state) do\n        # authenticator_state may have the key :username\n        # perform initial login\n        # return {:ok, current_authenticator_state} if successful\n        #   authenticator_state is passed around during the session\n        #   your authenticated?/1 may want this method to put \n        #     something about the password in the state\n        # return {:error, anything} if unsuccessful\n  end\n\n  @impl Authenticator\n  @spec authenticated?(\n          authenticator_state :: Authenticator.authenticator_state()\n        ) :: boolean()\n  def authenticated?(authenticator_state), do\n        # re-check that a user is authenticated\n        # return true if successful\n        # return false if unsuccessful\n  end\nend\n```\n\n[^ top](#top)\n\n-------\n\n## Storage Connectors\n\nBelow are all the included storage connectors.\n\n### Storage Connector: File\n\nWhen **storage_connector** is `ExFTP.Storage.FileConnector`, this connector will use the file system of where \nit is running.\n\nThis is the out-of-the-box behavior you'd expect from any FTP server.\n\n```elixir\nconfig :ex_ftp,\n  #....\n  storage_connector: ExFTP.Storage.FileConnector,\n  storage_config: %{}\n```\n\n[^ top](#top)\n\n-------\n\n### Storage Connector: S3\n\nWhen **storage_connector** is `ExFTP.Storage.S3Connector`, this connector will use any S3-compatible storage provider.\n\nUnderneath the hood, ex_ftp is using `ExAws.S3`, so you'll need that configured properly.\n\n```elixir\n# ExAws is pretty smart figuring out S3 credentials of the system\n# For me, I had to include the region.\n# Consult the ExAws docs for more\nconfig :ex_aws,\n  region: {:system, \"AWS_REGION\"}\n\nconfig :ex_ftp,\n  #....\n  storage_connector: ExFTP.Storage.S3Connector,\n  storage_config: %{\n    # the `/` path of the FTP server will point to s3://{my-storage-bucket}/\n    storage_bucket: \"my-storage-bucket\"\n  }\n```\n\n#### Using Minio or LocalStack\n\nMinio is a popular open-source, self-hosted alternative to AWS S3. \n\nLocalStack is a popular way to test AWS without connecting to AWS.\n\nThe only difference in config will be how you configure `ExAws`.\n\nHere's an example with minio where we're changing the credentials and endpoint\n\n```elixir\n# Assuming:\n#   we're connecting to a minio @ https://my.minio.example.com:9000/\n#   there exists a $MINIO_ACCESS or $AWS_ACCESS_KEY_ID in system env\n#   there exists a $MINIO_SECRET or $AWS_SECRET_ACCESS_KEY in system env\nconfig :ex_aws,\n  access_key_id: [\n    {:system, \"MINIO_ACCESS\"},\n    {:system, \"AWS_ACCESS_KEY_ID\"},\n    :instance_role\n  ],\n  secret_access_key: [\n    {:system, \"MINIO_SECRET\"},\n    {:system, \"AWS_SECRET_ACCESS_KEY\"},\n    :instance_role\n  ],\n  s3: [\n    scheme: \"https://\",\n    host: \"my.minio.example.com\",\n    port: 9000,\n    region: \"us-east-1\"\n  ]\n\nconfig :ex_ftp,\n  #....\n  storage_connector: ExFTP.Storage.S3Connector,\n  storage_config: %{\n    # the `/` path of the FTP server will point to s3://{my-storage-bucket}/\n    storage_bucket: \"my-storage-bucket\"\n  }\n```\n\n[^ top](#top)\n\n\n-------\n\n### Storage Connector: Others through S3Proxy\n\nFor other storage providers (Google Cloud, Azure Storage, etc.), it's probably best to deploy a proxy that translates\nS3 requests into requests to those providers, then use the `ExFTP.Storage.S3Connector` to connect to that proxy.\n\n* See [S3Proxy](https://github.com/gaul/s3proxy?tab=readme-ov-file)\n\n[^ top](#top)\n\n-------\n\n### Custom Storage Connector\n\nCreating your own Storage Connector is simple - just implement the `ExFTP.StorageConnector` behaviour.\n\n```elixir\n# SPDX-License-Identifier: Apache-2.0\ndefmodule MyStorageConnector do\n  @moduledoc false\n\n  @behaviour ExFTP.StorageConnector\n\n  alias ExFTP.StorageConnector\n\n  @impl StorageConnector\n  @spec get_working_directory(connector_state :: StorageConnector.connector_state()) ::\n          String.t()\n  def get_working_directory(%{current_working_directory: cwd} = _connector_state) do\n    # returns the current directory, for most cases this is just a pass through\n    # however, you might want to modify what the current directory is\n    # based on some state\n  end\n\n  @impl StorageConnector\n  @spec directory_exists?(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) :: boolean\n  def directory_exists?(path, _connector_state) do\n    # Given a path, does this directory exist in storage?\n  end\n\n  @impl StorageConnector\n  @spec make_directory(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) :: {:ok, StorageConnector.connector_state()} | {:error, term()}\n  def make_directory(path, connector_state) do\n    # Given a path, make a directory\n    # For S3-like connectors, a \"directory\" doesn't really exist\n    #  so those connectors typically keep track of virtual directories\n    #  that we're created by user during the session\n    #  if they're unused, they aren't persisted.\n  end\n\n  @impl StorageConnector\n  @spec delete_directory(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) :: {:ok, StorageConnector.connector_state()} | {:error, term()}\n  def delete_directory(path, connector_state) do\n    # Give a path, delete the directory\n  end\n\n  @impl StorageConnector\n  @spec delete_file(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) :: {:ok, StorageConnector.connector_state()} | {:error, term()}\n  def delete_file(path, connector_state) do\n    # Give a path, delete the file\n  end\n\n  @impl StorageConnector\n  @spec get_directory_contents(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) ::\n          {:ok, [StorageConnector.content_info()]} | {:error, term()}\n  def get_directory_contents(path, connector_state) do\n    # returns a list of content_infos\n    # the model for them was inspired by File.lstat()\n    # Have a look at StorageConnector.content_info type\n  end\n\n  @impl StorageConnector\n  @spec get_content_info(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) ::\n          {:ok, StorageConnector.content_info()} | {:error, term()}\n  def get_content_info(path, _connector_state) do\n    # given a path, return information on the file/directory there\n    # Have a look at StorageConnector.content_info type\n  end\n\n  @impl StorageConnector\n  @spec get_content(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state()\n        ) :: {:ok, any()} | {:error, term()}\n  def get_content(path, _connector_state) do\n    # Return a {:ok, stream} of path\n  end\n\n  @impl StorageConnector\n  @spec create_write_func(\n          path :: StorageConnector.path(),\n          connector_state :: StorageConnector.connector_state(),\n          opts :: list()\n        ) :: function()\n  def create_write_func(path, connector_state, opts \\\\ []) do\n    # Return a function that will write `stream` to your storage at path\n    # e.g \n    # fn stream -\u003e\n    #  fs = File.stream!(path)\n    #\n    #  try do\n    #    _ =\n    #      stream\n    #      |\u003e chunk_stream(opts)\n    #      |\u003e Enum.into(fs)\n    #\n    #    {:ok, connector_state}\n    #  rescue\n    #    _ -\u003e\n    #      {:error, \"Failed to transfer\"}\n    #  end\n    #end\n  end\nend\n```\n\n[^ top](#top)\n\n-------\n\n## Technical Details\n\n### Supported Commands\n\n- General\n  - `QUIT`\n  - `SYST`\n  - `TYPE \u003cmode\u003e`\n  - `PASV`\n  - `EPSV`\n  - `EPRT \u003ceport_info\u003e`\n- Auth\n  - `USER \u003cusername\u003e`\n  - `PASS \u003cpassword\u003e`\n- Storage \n  - `PWD`\n  - `CDUP`\n  - `CWD \u003cpath\u003e`\n  - `MKD \u003cpath\u003e`\n  - `RMD \u003cpath\u003e`\n  - `DELE \u003cpath\u003e`\n  - `LIST`\n    - `LIST -a`\n    - `LIST -a \u003cpath\u003e`\n    - `LIST \u003cpath\u003e`\n  - `NLST`\n    - `NLST -a`\n    - `NLST -a \u003cpath\u003e`\n    - `NLST \u003cpath\u003e`\n  - `RETR \u003cpath\u003e`\n  - `SIZE \u003cpath\u003e`\n  - `STOR \u003cpath\u003e`\n\nSee `ExFTP.Storage.Common` for more information.\n\n\n### Notes about Fly.io\n\nIf you're wanting to deploy onto Fly.io, you'll quickly discover an issue with passive ports.\n\nFly wants you to enumerate all ports that your server will use, fine; however, it takes the assumption\nthat these ports will be open *on start* and will *remain* open. \n\nFTP passive ports are temporary and negotiated. Fly hates this and assumes something is going wrong.\n\nBe careful.\n\n[^ top](#top)\n\n-----\n\n## Special Thanks\n\nThe initial funding for this code came from [StudioCMS.io](https://studiocms.io/).\n\nIts first closed-source implementation came from [Jake Stover](https://github.com/jwstover) and expanded by the \nentire team at StudioCMS.\n\nFurthermore, StudioCMS's leadership allowed me to clean it up, generalize it, and open source it.\n\nThanks!\n\n[^ top](#top)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcamatcode%2Fex_ftp","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fcamatcode%2Fex_ftp","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fcamatcode%2Fex_ftp/lists"}