Limit timestamps to valid range to prevent database errors when users enter pre-epoch dates.

This commit is contained in:
Eugene Burmakin 2025-12-08 22:09:09 +01:00
parent 1970d78621
commit 336c6667e6
10 changed files with 149 additions and 18 deletions

View file

@ -0,0 +1 @@
ed88027f79a12643f6491f78ce705b17a2b00948174575c1b18f64692660e7cd

View file

@ -12,8 +12,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Fixed ## 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 - 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 # [0.36.2] - 2025-12-06

View file

@ -1,11 +1,13 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::Countries::VisitedCitiesController < ApiController class Api::V1::Countries::VisitedCitiesController < ApiController
include SafeTimestampParser
before_action :validate_params before_action :validate_params
def index def index
start_at = DateTime.parse(params[:start_at]).to_i start_at = safe_timestamp(params[:start_at])
end_at = DateTime.parse(params[:end_at]).to_i end_at = safe_timestamp(params[:end_at])
points = current_api_user points = current_api_user
.points .points

View file

@ -1,12 +1,14 @@
# frozen_string_literal: true # frozen_string_literal: true
class Api::V1::PointsController < ApiController class Api::V1::PointsController < ApiController
include SafeTimestampParser
before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy] before_action :authenticate_active_api_user!, only: %i[create update destroy bulk_destroy]
before_action :validate_points_limit, only: %i[create] before_action :validate_points_limit, only: %i[create]
def index def index
start_at = params[:start_at]&.to_datetime&.to_i start_at = params[:start_at].present? ? safe_timestamp(params[:start_at]) : nil
end_at = params[:end_at]&.to_datetime&.to_i || Time.zone.now.to_i end_at = params[:end_at].present? ? safe_timestamp(params[:end_at]) : Time.zone.now.to_i
order = params[:order] || 'desc' order = params[:order] || 'desc'
points = current_api_user points = current_api_user

View file

@ -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

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class Map::LeafletController < ApplicationController class Map::LeafletController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user! before_action :authenticate_user!
layout 'map', only: :index layout 'map', only: :index
@ -71,14 +73,14 @@ class Map::LeafletController < ApplicationController
end end
def start_at 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? return Time.zone.at(points.last.timestamp).beginning_of_day.to_i if points.any?
Time.zone.today.beginning_of_day.to_i Time.zone.today.beginning_of_day.to_i
end end
def end_at 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? return Time.zone.at(points.last.timestamp).end_of_day.to_i if points.any?
Time.zone.today.end_of_day.to_i Time.zone.today.end_of_day.to_i

View file

@ -1,5 +1,7 @@
module Map module Map
class MaplibreController < ApplicationController class MaplibreController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user! before_action :authenticate_user!
layout 'map' layout 'map'
@ -11,13 +13,13 @@ module Map
private private
def start_at 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 Time.zone.today.beginning_of_day.to_i
end end
def end_at 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 Time.zone.today.end_of_day.to_i
end end

View file

@ -1,6 +1,8 @@
# frozen_string_literal: true # frozen_string_literal: true
class PointsController < ApplicationController class PointsController < ApplicationController
include SafeTimestampParser
before_action :authenticate_user! before_action :authenticate_user!
def index def index
@ -40,13 +42,13 @@ class PointsController < ApplicationController
def start_at def start_at
return 1.month.ago.beginning_of_day.to_i if params[:start_at].nil? 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 end
def end_at def end_at
return Time.zone.today.end_of_day.to_i if params[:end_at].nil? 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 end
def points def points

View file

@ -1,18 +1,21 @@
# frozen_string_literal: true # frozen_string_literal: true
module Timestamps 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) def self.parse_timestamp(timestamp)
begin parsed = DateTime.parse(timestamp).to_time.to_i
# if the timestamp is in ISO 8601 format, try to parse it
DateTime.parse(timestamp).to_time.to_i parsed.clamp(MIN_TIMESTAMP, MAX_TIMESTAMP)
rescue rescue StandardError
result =
if timestamp.to_s.length > 10 if timestamp.to_s.length > 10
# If the timestamp is in milliseconds, convert to seconds
timestamp.to_i / 1000 timestamp.to_i / 1000
else else
# If the timestamp is in seconds, return it without change
timestamp.to_i timestamp.to_i
end end
end
result.clamp(MIN_TIMESTAMP, MAX_TIMESTAMP)
end end
end end

View file

@ -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