A store keeps the files: Storehouse.Local (the local disk, the
default), Storehouse.S3 (S3 and the stores that speak its API), or an
adapter of your own (Storehouse.Adapter).
The local disk
# config/config.exs
config :storehouse, adapter: Storehouse.Local
# config/runtime.exs
if config_env() == :prod do
config :storehouse, Storehouse.Local,
root: System.get_env("UPLOADS", "/var/lib/my_app/uploads"),
url_prefix: "/uploads"
end
# config/dev.exs
config :storehouse, Storehouse.Local, root: Path.expand("../tmp/uploads", __DIR__)In a release, give an absolute root outside the release directory, in
the :prod block of config/runtime.exs. A relative path is relative to
the working directory, and files in the release directory are lost at the
next deploy. Keep the production root inside the :prod block:
config/runtime.exs runs in every environment, after config/dev.exs,
so outside the block it would replace the development root.
Serve the files with Storehouse.Local.Plug (see Serving).
S3, and the stores that speak its API
Storehouse.S3 keeps the files in a bucket of Amazon S3, or of a store
with its API: Cloudflare R2, MinIO, Tigris, Backblaze B2, DigitalOcean
Spaces, Google Cloud Storage's interoperability. It needs no other
library: it signs its requests itself.
# config/runtime.exs
if config_env() == :prod do
config :storehouse, adapter: Storehouse.S3
config :storehouse, Storehouse.S3,
bucket: "my-app-uploads",
region: "eu-west-1",
access_key_id: System.fetch_env!("AWS_ACCESS_KEY_ID"),
secret_access_key: System.fetch_env!("AWS_SECRET_ACCESS_KEY")
endAnother store: its endpoint, whose URLs name the bucket in their path
(path_style, the default with an endpoint), and the public_url of its
files when it serves them from a domain of yours or a CDN:
config :storehouse, Storehouse.S3,
bucket: "uploads",
region: "auto",
endpoint: "https://<account>.r2.cloudflarestorage.com",
access_key_id: System.fetch_env!("R2_ACCESS_KEY_ID"),
secret_access_key: System.fetch_env!("R2_SECRET_ACCESS_KEY"),
public_url: "https://files.example.com"A file's url is its public URL: of a public bucket, or of public_url.
A private bucket serves its files at signed URLs (see
Private files).
In development
Two ways, as you like it.
The local disk, with nothing else to run: Storehouse.Local in
config/dev.exs, its plug in the endpoint, and a secret for direct
uploads and signed URLs. The browser uploads straight to the plug as it
would to a bucket, so the LiveView is the same in development and in
production.
# config/dev.exs
config :storehouse, adapter: Storehouse.Local
config :storehouse, Storehouse.Local,
root: Path.expand("../tmp/uploads", __DIR__),
secret: "a development secret of at least 32 bytes"LocalStack (or MinIO), S3 on your machine: Storehouse.S3, with its
endpoint and any keys.
# config/dev.exs
config :storehouse, adapter: Storehouse.S3
config :storehouse, Storehouse.S3,
bucket: "uploads",
region: "us-east-1",
endpoint: "http://localhost:4566",
access_key_id: "test",
secret_access_key: "test"awslocal s3 mb s3://uploads
awslocal s3api put-bucket-cors --bucket uploads --cors-configuration file://cors.json
with the CORS rule of Direct uploads, for the
origin of your development server (http://localhost:4000).
In tests, the local disk, in a directory of the test's own:
# config/test.exs
config :storehouse, Storehouse.Local,
root: Path.expand("../tmp/test-uploads", __DIR__),
secret: "a test secret of at least 32 bytes"Another store
Write a module with the three callbacks of Storehouse.Adapter
(put/3, url/1 and delete/1), and the optional ones for direct
uploads and signed URLs (presign_upload/3, read_head/2,
signed_url/2), and name it in the config:
config :storehouse, adapter: MyApp.Storageor give it per call: Storehouse.store(path, adapter: MyApp.Storage).
The Storehouse.Adapter docs have an example on ExAws.S3, for an app
that has it already.
Two rules for an adapter:
- Store each file with its checked content type (
meta.content_type), and withContent-Disposition: attachmentfor every type thatStorehouse.Check.inline?/1refuses. Without this, a store such as S3 serves an uploaded HTML file inline, and it can run a script on the store's origin. - The URL goes into your records and documents, so it must stay valid.
Give a public or CDN URL, or a path of your app (for example
"/files/" <> key) whose controller checks access and redirects to a short-lived signed URL.
Keys
Storehouse.key/2 makes yyyy/mm/<uuid>-<safe name><extension>. The
:key option of Storehouse.store/2 (and of Storehouse.Live.consume/3)
makes another, a function of the name and the checked content type: a
prefix per account, for example.
Storehouse.store(path, key: &("#{account.slug}/uploads/" <> Storehouse.key(&1, &2)))An adapter refuses a key that Storehouse.valid_key?/1 refuses: no ..,
no absolute path, nothing but ASCII letters, digits, ., -, _ and
/.
Files from the server
A seed, an import or a file the app makes itself is stored the same way,
with no browser: Storehouse.store/2 checks it, stores it and gives a
Storehouse.File. The file at the path stays where it is; the store has
its copy.
{:ok, file} = Storehouse.store("priv/seeds/timeline.png", name: "Timeline.png")Files from an address
Storehouse.fetch/2 downloads a file and stores it: an image pasted by
its address, a file an agent names. It fetches only from the hosts you
give, https unless you allow http, follows a redirect only to one of
them, and stops a download as soon as it is larger than :max_bytes:
Storehouse.fetch(url,
hosts: ["images.example.com", "*.cdn.example.com"],
accept: Storehouse.Check.images(),
max_bytes: 10_000_000
)Never give a host whose addresses people choose freely, and never one of your own network: the server would fetch what they ask for.
Previews
A PDF can show an image of its first page, a video a poster frame, a
large photo a thumbnail. Storehouse does not make them: give :preview,
a function of the stored file and the path of the file that returns the
URL of the image, or nil. It runs while the file is still there. With
Poppler and FFmpeg installed:
defmodule MyApp.Previews do
# The first page of a PDF (Poppler), the first frame of a video (FFmpeg).
def make(%Storehouse.File{content_type: "application/pdf"} = file, path),
do: store(file, ".png", &pdftoppm(path, &1))
def make(%Storehouse.File{content_type: "video/" <> _} = file, path),
do: store(file, ".jpg", &ffmpeg(path, &1))
def make(_file, _path), do: nil
defp pdftoppm(path, image),
do: System.cmd("pdftoppm", ["-png", "-singlefile", "-r", "72", path, Path.rootname(image)])
defp ffmpeg(path, image),
do: System.cmd("ffmpeg", ["-v", "error", "-i", path, "-frames:v", "1", image])
# Renders the image to a temporary file and stores it next to the file.
defp store(file, extension, render) do
image = Path.join(System.tmp_dir!(), "preview-#{System.unique_integer([:positive])}#{extension}")
try do
with {_output, 0} <- render.(image),
{:ok, preview} <- Storehouse.store(image, key: fn _, _ -> file.key <> extension end) do
preview.url
else
_error -> nil
end
after
File.rm(image)
end
end
endThe URL goes into the file's preview. A result that is not a safe URL,
or an exception, gives no preview and logs a warning: the file is stored
either way. In a LiveView, the function runs in the LiveView's process:
give the tools a time limit.
Deleted files
Storehouse does not know which of your records use a file. When one goes,
delete its file with Storehouse.delete/2 (or let a job compare your
records with the store). Cleanup of the files nothing uses is on the
roadmap.