Skip to content

Commit 49c1e5a

Browse files
authored
Merge pull request #311 from dainst/field_hub_streamed_files
Support arbitrary file sizes in FieldHub
2 parents 0e28878 + f52eafc commit 49c1e5a

File tree

7 files changed

+70
-35
lines changed

7 files changed

+70
-35
lines changed

server/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
# - https://pkgs.org/ - resource for finding needed packages
1313
# - Ex: hexpm/elixir:1.13.0-erlang-24.2-debian-bullseye-20210902-slim
1414
#
15-
ARG BUILDER_IMAGE="hexpm/elixir:1.13.0-erlang-24.2-debian-bullseye-20210902-slim"
16-
ARG RUNNER_IMAGE="debian:bullseye-20210902-slim"
15+
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-25.3.2.20-debian-bookworm-20250407-slim"
16+
ARG RUNNER_IMAGE="debian:bookworm-20250407-slim"
1717

1818
FROM ${BUILDER_IMAGE} as builder
1919

server/config/config.exs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ config :field_hub,
3131
couchdb_user_password: "app_user_password",
3232
valid_file_variants: [:thumbnail_image, :original_image],
3333
file_index_cache_name: :file_info,
34-
file_max_size: 1_000_000_000,
3534
user_tokens_cache_name: :user_tokens,
3635
max_project_identifier_length: 30
3736

server/config/test.exs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,4 @@ config :logger, :console, level: :error
2323
config :phoenix, :plug_init_mode, :runtime
2424

2525
config :field_hub,
26-
file_directory_root: "test/tmp",
27-
# ~10mb instead of the 1gb default value
28-
file_max_size: 10_000_000
26+
file_directory_root: "test/tmp"

server/lib/field_hub/file_store.ex

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,26 @@ defmodule FieldHub.FileStore do
179179
result
180180
end
181181

182+
@doc """
183+
Open a io_device for a new file.
184+
185+
The io_device can then be used to write chunked/streamed data without having to
186+
load the whole file into memory at once.
187+
188+
__Parameters__
189+
190+
- `uuid` the uuid for the file (will be used as its file_name).
191+
- `project_identifier` the project's name.
192+
- `file_variant` a valid file variant, one of `#{inspect(@valid_file_variants)}`.
193+
194+
Returns `{:ok, io_device}` on success or `{:error, posix}` on failure.
195+
"""
196+
def create_write_io_device(uuid, project_identifier, file_variant) do
197+
directory = get_variant_directory(project_identifier, file_variant)
198+
file_path = "#{directory}/#{uuid}"
199+
File.open(file_path, [:write])
200+
end
201+
182202
@doc """
183203
Mark all file variants for a UUID/file as deleted.
184204

server/lib/field_hub_web/rest/api/file.ex

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ defmodule FieldHubWeb.Rest.Api.Rest.File do
33

44
alias FieldHub.FileStore
55

6-
@max_size Application.compile_env(:field_hub, :file_max_size)
6+
require Logger
77

88
def index(conn, %{"project" => project, "types" => types}) when is_list(types) do
99
parsed_types =
@@ -89,29 +89,47 @@ defmodule FieldHubWeb.Rest.Api.Rest.File do
8989
end
9090

9191
def update(conn, %{"project" => project, "id" => uuid, "type" => type}) when is_binary(type) do
92-
parsed_type = parse_type(type)
92+
with {:parsed_type, parsed_type} when is_atom(parsed_type) <-
93+
{:parsed_type, parse_type(type)},
94+
{:io_opened, {:ok, io_device}} <-
95+
{:io_opened, FileStore.create_write_io_device(uuid, project, parsed_type)},
96+
{:stream, {:ok, %Plug.Conn{} = conn}} <- {:stream, stream_body(conn, io_device)} do
97+
FileStore.clear_cache(project)
98+
99+
send_resp(conn, 201, Jason.encode!(%{info: "File created."}))
100+
else
101+
{:parsed_type, {:error, type}} ->
102+
send_resp(conn, 400, Jason.encode!(%{reason: "Unknown file type: #{type}"}))
103+
104+
{:io_opened, posix} ->
105+
Logger.error(
106+
"Got `#{posix}` while trying to open file `#{uuid}` (#{type}) for project `#{project}`."
107+
)
108+
109+
send_resp(conn, 500, Jason.encode!(%{reason: "Unable to write file."}))
110+
111+
{:stream, {:error, term}} ->
112+
Logger.error(
113+
"Got `#{term}` error while trying to stream request body for `#{uuid}` (#{type}) for project `#{project}`."
114+
)
115+
116+
send_resp(conn, 500, Jason.encode!(%{reason: "Unable to write file."}))
117+
end
118+
end
93119

94-
conn
95-
|> read_body(length: @max_size)
120+
defp stream_body(conn, io_device) do
121+
read_body(conn)
96122
|> case do
97123
{:ok, data, conn} ->
98-
case parsed_type do
99-
{:error, type} ->
100-
send_resp(conn, 400, Jason.encode!(%{reason: "Unknown file type: #{type}"}))
124+
IO.binwrite(io_device, data)
125+
{:ok, conn}
101126

102-
valid ->
103-
FileStore.store(Zarex.sanitize(uuid), Zarex.sanitize(project), valid, data)
104-
send_resp(conn, 201, Jason.encode!(%{info: "File created."}))
105-
end
127+
{:more, data, conn} ->
128+
IO.binwrite(io_device, data)
129+
stream_body(conn, io_device)
106130

107-
{:more, _partial_body, conn} ->
108-
send_resp(
109-
conn,
110-
413,
111-
Jason.encode!(%{
112-
reason: "Payload too large, maximum of #{Sizeable.filesize(@max_size)} bytes allowed."
113-
})
114-
)
131+
error ->
132+
error
115133
end
116134
end
117135

server/mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ defmodule FieldHub.MixProject do
44
def project do
55
[
66
app: :field_hub,
7-
version: "3.3.2",
7+
version: "3.4.0",
88
elixir: "~> 1.18",
99
elixirc_paths: elixirc_paths(Mix.env()),
1010
compilers: Mix.compilers(),

server/test/field_hub_web/controllers/api/file_controller_test.exs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -59,17 +59,17 @@ defmodule FieldHubWeb.Api.Rest.FileTest do
5959
assert conn.status == 400
6060
end
6161

62-
test "PUT /files/:project/:uuid with file exceeding maximum size throws 413", %{conn: conn} do
63-
roughly_20_mb = String.duplicate("0123456789", 2_000_000)
62+
# test "PUT /files/:project/:uuid with file exceeding maximum size throws 413", %{conn: conn} do
63+
# roughly_20_mb = String.duplicate("0123456789", 2_000_000)
6464

65-
conn =
66-
conn
67-
|> put_req_header("authorization", @basic_auth)
68-
|> put_req_header("content-type", "image/png")
69-
|> put("/files/#{@project}/1234?type=original_image", roughly_20_mb)
65+
# conn =
66+
# conn
67+
# |> put_req_header("authorization", @basic_auth)
68+
# |> put_req_header("content-type", "image/png")
69+
# |> put("/files/#{@project}/1234?type=original_image", roughly_20_mb)
7070

71-
assert conn.status == 413
72-
end
71+
# assert conn.status == 413
72+
# end
7373

7474
test "GET /files/:project/:uuid returns 404 for non-existent file", %{conn: conn} do
7575
credentials = Base.encode64("#{@user_name}:#{@user_password}")

0 commit comments

Comments
 (0)