Skip to content

Commit 5aa9416

Browse files
committed
Add file download endpoint for beacon content sync
Beacons receive a manifest listing files they need, then download each file individually via this endpoint. The controller validates that the requesting beacon has access through its assigned topics and supports resumable downloads via Range headers for reliability on unstable connections.
1 parent 396d860 commit 5aa9416

3 files changed

Lines changed: 155 additions & 0 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
module Api
2+
module V1
3+
module Beacons
4+
class FilesController < Beacons::BaseController
5+
include ActiveStorage::Streaming
6+
7+
def show
8+
blob = Current.beacon.accessible_blobs.find(params[:id])
9+
10+
if request.headers["Range"].present?
11+
send_blob_byte_range_data(blob, request.headers["Range"])
12+
else
13+
send_blob_stream(blob, disposition: :attachment)
14+
end
15+
rescue ActiveRecord::RecordNotFound
16+
head :not_found
17+
end
18+
end
19+
end
20+
end
21+
end

config/routes.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@
4141
resources :tags, only: %i[index show]
4242

4343
namespace :beacons do
44+
resource :manifest, only: :show
45+
resources :files, only: :show
4446
resource :status, only: :show
4547
end
4648
end
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
require "rails_helper"
2+
3+
RSpec.describe "Beacons Files API", type: :request do
4+
let(:language) { create(:language) }
5+
let(:region) { create(:region) }
6+
let(:provider) { create(:provider) }
7+
8+
let(:beacon) do
9+
b, @raw_key = create_beacon_with_key(language: language, region: region)
10+
b.providers << provider
11+
b
12+
end
13+
14+
let(:raw_key) { beacon; @raw_key }
15+
16+
let(:topic) do
17+
create(:topic, :with_documents, provider: provider, language: language).tap do |t|
18+
beacon.topics << t
19+
end
20+
end
21+
22+
let(:blob) { topic.documents.first.blob }
23+
24+
describe "GET /api/v1/beacons/files/:id" do
25+
context "with valid authentication and access" do
26+
it "returns the file with correct headers" do
27+
get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key)
28+
29+
expect(response).to have_http_status(:ok)
30+
expect(response.headers["Content-Type"]).to eq(blob.content_type)
31+
expect(response.headers["Content-Disposition"]).to include("attachment")
32+
expect(response.headers["Content-Length"]).to eq(blob.byte_size.to_s)
33+
end
34+
35+
it "streams the file content" do
36+
get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key)
37+
38+
expect(response.body).not_to be_empty
39+
expect(response.body.bytesize).to eq(blob.byte_size)
40+
end
41+
42+
it "includes the filename in Content-Disposition" do
43+
get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key)
44+
45+
expect(response.headers["Content-Disposition"]).to include(blob.filename.to_s)
46+
end
47+
end
48+
49+
context "with Range header for resumable downloads" do
50+
it "returns 206 Partial Content" do
51+
get "/api/v1/beacons/files/#{blob.id}",
52+
headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-1023")
53+
54+
expect(response).to have_http_status(:partial_content)
55+
end
56+
57+
it "includes Content-Range header" do
58+
get "/api/v1/beacons/files/#{blob.id}",
59+
headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-1023")
60+
61+
expect(response.headers["Content-Range"]).to be_present
62+
expect(response.headers["Content-Range"]).to match(/bytes 0-1023\/\d+/)
63+
end
64+
65+
it "returns only the requested byte range" do
66+
get "/api/v1/beacons/files/#{blob.id}",
67+
headers: beacon_auth_headers(raw_key).merge("Range" => "bytes=0-99")
68+
69+
expect(response.body.bytesize).to be <= 100
70+
end
71+
end
72+
73+
context "when file does not exist" do
74+
it "returns 404" do
75+
get "/api/v1/beacons/files/99999", headers: beacon_auth_headers(raw_key)
76+
77+
expect(response).to have_http_status(:not_found)
78+
end
79+
end
80+
81+
context "when beacon does not have access to the file" do
82+
let(:other_beacon) do
83+
b, @other_raw_key = create_beacon_with_key(language: language, region: region)
84+
b.providers << provider
85+
b
86+
end
87+
88+
let(:other_raw_key) { other_beacon; @other_raw_key }
89+
90+
let(:other_topic) do
91+
create(:topic, :with_documents, provider: provider, language: language).tap do |t|
92+
other_beacon.topics << t
93+
end
94+
end
95+
96+
let(:other_blob) { other_topic.documents.first.blob }
97+
98+
it "returns 404 when requesting another beacon's file" do
99+
get "/api/v1/beacons/files/#{other_blob.id}", headers: beacon_auth_headers(raw_key)
100+
101+
expect(response).to have_http_status(:not_found)
102+
end
103+
end
104+
105+
context "without authentication" do
106+
it "returns 401" do
107+
get "/api/v1/beacons/files/#{blob.id}"
108+
109+
expect(response).to have_http_status(:unauthorized)
110+
end
111+
end
112+
113+
context "with invalid authentication" do
114+
it "returns 401" do
115+
get "/api/v1/beacons/files/#{blob.id}",
116+
headers: beacon_auth_headers("invalid-key")
117+
118+
expect(response).to have_http_status(:unauthorized)
119+
end
120+
end
121+
122+
context "with revoked beacon" do
123+
before { beacon.revoke! }
124+
125+
it "returns 401" do
126+
get "/api/v1/beacons/files/#{blob.id}", headers: beacon_auth_headers(raw_key)
127+
128+
expect(response).to have_http_status(:unauthorized)
129+
end
130+
end
131+
end
132+
end

0 commit comments

Comments
 (0)