diff --git a/AGENTS.md b/AGENTS.md index 1d00522..68f2f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,3 +25,7 @@ - `ApplicationController` rejects non-modern browsers with `allow_browser versions: :modern` and uses `stale_when_importmap_changes`. - Solid Queue jobs can run with `bin/jobs`; Kamal production config currently runs Solid Queue inside Puma via `SOLID_QUEUE_IN_PUMA: true`. - Production Docker is intended for Kamal/production, not local development; the entrypoint runs `bin/rails db:prepare` before starting the server. + +## Instructions + +If you are implimenting a phase eg phaseXXX ensure that while you are modifying files you write a file in teh `executions/` folder matching using `executionXXX.md` for the filename. This is all replacing the XXX with the phase number you are on. diff --git a/DESIGN.md b/DESIGN.md index 6f713ea..1e50f62 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -169,3 +169,4 @@ issues. For example, I already had to fix an error with the `csrf_meta_tags` in the erb templates. The error was nothing to do with the code but with the JSON gem which Rails requires to be less than version 3 and we had 3.0.2 installed. This caused the signatures to be mis-matched and made things wrong. + diff --git a/Gemfile b/Gemfile index 26b1593..c496219 100644 --- a/Gemfile +++ b/Gemfile @@ -70,4 +70,4 @@ group :development do end # Trying to fix CSRF errors: -gem 'json', '< 3' +gem "json", "< 3" diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..3809ba8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,7 +1,59 @@ class ApplicationController < ActionController::Base + AUTH_TOKEN_COOKIE = :auth_token + # Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has. allow_browser versions: :modern # Changes to the importmap will invalidate the etag for HTML responses stale_when_importmap_changes + + before_action :rotate_access_key_if_needed + before_action :load_current_user + before_action :require_identified_user + + helper_method :current_user, :identified? + + private + + def rotate_access_key_if_needed + AccessKey.rotate_if_needed! + end + + def load_current_user + Current.user_session = UserSession.find_by_token(cookies.encrypted[AUTH_TOKEN_COOKIE]) + Current.user = Current.user_session&.user + Current.user_session&.touch(:last_used_at) + end + + def require_identified_user + return if identified? + + redirect_to identify_path, alert: "Identify yourself before using Car Tracker." + end + + def current_user + Current.user + end + + def identified? + current_user.present? + end + + def sign_in(user) + user_session, raw_token = UserSession.create_for!(user) + Current.user_session = user_session + Current.user = user + cookies.encrypted[AUTH_TOKEN_COOKIE] = { + value: raw_token, + httponly: true, + same_site: :lax + } + end + + def sign_out + Current.user_session&.destroy! + Current.user_session = nil + Current.user = nil + cookies.delete(AUTH_TOKEN_COOKIE) + end end diff --git a/app/controllers/fuel_entries_controller.rb b/app/controllers/fuel_entries_controller.rb new file mode 100644 index 0000000..db6b5f5 --- /dev/null +++ b/app/controllers/fuel_entries_controller.rb @@ -0,0 +1,46 @@ +class FuelEntriesController < ApplicationController + def create + @vehicle = Vehicle.find(params[:vehicle_id]) + @fuel_entry = @vehicle.fuel_entries.build(fuel_entry_params) + @fuel_entry.updated_by_user = Current.user + + if save_fuel_entry + redirect_to @vehicle, notice: "Fuel entry recorded." + else + prepare_vehicle_show + render "vehicles/show", status: :unprocessable_entity + end + end + + private + + def fuel_entry_params + params.require(:fuel_entry).permit(:odometer, :gallons_pumped, :price_paid, :date) + end + + def save_fuel_entry + saved = false + + FuelEntry.transaction do + if @fuel_entry.save + update_vehicle_odometer(@fuel_entry.odometer) + saved = true + end + end + + saved + end + + def update_vehicle_odometer(odometer) + return unless odometer > @vehicle.current_odometer + + @vehicle.update!(current_odometer: odometer) + end + + def prepare_vehicle_show + @maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type) + @fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) + @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) + @maintenance_entry = @vehicle.maintenance_entries.build(date: Date.current) + end +end diff --git a/app/controllers/maintenance_entries_controller.rb b/app/controllers/maintenance_entries_controller.rb new file mode 100644 index 0000000..be23e21 --- /dev/null +++ b/app/controllers/maintenance_entries_controller.rb @@ -0,0 +1,61 @@ +class MaintenanceEntriesController < ApplicationController + ODOMETER_WARNING_THRESHOLD = 500 + + def create + @vehicle = Vehicle.find(params[:vehicle_id]) + original_odometer = @vehicle.current_odometer + @maintenance_entry = @vehicle.maintenance_entries.build(maintenance_entry_params) + @maintenance_entry.updated_by_user = Current.user + + if save_maintenance_entry + set_old_odometer_warning(original_odometer) + redirect_to @vehicle, notice: "Maintenance entry recorded." + else + prepare_vehicle_show + render "vehicles/show", status: :unprocessable_entity + end + end + + private + + def maintenance_entry_params + params.require(:maintenance_entry).permit(:name, :notes, :odometer, :cost, :date) + end + + def save_maintenance_entry + saved = false + + MaintenanceEntry.transaction do + if @maintenance_entry.save + update_vehicle_odometer(@maintenance_entry.odometer) + saved = true + end + end + + saved + end + + def update_vehicle_odometer(odometer) + return unless odometer > @vehicle.current_odometer + + @vehicle.update!(current_odometer: odometer) + end + + def set_old_odometer_warning(original_odometer) + return unless @maintenance_entry.odometer < original_odometer - ODOMETER_WARNING_THRESHOLD + + message = "Maintenance odometer is more than 500 miles behind the current vehicle odometer." + flash[:warning] = message + Rails.logger.warn( + "#{message} vehicle_id=#{@vehicle.id} maintenance_entry_id=#{@maintenance_entry.id} " \ + "entry_odometer=#{@maintenance_entry.odometer} vehicle_odometer=#{original_odometer}" + ) + end + + def prepare_vehicle_show + @maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type) + @fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) + @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) + @fuel_entry = @vehicle.fuel_entries.build(date: Date.current) + end +end diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..e1205d7 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -0,0 +1,52 @@ +class SessionsController < ApplicationController + skip_before_action :require_identified_user, only: %i[new create destroy] + + def new + end + + def create + access_key = AccessKey.current + + unless access_key.matches?(session_params[:key]) + Rails.logger.warn("Invalid access key identification attempt for email=#{session_params[:email]}") + flash.now[:alert] = "The access key is not valid." + return render :new, status: :unprocessable_entity + end + + user = find_or_build_user + return render :new, status: :unprocessable_entity unless user + + sign_in(user) + redirect_to vehicles_path, notice: "You have been identified." + end + + def destroy + sign_out + redirect_to identify_path, notice: "You have been logged out." + end + + private + + def session_params + params.permit(:name, :email, :key) + end + + def find_or_build_user + email = session_params[:email].to_s.strip.downcase + name = session_params[:name].to_s.strip + user = User.find_by(email: email) + + if user + return user if user.name == name + + flash.now[:alert] = "The name does not match the existing user for that email." + return nil + end + + user = User.new(name: name, email: email) + return user if user.save + + flash.now[:alert] = user.errors.full_messages.to_sentence + nil + end +end diff --git a/app/controllers/vehicles_controller.rb b/app/controllers/vehicles_controller.rb new file mode 100644 index 0000000..ee2c705 --- /dev/null +++ b/app/controllers/vehicles_controller.rb @@ -0,0 +1,81 @@ +class VehiclesController < ApplicationController + before_action :set_vehicle, only: %i[show edit update destroy] + + def index + @status = params[:status] + @vehicles = vehicles_for_status(@status).order(:make, :model, :year) + end + + def show + prepare_show + end + + def new + @vehicle = Vehicle.new + end + + def create + @vehicle = Vehicle.new(vehicle_params) + + if @vehicle.save + redirect_to @vehicle, notice: "Vehicle created." + else + render :new, status: :unprocessable_entity + end + end + + def edit + end + + def update + if @vehicle.update(vehicle_params) + redirect_to @vehicle, notice: "Vehicle updated." + else + render :edit, status: :unprocessable_entity + end + end + + def destroy + @vehicle.update(active: false) + redirect_to vehicles_path, notice: "Vehicle deactivated." + end + + private + + def set_vehicle + @vehicle = Vehicle.find(params[:id]) + end + + def vehicles_for_status(status) + case status + when "inactive" + Vehicle.where(active: false) + when "all" + Vehicle.all + else + Vehicle.where(active: true) + end + end + + def vehicle_params + params.require(:vehicle).permit( + :make, + :model, + :year, + :color, + :vin, + :licence_plate, + :current_odometer, + :fuel_tank_size, + :active + ) + end + + def prepare_show + @maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type) + @fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) + @maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc) + @fuel_entry ||= @vehicle.fuel_entries.build(date: Date.current) + @maintenance_entry ||= @vehicle.maintenance_entries.build(date: Date.current) + end +end diff --git a/app/javascript/controllers/maintenance_odometer_warning_controller.js b/app/javascript/controllers/maintenance_odometer_warning_controller.js new file mode 100644 index 0000000..2740b6d --- /dev/null +++ b/app/javascript/controllers/maintenance_odometer_warning_controller.js @@ -0,0 +1,20 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["odometer", "warning"] + static values = { + currentOdometer: Number, + threshold: Number + } + + check() { + const odometer = Number.parseInt(this.odometerTarget.value, 10) + + if (Number.isNaN(odometer) || odometer >= this.currentOdometerValue - this.thresholdValue) { + this.warningTarget.hidden = true + return + } + + this.warningTarget.hidden = false + } +} diff --git a/app/models/access_key.rb b/app/models/access_key.rb new file mode 100644 index 0000000..bf627b8 --- /dev/null +++ b/app/models/access_key.rb @@ -0,0 +1,65 @@ +class AccessKey < ApplicationRecord + DEFAULT_ROTATION_FREQUENCY_DAYS = 30 + KEY_LENGTH = 32 + + validates :raw_key, presence: true + validates :generated_at, presence: true + validates :auto_rotation_frequency_days, + numericality: { only_integer: true, greater_than: 0 } + + def self.current + order(generated_at: :desc, id: :desc).first || create_current! + end + + def self.create_current! + create!( + raw_key: generate_raw_key, + generated_at: Time.current, + auto_rotate: true, + auto_rotation_frequency_days: DEFAULT_ROTATION_FREQUENCY_DAYS + ) + end + + def self.rotate! + previous_key = current + + transaction do + UserSession.delete_all + create!( + raw_key: generate_raw_key, + generated_at: Time.current, + auto_rotate: previous_key.auto_rotate, + auto_rotation_frequency_days: previous_key.auto_rotation_frequency_days + ) + end + end + + def self.rotate_if_needed! + key = current + return key unless key.auto_rotate? && key.expired? + + rotate! + end + + def self.generate_raw_key + SecureRandom.urlsafe_base64(KEY_LENGTH) + end + + def matches?(submitted_key) + ActiveSupport::SecurityUtils.secure_compare(raw_key, submitted_key.to_s) + rescue ArgumentError + false + end + + def expired? + generated_at <= auto_rotation_frequency_days.days.ago + end + + def disable_auto_rotation! + update!(auto_rotate: false) + end + + def enable_auto_rotation! + update!(auto_rotate: true) + end +end diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..f1051db --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user, :user_session +end diff --git a/app/models/fuel_entry.rb b/app/models/fuel_entry.rb index 35090e3..1a73c95 100644 --- a/app/models/fuel_entry.rb +++ b/app/models/fuel_entry.rb @@ -7,6 +7,7 @@ class FuelEntry < ApplicationRecord validates :price_paid, presence: true, numericality: { greater_than: 0 } validates :date, presence: true validate :date_cannot_be_in_the_future + validate :odometer_cannot_be_less_than_vehicle_current_odometer private @@ -15,4 +16,11 @@ class FuelEntry < ApplicationRecord errors.add(:date, "can't be in the future") end + + def odometer_cannot_be_less_than_vehicle_current_odometer + return if vehicle.blank? || odometer.blank? + return if odometer >= vehicle.current_odometer + + errors.add(:odometer, "cannot be less than the vehicle's current odometer") + end end diff --git a/app/models/user.rb b/app/models/user.rb index 8878592..7955a9f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1,6 +1,7 @@ class User < ApplicationRecord has_many :fuel_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user has_many :maintenance_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user + has_many :user_sessions, dependent: :destroy before_validation :normalize_email diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 0000000..4602e72 --- /dev/null +++ b/app/models/user_session.rb @@ -0,0 +1,28 @@ +class UserSession < ApplicationRecord + TOKEN_LENGTH = 32 + + belongs_to :user + + validates :token_digest, presence: true, uniqueness: true + + def self.create_for!(user) + raw_token = generate_raw_token + user_session = create!(user: user, token_digest: digest(raw_token), last_used_at: Time.current) + + [ user_session, raw_token ] + end + + def self.find_by_token(raw_token) + return if raw_token.blank? + + find_by(token_digest: digest(raw_token)) + end + + def self.generate_raw_token + SecureRandom.urlsafe_base64(TOKEN_LENGTH) + end + + def self.digest(raw_token) + Digest::SHA256.hexdigest(raw_token.to_s) + end +end diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb index 6387fae..f680401 100644 --- a/app/models/vehicle.rb +++ b/app/models/vehicle.rb @@ -3,7 +3,7 @@ class Vehicle < ApplicationRecord has_many :fuel_entries, dependent: :restrict_with_error has_many :maintenance_entries, dependent: :restrict_with_error - before_validation :normalize_vin + before_validation :normalize_vin, :normalize_licence_plate validates :make, :model, :color, :vin, :licence_plate, presence: true validates :vin, uniqueness: true @@ -19,4 +19,8 @@ class Vehicle < ApplicationRecord def normalize_vin self.vin = vin.to_s.strip.upcase if vin.present? end + + def normalize_licence_plate + self.licence_plate = licence_plate.to_s.strip.upcase if licence_plate.present? + end end diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index cb8c027..098f121 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,6 +24,15 @@
+ <% if identified? %> ++ Identified as <%= current_user.name %> (<%= current_user.email %>) + <%= button_to "Log out", logout_path, method: :delete %> +
+