diff --git a/.ruby-lsp/main_lockfile_hash b/.ruby-lsp/main_lockfile_hash new file mode 100644 index 00000000..b00b6821 --- /dev/null +++ b/.ruby-lsp/main_lockfile_hash @@ -0,0 +1 @@ +ed88027f79a12643f6491f78ce705b17a2b00948174575c1b18f64692660e7cd \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c9f1351..722447aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Fixed -- Cities visited during a trip are now being calculated correctly. #547 +- Cities visited during a trip are now being calculated correctly. #547 #641 - Points on the map are now show time in user's timezone. #580 +- Date range inputs now handle pre-epoch dates gracefully by clamping to valid PostgreSQL integer range (1970-2038), preventing database errors when users enter dates like year 1000. # [0.36.2] - 2025-12-06 diff --git a/app/controllers/api/v1/countries/visited_cities_controller.rb b/app/controllers/api/v1/countries/visited_cities_controller.rb index 90baf6ce..5efee0d6 100644 --- a/app/controllers/api/v1/countries/visited_cities_controller.rb +++ b/app/controllers/api/v1/countries/visited_cities_controller.rb @@ -1,11 +1,13 @@ # frozen_string_literal: true class Api::V1::Countries::VisitedCitiesController < ApiController + include SafeTimestampParser + before_action :validate_params def index - start_at = DateTime.parse(params[:start_at]).to_i - end_at = DateTime.parse(params[:end_at]).to_i + start_at = safe_timestamp(params[:start_at]) + end_at = safe_timestamp(params[:end_at]) points = current_api_user .points diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index ad5dca57..1595d326 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class Api::V1::PointsController < ApiController + include SafeTimestampParser + before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy] before_action :validate_points_limit, only: %i[create] def index - start_at = params[:start_at]&.to_datetime&.to_i - end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i + start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil + end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i order = params[:order] || 'desc' points = current_api_user diff --git a/app/controllers/concerns/safe_timestamp_parser.rb b/app/controllers/concerns/safe_timestamp_parser.rb new file mode 100644 index 00000000..a7ef568f --- /dev/null +++ b/app/controllers/concerns/safe_timestamp_parser.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module SafeTimestampParser + extend ActiveSupport::Concern + + private + + def safe_timestamp(date_string) + parsed_time = Time.zone.parse(date_string) + min_timestamp = Time.zone.parse('1970-01-01').to_i + max_timestamp = Time.zone.parse('2100-01-01').to_i + + parsed_time.to_i.clamp(min_timestamp, max_timestamp) + rescue ArgumentError + Time.zone.now.to_i + end +end diff --git a/app/controllers/map/leaflet_controller.rb b/app/controllers/map/leaflet_controller.rb index 2c5e2672..660b9615 100644 --- a/app/controllers/map/leaflet_controller.rb +++ b/app/controllers/map/leaflet_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Map::LeafletController < ApplicationController + include SafeTimestampParser + before_action :authenticate_user! layout 'map', only: :index @@ -71,14 +73,14 @@ class Map::LeafletController < ApplicationController end def start_at - return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present? + return safe_timestamp(params[:start_at]) if params[:start_at].present? return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any? Time.zone.today.beginning_of_day.to_i end def end_at - return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present? + return safe_timestamp(params[:end_at]) if params[:end_at].present? return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any? Time.zone.today.end_of_day.to_i diff --git a/app/controllers/map/maplibre_controller.rb b/app/controllers/map/maplibre_controller.rb index 529242d5..b11bc1e8 100644 --- a/app/controllers/map/maplibre_controller.rb +++ b/app/controllers/map/maplibre_controller.rb @@ -1,5 +1,7 @@ module Map class MaplibreController < ApplicationController + include SafeTimestampParser + before_action :authenticate_user! layout 'map' @@ -11,13 +13,13 @@ module Map private def start_at - return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present? + return safe_timestamp(params[:start_at]) if params[:start_at].present? Time.zone.today.beginning_of_day.to_i end def end_at - return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present? + return safe_timestamp(params[:end_at]) if params[:end_at].present? Time.zone.today.end_of_day.to_i end diff --git a/app/controllers/points_controller.rb b/app/controllers/points_controller.rb index 65d99698..87cdd1a4 100644 --- a/app/controllers/points_controller.rb +++ b/app/controllers/points_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class PointsController < ApplicationController + include SafeTimestampParser + before_action :authenticate_user! def index @@ -40,13 +42,13 @@ class PointsController < ApplicationController def start_at return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil? - Time.zone.parse(params[:start_at]).to_i + safe_timestamp(params[:start_at]) end def end_at return Time.zone.today.end_of_day.to_i if params[:end_at].nil? - Time.zone.parse(params[:end_at]).to_i + safe_timestamp(params[:end_at]) end def points diff --git a/lib/timestamps.rb b/lib/timestamps.rb index 2154a3ef..1912de46 100644 --- a/lib/timestamps.rb +++ b/lib/timestamps.rb @@ -1,18 +1,21 @@ # frozen_string_literal: true module Timestamps + MIN_TIMESTAMP = Time.zone.parse('1970-01-01').to_i + MAX_TIMESTAMP = Time.zone.parse('2100-01-01').to_i + def self.parse_timestamp(timestamp) - begin - # if the timestamp is in ISO 8601 format, try to parse it - DateTime.parse(timestamp).to_time.to_i - rescue + parsed = DateTime.parse(timestamp).to_time.to_i + + parsed.clamp(MIN_TIMESTAMP, MAX_TIMESTAMP) + rescue StandardError + result = if timestamp.to_s.length > 10 - # If the timestamp is in milliseconds, convert to seconds timestamp.to_i / 1000 else - # If the timestamp is in seconds, return it without change timestamp.to_i end - end + + result.clamp(MIN_TIMESTAMP, MAX_TIMESTAMP) end end diff --git a/spec/controllers/concerns/safe_timestamp_parser_spec.rb b/spec/controllers/concerns/safe_timestamp_parser_spec.rb new file mode 100644 index 00000000..295ea7c2 --- /dev/null +++ b/spec/controllers/concerns/safe_timestamp_parser_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SafeTimestampParser, type: :controller do + controller(ApplicationController) do + include SafeTimestampParser + + def index + render plain: safe_timestamp(params[:date]).to_s + end + end + + before do + routes.draw { get 'index' => 'anonymous#index' } + end + + describe '#safe_timestamp' do + context 'with valid dates within range' do + it 'returns correct timestamp for 2020-01-01' do + get :index, params: { date: '2020-01-01' } + expected = Time.zone.parse('2020-01-01').to_i + expect(response.body).to eq(expected.to_s) + end + + it 'returns correct timestamp for 1980-06-15' do + get :index, params: { date: '1980-06-15' } + expected = Time.zone.parse('1980-06-15').to_i + expect(response.body).to eq(expected.to_s) + end + end + + context 'with dates before valid range' do + it 'clamps year 1000 to minimum timestamp (1970-01-01)' do + get :index, params: { date: '1000-01-30' } + min_timestamp = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(min_timestamp.to_s) + end + + it 'clamps year 1900 to minimum timestamp (1970-01-01)' do + get :index, params: { date: '1900-12-25' } + min_timestamp = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(min_timestamp.to_s) + end + + it 'clamps year 1969 to minimum timestamp (1970-01-01)' do + get :index, params: { date: '1969-07-20' } + min_timestamp = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(min_timestamp.to_s) + end + end + + context 'with dates after valid range' do + it 'clamps year 2150 to maximum timestamp (2100-01-01)' do + get :index, params: { date: '2150-01-01' } + max_timestamp = Time.zone.parse('2100-01-01').to_i + expect(response.body).to eq(max_timestamp.to_s) + end + + it 'clamps year 3000 to maximum timestamp (2100-01-01)' do + get :index, params: { date: '3000-12-31' } + max_timestamp = Time.zone.parse('2100-01-01').to_i + expect(response.body).to eq(max_timestamp.to_s) + end + end + + context 'with invalid date strings' do + it 'returns current time for unparseable date' do + freeze_time do + get :index, params: { date: 'not-a-date' } + expected = Time.zone.now.to_i + expect(response.body).to eq(expected.to_s) + end + end + + it 'returns current time for empty string' do + freeze_time do + get :index, params: { date: '' } + expected = Time.zone.now.to_i + expect(response.body).to eq(expected.to_s) + end + end + end + + context 'edge cases' do + it 'handles Unix epoch exactly (1970-01-01)' do + get :index, params: { date: '1970-01-01' } + expected = Time.zone.parse('1970-01-01').to_i + expect(response.body).to eq(expected.to_s) + end + + it 'handles maximum date exactly (2100-01-01)' do + get :index, params: { date: '2100-01-01' } + expected = Time.zone.parse('2100-01-01').to_i + expect(response.body).to eq(expected.to_s) + end + end + end +end