Skip to content

Commit 121b2d8

Browse files
committed
Merge branch 'feature/track-file-downloads' into staging
2 parents aee5226 + 19c4926 commit 121b2d8

File tree

9 files changed

+267
-48
lines changed

9 files changed

+267
-48
lines changed

app/controllers/concerns/secure_send_file.rb

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

app/controllers/private_uploads_controller.rb

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
# frozen_string_literal: true
22

33
class PrivateUploadsController < ApplicationController
4-
include SecureSendFile
5-
64
before_action :authenticate_user!
75

86
rescue_from ActionController::MissingFile, with: :raise_not_found_exception
97

108
def download
11-
filepath = "#{params[:rest]}.#{params[:format]}"
12-
secure_send_file allowed_filepath, filepath, disposition: :inline
9+
sanitize_filepath
10+
send_file @sanitized_filepath, disposition: :inline
1311
end
1412

1513
private
1614

15+
def sanitize_filepath
16+
filepath = "#{params[:rest]}.#{params[:format]}"
17+
allowed_path = File.realpath(allowed_directory)
18+
full_path = File.realpath(File.join(allowed_path, filepath))
19+
20+
raise_not_found_exception unless full_path.start_with?(allowed_path + File::SEPARATOR)
21+
22+
@sanitized_filepath = full_path
23+
rescue Errno::ENOENT
24+
raise_not_found_exception
25+
end
26+
1727
def authenticate_user!
1828
if current_user.present?
1929
super

app/controllers/uploads_controller.rb

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
# frozen_string_literal: true
22

33
class UploadsController < ApplicationController
4-
include SecureSendFile
5-
64
rescue_from ActionController::MissingFile, with: :raise_not_found_exception
75

8-
ALLOWED_MODELS_OVERRIDES = {
6+
MODELS_OVERRIDES = {
97
"operator_document_file" => "document_file",
108
"documents" => "uploaded_document"
119
}.freeze
@@ -21,28 +19,39 @@ class UploadsController < ApplicationController
2119
].freeze
2220

2321
def download
22+
sanitize_filepath
2423
parse_upload_path
2524
track_download if trackable_request?
26-
secure_send_file allowed_directory, @filepath, disposition: :inline
25+
send_file @sanitized_filepath, disposition: :inline
2726
end
2827

2928
private
3029

30+
def sanitize_filepath
31+
filepath = "#{params[:rest]}.#{params[:format]}"
32+
allowed_path = File.realpath(allowed_directory)
33+
full_path = File.realpath(File.join(allowed_path, filepath))
34+
35+
raise_not_found_exception unless full_path.start_with?(allowed_path + File::SEPARATOR)
36+
37+
@sanitized_filepath = full_path
38+
@relative_filepath = full_path.gsub(allowed_path + File::SEPARATOR, "")
39+
rescue Errno::ENOENT
40+
raise_not_found_exception
41+
end
42+
3143
def parse_upload_path
32-
path_parts = "#{params[:rest]}.#{params[:format]}".split("/")
33-
@filepath = "#{params[:rest]}.#{params[:format]}"
44+
path_parts = @relative_filepath.split("/")
3445

3546
raise_not_found_exception if path_parts.length < 4
3647

3748
model_key = path_parts[0].downcase
38-
@model_name = ALLOWED_MODELS_OVERRIDES[model_key] || model_key
49+
@model_name = MODELS_OVERRIDES[model_key] || model_key
3950
@filename = path_parts[3..].join("/")
40-
41-
raise_not_known_model unless allowed_models.include?(@model_name) # extra security check
4251
end
4352

4453
def track_download
45-
TrackDownloadJob.perform_later(request.url, @filename, @model_name)
54+
TrackFileDownloadJob.perform_later(request.url, @filename, @model_name)
4655
end
4756

4857
def trackable_request?
@@ -71,22 +80,11 @@ def admin_panel_request?
7180
admin_paths.any? { |path| referer.include?(path) }
7281
end
7382

