Sanitize input

This commit is contained in:
Eugene Burmakin 2025-09-04 00:12:33 +02:00
parent 5c6b76dd63
commit e3b2fcd415
3 changed files with 39 additions and 59 deletions

View file

@ -130,7 +130,7 @@ npx playwright test # E2E tests
- **Framework**: rSwag (Swagger/OpenAPI)
- **Location**: `/api-docs` endpoint
- **Authentication**: JWT-based for API access
- **Authentication**: API key (Bearer) for API access
## Database Schema

View file

@ -83,12 +83,6 @@ class LocationSearch {
class="flex-1 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
id="location-search-input"
/>
<button
id="location-search-submit"
class="px-3 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 text-sm"
>
Search
</button>
<button
id="location-search-close"
class="px-2 py-2 text-gray-400 hover:text-gray-600"
@ -116,7 +110,6 @@ class LocationSearch {
// Store references
this.searchBar = searchBar;
this.searchInput = document.getElementById('location-search-input');
this.searchButton = document.getElementById('location-search-submit');
this.closeButton = document.getElementById('location-search-close');
this.suggestionsContainer = document.getElementById('location-search-suggestions');
this.suggestionsPanel = document.getElementById('location-search-suggestions-panel');
@ -200,18 +193,11 @@ class LocationSearch {
this.hideSearchBar();
});
// Search on button click
this.searchButton.addEventListener('click', () => {
this.performSearch();
});
// Search on Enter key
this.searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
if (this.suggestionsVisible && this.currentSuggestionIndex >= 0) {
this.selectSuggestion(this.currentSuggestionIndex);
} else {
this.performSearch();
}
}
});
@ -284,35 +270,6 @@ class LocationSearch {
});
}
async performSearch() {
const query = this.searchInput.value.trim();
if (!query) return;
this.currentSearchQuery = query;
this.showLoading();
try {
const response = await fetch(`/api/v1/locations?q=${encodeURIComponent(query)}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`Search failed: ${response.status} ${response.statusText}`);
}
const data = await response.json();
this.displaySearchResults(data);
} catch (error) {
console.error('Location search error:', error);
this.showError('Failed to search locations. Please try again.');
}
}
showLoading() {
// Hide other panels and show results with loading
this.suggestionsPanel.classList.add('hidden');

View file

@ -7,9 +7,13 @@ module LocationSearch
end
def find_points_near(user, latitude, longitude, radius_meters, date_options = {})
points_query = build_spatial_query(user, latitude, longitude, radius_meters, date_options)
query_sql, bind_values = build_spatial_query(user, latitude, longitude, radius_meters, date_options)
ActiveRecord::Base.connection.exec_query(points_query)
# Use sanitize_sql_array to safely execute the parameterized query
safe_query = ActiveRecord::Base.sanitize_sql_array([query_sql] + bind_values)
ActiveRecord::Base.connection.exec_query(safe_query)
.map { |row| format_point_result(row) }
.sort_by { |point| point[:timestamp] }
.reverse # Most recent first
@ -18,9 +22,14 @@ module LocationSearch
private
def build_spatial_query(user, latitude, longitude, radius_meters, date_options = {})
date_filter = build_date_filter(date_options)
date_filter_sql, date_bind_values = build_date_filter(date_options)
<<~SQL
# Build parameterized query with proper SRID using ? placeholders
# Use a CTE to avoid duplicating the point calculation
base_sql = <<~SQL
WITH search_point AS (
SELECT ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography AS geom
)
SELECT
p.id,
p.timestamp,
@ -30,35 +39,49 @@ module LocationSearch
p.country,
p.altitude,
p.accuracy,
ST_Distance(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography) as distance_meters,
ST_Distance(p.lonlat, search_point.geom) as distance_meters,
TO_TIMESTAMP(p.timestamp) as recorded_at
FROM points p
WHERE p.user_id = #{user.id}
AND ST_DWithin(p.lonlat, ST_Point(#{longitude}, #{latitude})::geography, #{radius_meters})
#{date_filter}
ORDER BY p.timestamp DESC;
FROM points p, search_point
WHERE p.user_id = ?
AND ST_DWithin(p.lonlat, search_point.geom, ?)
#{date_filter_sql}
ORDER BY p.timestamp DESC
SQL
# Combine bind values: longitude, latitude, user_id, radius, then date filters
bind_values = [
longitude.to_f, # longitude for search point
latitude.to_f, # latitude for search point
user.id, # user_id
radius_meters.to_f # radius_meters
]
bind_values.concat(date_bind_values)
[base_sql, bind_values]
end
def build_date_filter(date_options)
return '' unless date_options[:date_from] || date_options[:date_to]
return ['', []] unless date_options[:date_from] || date_options[:date_to]
filters = []
bind_values = []
if date_options[:date_from]
timestamp_from = date_options[:date_from].to_time.to_i
filters << "p.timestamp >= #{timestamp_from}"
filters << "p.timestamp >= ?"
bind_values << timestamp_from
end
if date_options[:date_to]
# Add one day to include the entire end date
timestamp_to = (date_options[:date_to] + 1.day).to_time.to_i
filters << "p.timestamp < #{timestamp_to}"
filters << "p.timestamp < ?"
bind_values << timestamp_to
end
return '' if filters.empty?
return ['', []] if filters.empty?
"AND #{filters.join(' AND ')}"
["AND #{filters.join(' AND ')}", bind_values]
end
def format_point_result(row)