Skip to content

Commit 1c47da0

Browse files
committed
refactor: move file upload and compression to backend
Previously the frontend would: - request presigned PUT/GET URLs, - upload directly to storage, - then mark records uploaded Now the backend: - receives an `Upload` (GraphQL `Upload`) and uploads it to bucket storage itself - computes digests on the backend - generates compressed variants (`.gz` and `.lz4`) and stores them too - exposes direct URLs (not presigned) via `baseFile/gzFile/lz4File` in GraphQL Signed-off-by: ArnelaL <arnela.lisic@secomind.com>
1 parent 31595dc commit 1c47da0

52 files changed

Lines changed: 2297 additions & 1212 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/config/test.exs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,10 @@ config :edgehog,
124124
:astarte_file_transfer_capabilities_module,
125125
Edgehog.Astarte.Device.FileTransferCapabilitiesMock
126126

127+
config :edgehog,
128+
:astarte_file_download_request_module,
129+
Edgehog.Astarte.Device.FileDownloadRequestMock
130+
127131
config :edgehog, :astarte_forwarder_session_module, Edgehog.Astarte.Device.ForwarderSessionMock
128132
config :edgehog, :astarte_geolocation_module, Edgehog.Astarte.Device.GeolocationMock
129133
config :edgehog, :astarte_hardware_info_module, Edgehog.Astarte.Device.HardwareInfoMock
@@ -140,8 +144,10 @@ config :edgehog, :astarte_trigger_data_layer, Edgehog.Astarte.Trigger.MockDataLa
140144
config :edgehog, :astarte_wifi_scan_result_module, Edgehog.Astarte.Device.WiFiScanResultMock
141145
config :edgehog, :base_images_storage_module, Edgehog.BaseImages.StorageMock
142146
config :edgehog, :container_reconciler, Edgehog.Containers.ReconcilerMock
143-
config :edgehog, :files_storage_module, Edgehog.StorageMock
147+
config :edgehog, :files_storage_module, Edgehog.Files.StorageMock
148+
config :edgehog, :files_ephemeral_file_module, Edgehog.Files.EphemeralFileMock
144149
config :edgehog, :os_management_ephemeral_image_module, Edgehog.OSManagement.EphemeralImageMock
150+
config :edgehog, :presigned_urls_storage_module, Edgehog.StorageMock
145151
config :edgehog, :reconciler_module, Edgehog.Tenants.ReconcilerMock
146152

147153
# Enable s3 storage since we're using mocks for it

backend/lib/edgehog/campaigns/campaign_mechanism/file_download/core.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ defimpl Edgehog.Campaigns.CampaignMechanism.Core,
6161
def mark_operation_as_timed_out!(_mechanism, operation_id, tenant_id) do
6262
file_download_request = Files.fetch_file_download_request!(operation_id, tenant: tenant_id)
6363