74-
def allowed_models
75-
Dir.entries(Rails.root.join("uploads"))
76-
.select { |entry| File.directory?(Rails.root.join("uploads", entry)) }
77-
.reject { |entry| entry.start_with?(".") || entry == "tmp" }
78-
.map { |entry| ALLOWED_MODELS_OVERRIDES[entry.downcase] || entry }
79-
end
80-
8183
def allowed_directory
8284
Rails.env.test? ? File.join(Rails.root, "tmp", "uploads") : File.join(Rails.root, "uploads")
8385
end
8486

8587
def raise_not_found_exception
8688
raise ActionController::RoutingError, "Not Found"
8789
end
88-
89-
def raise_not_known_model
90-
raise ActionController::RoutingError, "Not Known Model"
91-
end
9290
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
class TrackDownloadJob < ApplicationJob
1+
class TrackFileDownloadJob < ApplicationJob
22
queue_as :default
33

44
def perform(file_url, file_name, model_name)

spec/integration/uploads_spec.rb

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe UploadsController, type: :request do
6+
before(:all) do
7+
@etc_dir = Rails.root.join("tmp", "etc")
8+
FileUtils.mkdir_p(@etc_dir)
9+
10+
@observation_report = create(:observation_report)
11+
@document_file = create(:document_file) # operator_document_file
12+
@donor = create(:donor) # donor logo
13+
14+
File.write(@etc_dir.join("passwd.txt"), "private")
15+
end
16+
17+
before do
18+
allow(TrackFileDownloadJob).to receive(:perform_later)
19+
end
20+
21+
describe "GET /uploads/*path" do
22+
context "successful file downloads" do
23+
it "downloads existing files" do
24+
get @document_file.attachment.url
25+
26+
expect(response).to have_http_status(:ok)
27+
expect(response.headers["Content-Disposition"]).to include("inline")
28+
end
29+
end
30+
31+
context "file not found scenarios" do
32+
it "returns 404 for non-existent files" do
33+
get "/uploads/operator_document_file/123/456/nonexistent.pdf"
34+
35+
expect(response).to have_http_status(:not_found)
36+
end
37+
38+
it "returns 404 for non-existent directories" do
39+
get "/uploads/operator_document_file/999/888/test.pdf"
40+
41+
expect(response).to have_http_status(:not_found)
42+
end
43+
end
44+
45+
context "invalid path handling" do
46+
it "returns 404 for insufficient path segments" do
47+
get "/uploads/operator_document_file/123.pdf"
48+
49+
expect(response).to have_http_status(:not_found)
50+
end
51+
52+
it "returns 404 for unknown models" do
53+
get "/uploads/unknown_model/123/456/test.pdf"
54+
55+
expect(response).to have_http_status(:not_found)
56+
end
57+
58+
it "returns 404 for empty paths" do
59+
get "/uploads/.pdf"
60+
61+
expect(response).to have_http_status(:not_found)
62+
end
63+
end
64+
65+
context "download tracking" do
66+
it "tracks downloads for trackable models with regular browsers" do
67+
get @document_file.attachment.url, headers: {
68+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
69+
}
70+
71+
expect(response).to have_http_status(:ok)
72+
expect(TrackFileDownloadJob).to have_received(:perform_later).with(
73+
request.url, @document_file.attachment.filename, "document_file"
74+
)
75+
76+
get @observation_report.attachment.url, headers: {
77+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
78+
}
79+
80+
expect(response).to have_http_status(:ok)
81+
expect(TrackFileDownloadJob).to have_received(:perform_later).with(
82+
request.url, @observation_report.attachment.filename, "observation_report"
83+
)
84+
end
85+
86+
it "does not track bot requests" do
87+
get @document_file.attachment.url, headers: {
88+
"User-Agent" => "Googlebot/2.1"
89+
}
90+
91+
expect(response).to have_http_status(:ok)
92+
expect(TrackFileDownloadJob).not_to have_received(:perform_later)
93+
end
94+
95+
it "does not track admin panel requests" do
96+
admin_referers = [
97+
"https://example.com/admin",
98+
"https://example.com/admin/documents",
99+
"https://example.com/observations-tool",
100+
"https://example.com/observations-tool/reports"
101+
]
102+
103+
admin_referers.each do |referer|
104+
get @document_file.attachment.url, headers: {
105+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
106+
"Referer" => referer
107+
}
108+
109+
expect(response).to have_http_status(:ok)
110+
expect(TrackFileDownloadJob).not_to have_received(:perform_later)
111+
end
112+
end
113+
114+
it "does not track for not trackable model" do
115+
get @donor.logo.url, headers: {
116+
"User-Agent" => "Mozilla/5.0 (Chrome/91.0) Safari/537.36"
117+
}
118+
119+
expect(response).to have_http_status(:ok)
120+
expect(TrackFileDownloadJob).not_to have_received(:perform_later)
121+
end
122+
end
123+
124+
context "edge cases and error handling" do
125+
it "handles requests without user agent" do
126+
get @observation_report.attachment.url
127+
128+
expect(response).to have_http_status(:ok)
129+
expect(TrackFileDownloadJob).to have_received(:perform_later)
130+
end
131+
132+
it "handles requests without referer" do
133+
get @document_file.attachment.url, headers: {
134+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
135+
}
136+
137+
expect(response).to have_http_status(:ok)
138+
expect(TrackFileDownloadJob).to have_received(:perform_later)
139+
end
140+
141+
it "prevents directory traversal" do
142+
get "/uploads/operator_document_file/../../etc/passwd.txt"
143+
144+
expect(response).to have_http_status(:not_found)
145+
end
146+
end
147+
end
148+
end
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
require "rails_helper"
2+
3+
RSpec.describe TrackFileDownloadJob, type: :job do
4+
let(:file_url) { "https://example.com/files/document.pdf" }
5+
let(:file_name) { "document.pdf" }
6+
let(:model_name) { "User" }
7+
let(:measurement_id) { "G-XXXXXXXXXX" }
8+
let(:api_secret) { "test_api_secret" }
9+
10+
before do
11+
ENV["GA4_MEASUREMENT_ID"] = measurement_id
12+
ENV["GA4_API_SECRET"] = api_secret
13+
end
14+
15+
after do
16+
ENV.delete("GA4_MEASUREMENT_ID")
17+
ENV.delete("GA4_API_SECRET")
18+
end
19+
20+
describe "#perform" do
21+
context "when environment variables are present" do
22+
let(:expected_payload) do
23+
{
24+
events: [{
25+
name: "file_download",
26+
params: {
27+
file_name: file_name,
28+
file_extension: "pdf",
29+
file_url: file_url,
30+
link_url: file_url,
31+
model_name: model_name
32+
}
33+
}]
34+
}
35+
end
36+
37+
let(:expected_params) do
38+
{
39+
measurement_id: measurement_id,
40+
api_secret: api_secret
41+
}
42+
end
43+
44+
it "sends a GA4 event with correct payload" do
45+
expect(HTTP).to receive(:post).with(
46+
"https://www.google-analytics.com/mp/collect",
47+
params: expected_params,
48+
json: expected_payload
49+
)
50+
51+
described_class.perform_now(file_url, file_name, model_name)
52+
end
53+
end
54+
55+
context "when measurement_id is blank" do
56+
before do
57+
ENV["GA4_MEASUREMENT_ID"] = ""
58+
end
59+
60+
it "does not send GA4 event" do
61+
expect(HTTP).not_to receive(:post)
62+
described_class.perform_now(file_url, file_name, model_name)
63+
end
64+
end
65+
66+
context "when api_secret is blank" do
67+
before do
68+
ENV["GA4_API_SECRET"] = ""
69+
end
70+
71+
it "does not send GA4 event" do
72+
expect(HTTP).not_to receive(:post)
73+
described_class.perform_now(file_url, file_name, model_name)
74+
end
75+
end
76+
end
77+
end

0 commit comments

Comments
 (0)