64-
case Files.set_response(
64+
case Files.set_file_download_response(
6565
file_download_request,
6666
%{status: :failed, response_code: -1, response_message: "Request timed out"},
6767
tenant: tenant_id
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
#
2+
# This file is part of Edgehog.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
#
20+
21+
defmodule Edgehog.Files.Compressor do
22+
@moduledoc false
23+
24+
def compress(%Plug.Upload{} = upload, :gz) do
25+
compressed_path =
26+
Path.join(System.tmp_dir!(), "#{Ash.UUIDv7.generate()}.gz")
27+
28+
input = File.stream!(upload.path, 64_000, [])
29+
output = File.open!(compressed_path, [:write, :binary])
30+
31+
z = :zlib.open()
32+
:ok = :zlib.deflateInit(z, :default, :deflated, 31, 8, :default)
33+
34+
Enum.each(input, fn chunk ->
35+
compressed = :zlib.deflate(z, chunk)
36+
IO.binwrite(output, compressed)
37+
end)
38+
39+
final = :zlib.deflate(z, <<>>, :finish)
40+
IO.binwrite(output, final)
41+
42+
:zlib.close(z)
43+
File.close(output)
44+
45+
{:ok,
46+
%Plug.Upload{
47+
path: compressed_path,
48+
filename: upload.filename <> ".gz",
49+
content_type: "application/gzip"
50+
}}
51+
rescue
52+
error -> {:error, error}
53+
end
54+
55+
def compress(%Plug.Upload{} = upload, :lz4) do
56+
compressed_path =
57+
Path.join(System.tmp_dir!(), "#{Ash.UUIDv7.generate()}.lz4")
58+
59+
upload.path
60+
|> File.read!()
61+
|> NimbleLZ4.compress_frame()
62+
|> then(&File.write!(compressed_path, &1))
63+
64+
{:ok,
65+
%Plug.Upload{
66+
path: compressed_path,
67+
filename: upload.filename <> ".lz4",
68+
content_type: "application/x-lz4"
69+
}}
70+
rescue
71+
error -> {:error, error}
72+
end
73+
end
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#
2+
# This file is part of Edgehog.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
#
20+
21+
defmodule Edgehog.Files.Digest do
22+
@moduledoc false
23+
24+
@sha256_prefix "sha256:"
25+
26+
def file_sha256!(file_path) do
27+
hash =
28+
file_path
29+
|> File.stream!(2048)
30+
|> Enum.reduce(:crypto.hash_init(:sha256), &:crypto.hash_update(&2, &1))
31+
|> :crypto.hash_final()
32+
|> Base.encode16(case: :lower)
33+
34+
@sha256_prefix <> hash
35+
end
36+
end
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
#
2+
# This file is part of Edgehog.
3+
#
4+
# Copyright 2026 SECO Mind Srl
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
#
18+
# SPDX-License-Identifier: Apache-2.0
19+
#
20+
21+
defmodule Edgehog.Files.File.BucketStorage do
22+
@moduledoc """
23+
Implementation of `Edgehog.Files.File.Storage` that uses a bucket storage (e.g., S3, MinIO) to store files.
24+
"""
25+
26+
@behaviour Edgehog.Files.File.Storage
27+
28+
alias Edgehog.Files.File
29+
alias Edgehog.Files.File.Storage
30+
alias Edgehog.Files.Uploaders
31+
32+
@impl Storage
33+
def store(tenant_id, file_name, repository_id, encoding, %Plug.Upload{} = upload) do
34+
scope = %{
35+
tenant_id: tenant_id,
36+
file_name: file_name,
37+
repository_id: repository_id,
38+
encoding: encoding
39+
}
40+
41+
with {:ok, stored_name} <- Uploaders.File.store({upload, scope}) do
42+
file_url = Uploaders.File.url({stored_name, scope})
43+
{:ok, file_url}
44+
end
45+
end
46+
47+
@impl Storage
48+
def delete(%File{} = file, encoding) when encoding in [nil, :gz, :lz4] do
49+
{file_field, encoding_value} =
50+
case encoding do
51+
nil -> {:base_file, nil}
52+
:gz -> {:gz_file, :gz}
53+
:lz4 -> {:lz4_file, :lz4}
54+
end
55+
56+
%File{
57+
tenant_id: tenant_id,
58+
name: file_name,
59+
repository_id: repository_id
60+
} = file
61+
62+
url =
63+
file
64+
|> Map.fetch!(file_field)
65+
|> Map.fetch!(:url)
66+
67+
scope = %{
68+
tenant_id: tenant_id,
69+
file_name: file_name,
70+
repository_id: repository_id,
71+
encoding: encoding_value
72+
}
73+
74+
Uploaders.File.delete({url, scope})
75+
end
76+
end

backend/lib/edgehog/files/file/calculations/get_presigned_url.ex

Lines changed: 0 additions & 60 deletions
This file was deleted.

backend/lib/edgehog/files/file/calculations/put_presigned_url.ex

Lines changed: 0 additions & 60 deletions
This file was deleted.

backend/lib/edgehog/files/file/changes/delete_file.ex renamed to backend/lib/edgehog/files/file/changes/handle_file_deletion.ex

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,23 @@
1818
# SPDX-License-Identifier: Apache-2.0
1919
#
2020

21-
defmodule Edgehog.Files.File.Changes.DeleteFile do
21+
defmodule Edgehog.Files.File.Changes.HandleFileDeletion do
2222
@moduledoc """
23-
Deletes a file from the storage backend that was uploaded via a presigned URL.
23+
Ash change to handle file deletion after a file record is deleted.
2424
"""
25+
2526
use Ash.Resource.Change
2627

28+
alias Edgehog.Files.File.BucketStorage
29+
30+
require Logger
31+
32+
@storage_module Application.compile_env(
33+
:edgehog,
34+
:files_storage_module,
35+
BucketStorage
36+
)
37+
2738
@impl Ash.Resource.Change
2839
def change(%Ash.Changeset{valid?: false} = changeset, _opts, _context), do: changeset
2940

@@ -34,13 +45,9 @@ defmodule Edgehog.Files.File.Changes.DeleteFile do
3445
end
3546

3647
defp delete_old_file({:ok, file} = result) do
37-
tenant_id = file.tenant_id
38-
repository_id = file.repository_id
39-
filename = file.name
40-
41-
file_path = "uploads/tenants/#{tenant_id}/repositories/#{repository_id}/files/#{filename}"
42-
43-
_ = Edgehog.Storage.delete(file_path)
48+
_ = @storage_module.delete(file, nil)
49+
_ = @storage_module.delete(file, :gz)
50+
_ = @storage_module.delete(file, :lz4)
4451

4552
result
4653
end

0 commit comments

Comments
 (0)