diff --git a/CHANGELOG.md b/CHANGELOG.md index 98fcc355..3fbd7518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/). # 0.24.2 - 2025-02-24 +## Added + +- Status field to the User model. Inactive users are now being restricted from accessing some of the functionality, which is mostly about writing data to the database. Reading is remaining unrestricted. + ## Fixed - Fixed a bug where non-admin users could not import Immich and Photoprism geolocation data. diff --git a/app/controllers/api/v1/overland/batches_controller.rb b/app/controllers/api/v1/overland/batches_controller.rb index 530b7eab..db7c4ade 100644 --- a/app/controllers/api/v1/overland/batches_controller.rb +++ b/app/controllers/api/v1/overland/batches_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::Overland::BatchesController < ApiController + before_action :authenticate_active_api_user!, only: %i[create] + def create Overland::BatchCreatingJob.perform_later(batch_params, current_api_user.id) diff --git a/app/controllers/api/v1/owntracks/points_controller.rb b/app/controllers/api/v1/owntracks/points_controller.rb index e1f8bb9a..26c53c2f 100644 --- a/app/controllers/api/v1/owntracks/points_controller.rb +++ b/app/controllers/api/v1/owntracks/points_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::Owntracks::PointsController < ApiController + before_action :authenticate_active_api_user!, only: %i[create] + def create Owntracks::PointCreatingJob.perform_later(point_params, current_api_user.id) diff --git a/app/controllers/api/v1/points_controller.rb b/app/controllers/api/v1/points_controller.rb index f09340b8..dc34387c 100644 --- a/app/controllers/api/v1/points_controller.rb +++ b/app/controllers/api/v1/points_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::PointsController < ApiController + before_action :authenticate_active_api_user!, only: %i[create update destroy] + 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 diff --git a/app/controllers/api/v1/settings_controller.rb b/app/controllers/api/v1/settings_controller.rb index 316c201e..7d7e123d 100644 --- a/app/controllers/api/v1/settings_controller.rb +++ b/app/controllers/api/v1/settings_controller.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class Api::V1::SettingsController < ApiController + before_action :authenticate_active_api_user!, only: %i[update] + def index render json: { settings: current_api_user.settings, diff --git a/app/controllers/api_controller.rb b/app/controllers/api_controller.rb index c193148e..868c72c0 100644 --- a/app/controllers/api_controller.rb +++ b/app/controllers/api_controller.rb @@ -12,6 +12,12 @@ class ApiController < ApplicationController true end + def authenticate_active_api_user! + render json: { error: 'User is not active' }, status: :unauthorized unless current_api_user&.active? + + true + end + def current_api_user @current_api_user ||= User.find_by(api_key:) end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 7b7c27d0..78071582 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -25,6 +25,12 @@ class ApplicationController < ActionController::Base redirect_to root_path, notice: 'You are not authorized to perform this action.', status: :see_other end + def authenticate_active_user! + return if current_user&.active? + + redirect_to root_path, notice: 'Your account is not active.', status: :see_other + end + private def set_self_hosted_status diff --git a/app/controllers/imports_controller.rb b/app/controllers/imports_controller.rb index 3004cf8a..a6359e67 100644 --- a/app/controllers/imports_controller.rb +++ b/app/controllers/imports_controller.rb @@ -2,6 +2,7 @@ class ImportsController < ApplicationController before_action :authenticate_user! + before_action :authenticate_active_user!, only: %i[new create] before_action :set_import, only: %i[show destroy] def index diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 243189cf..82a934af 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -2,7 +2,7 @@ class SettingsController < ApplicationController before_action :authenticate_user! - + before_action :authenticate_active_user!, only: %i[update] def index; end def update diff --git a/app/controllers/stats_controller.rb b/app/controllers/stats_controller.rb index b7e68f41..045772f3 100644 --- a/app/controllers/stats_controller.rb +++ b/app/controllers/stats_controller.rb @@ -2,6 +2,7 @@ class StatsController < ApplicationController before_action :authenticate_user! + before_action :authenticate_active_user!, only: %i[update update_all] def index @stats = current_user.stats.group_by(&:year).sort.reverse diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index 038d4842..f9e57e1d 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -2,6 +2,7 @@ class TripsController < ApplicationController before_action :authenticate_user! + before_action :authenticate_active_user!, only: %i[new create] before_action :set_trip, only: %i[show edit update destroy] before_action :set_coordinates, only: %i[show edit] diff --git a/app/jobs/visit_suggesting_job.rb b/app/jobs/visit_suggesting_job.rb index b1a3e13d..e217dd4d 100644 --- a/app/jobs/visit_suggesting_job.rb +++ b/app/jobs/visit_suggesting_job.rb @@ -8,6 +8,7 @@ class VisitSuggestingJob < ApplicationJob users = user_ids.any? ? User.where(id: user_ids) : User.all users.find_each do |user| + next unless user.active? next if user.tracked_points.empty? Visits::Suggest.new(user, start_at:, end_at:).call diff --git a/app/models/user.rb b/app/models/user.rb index 97fb9fe0..2f6499d2 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -16,6 +16,7 @@ class User < ApplicationRecord has_many :trips, dependent: :destroy after_create :create_api_key + after_commit :activate, on: :create, if: -> { DawarichSettings.self_hosted? } before_save :sanitize_input validates :email, presence: true @@ -24,6 +25,8 @@ class User < ApplicationRecord attribute :admin, :boolean, default: false + enum :status, { inactive: 0, active: 1 } + def safe_settings Users::SafeSettings.new(settings) end @@ -104,6 +107,10 @@ class User < ApplicationRecord save end + def activate + update(status: :active) + end + def sanitize_input settings['immich_url']&.gsub!(%r{/+\z}, '') settings['photoprism_url']&.gsub!(%r{/+\z}, '') diff --git a/db/migrate/20250219195822_add_status_to_users.rb b/db/migrate/20250219195822_add_status_to_users.rb new file mode 100644 index 00000000..eeffba08 --- /dev/null +++ b/db/migrate/20250219195822_add_status_to_users.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddStatusToUsers < ActiveRecord::Migration[8.0] + def change + add_column :users, :status, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 757a2f3b..eccd2a45 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -224,6 +224,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_21_194509) do t.datetime "last_sign_in_at" t.string "current_sign_in_ip" t.string "last_sign_in_ip" + t.integer "status", default: 0 t.index ["email"], name: "index_users_on_email", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index f3fe9d7b..1f1c54bc 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -6,6 +6,8 @@ FactoryBot.define do "user#{n}@example.com" end + status { :active } + password { SecureRandom.hex(8) } settings do diff --git a/spec/jobs/visit_suggesting_job_spec.rb b/spec/jobs/visit_suggesting_job_spec.rb index f2ce47d9..271d7675 100644 --- a/spec/jobs/visit_suggesting_job_spec.rb +++ b/spec/jobs/visit_suggesting_job_spec.rb @@ -30,5 +30,17 @@ RSpec.describe VisitSuggestingJob, type: :job do expect(Visits::Suggest).to have_received(:new) end end + + context 'when user is inactive' do + before do + users.first.update(status: :inactive) + end + + it 'does not suggest visits' do + subject + + expect(Visits::Suggest).not_to have_received(:new) + end + end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 398e436f..0c5d60e3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -16,6 +16,10 @@ RSpec.describe User, type: :model do it { is_expected.to have_many(:trips).dependent(:destroy) } end + describe 'enums' do + it { is_expected.to define_enum_for(:status).with_values(inactive: 0, active: 1) } + end + describe 'callbacks' do describe '#create_api_key' do let(:user) { create(:user) } @@ -24,6 +28,33 @@ RSpec.describe User, type: :model do expect(user.api_key).to be_present end end + + describe '#activate' do + context 'when self-hosted' do + let!(:user) { create(:user, status: :inactive) } + + before do + allow(DawarichSettings).to receive(:self_hosted?).and_return(true) + end + + it 'activates user after creation' do + expect(user.active?).to be_truthy + end + end + + context 'when not self-hosted' do + let!(:user) { create(:user, status: :inactive) } + + before do + stub_const('SELF_HOSTED', false) + allow(DawarichSettings).to receive(:self_hosted?).and_return(false) + end + + xit 'does not activate user' do + expect(user.active?).to be_falsey + end + end + end end describe 'methods' do diff --git a/spec/requests/api/v1/overland/batches_spec.rb b/spec/requests/api/v1/overland/batches_spec.rb index 912aa280..a673480b 100644 --- a/spec/requests/api/v1/overland/batches_spec.rb +++ b/spec/requests/api/v1/overland/batches_spec.rb @@ -31,6 +31,18 @@ RSpec.describe 'Api::V1::Overland::Batches', type: :request do post "/api/v1/overland/batches?api_key=#{user.api_key}", params: params end.to have_enqueued_job(Overland::BatchCreatingJob) end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns http unauthorized' do + post "/api/v1/overland/batches?api_key=#{user.api_key}", params: params + + expect(response).to have_http_status(:unauthorized) + end + end end end end diff --git a/spec/requests/api/v1/owntracks/points_spec.rb b/spec/requests/api/v1/owntracks/points_spec.rb index e99b6c6f..39cf486f 100644 --- a/spec/requests/api/v1/owntracks/points_spec.rb +++ b/spec/requests/api/v1/owntracks/points_spec.rb @@ -31,6 +31,18 @@ RSpec.describe 'Api::V1::Owntracks::Points', type: :request do end.to have_enqueued_job(Owntracks::PointCreatingJob) end end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns http unauthorized' do + post api_v1_owntracks_points_path(api_key: user.api_key), params: params + + expect(response).to have_http_status(:unauthorized) + end + end end end end diff --git a/spec/requests/api/v1/points_spec.rb b/spec/requests/api/v1/points_spec.rb index 3d5f49d8..f218d085 100644 --- a/spec/requests/api/v1/points_spec.rb +++ b/spec/requests/api/v1/points_spec.rb @@ -119,4 +119,66 @@ RSpec.describe 'Api::V1::Points', type: :request do end end end + + describe 'POST /create' do + it 'returns a successful response' do + post "/api/v1/points?api_key=#{user.api_key}", params: { point: { latitude: 1.0, longitude: 1.0 } } + + expect(response).to have_http_status(:success) + end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns an unauthorized response' do + post "/api/v1/points?api_key=#{user.api_key}", params: { point: { latitude: 1.0, longitude: 1.0 } } + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'PUT /update' do + it 'returns a successful response' do + put "/api/v1/points/#{points.first.id}?api_key=#{user.api_key}", + params: { point: { latitude: 1.0, longitude: 1.1 } } + + expect(response).to have_http_status(:success) + end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns an unauthorized response' do + put "/api/v1/points/#{points.first.id}?api_key=#{user.api_key}", + params: { point: { latitude: 1.0, longitude: 1.1 } } + + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe 'DELETE /destroy' do + it 'returns a successful response' do + delete "/api/v1/points/#{points.first.id}?api_key=#{user.api_key}" + + expect(response).to have_http_status(:success) + end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns an unauthorized response' do + delete "/api/v1/points/#{points.first.id}?api_key=#{user.api_key}" + + expect(response).to have_http_status(:unauthorized) + end + end + end end diff --git a/spec/requests/api/v1/settings_spec.rb b/spec/requests/api/v1/settings_spec.rb index 69498221..075e3dca 100644 --- a/spec/requests/api/v1/settings_spec.rb +++ b/spec/requests/api/v1/settings_spec.rb @@ -25,6 +25,18 @@ RSpec.describe 'Api::V1::Settings', type: :request do expect(response.parsed_body['settings']['route_opacity'].to_f).to eq(0.3) end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns http unauthorized' do + patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 0.3 } } + + expect(response).to have_http_status(:unauthorized) + end + end end context 'with invalid request' do diff --git a/spec/requests/settings_spec.rb b/spec/requests/settings_spec.rb index 0ced085e..f457855c 100644 --- a/spec/requests/settings_spec.rb +++ b/spec/requests/settings_spec.rb @@ -82,6 +82,19 @@ RSpec.describe 'Settings', type: :request do expect(user.reload.settings).to eq(params[:settings]) end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'redirects to the root path' do + patch '/settings', params: params + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end end describe 'GET /settings/users' do diff --git a/spec/requests/stats_spec.rb b/spec/requests/stats_spec.rb index c7473bed..516f4cd3 100644 --- a/spec/requests/stats_spec.rb +++ b/spec/requests/stats_spec.rb @@ -69,6 +69,19 @@ RSpec.describe '/stats', type: :request do end end end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns an unauthorized response' do + put update_year_month_stats_url(year: '2024', month: '1') + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end end describe 'PUT /update_all' do @@ -83,6 +96,19 @@ RSpec.describe '/stats', type: :request do expect(Stats::CalculatingJob).to have_been_enqueued.with(user.id, 2024, 2) expect(Stats::CalculatingJob).to_not have_been_enqueued.with(user.id, 2024, 3) end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'returns an unauthorized response' do + put update_all_stats_url + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end end end end diff --git a/spec/requests/trips_spec.rb b/spec/requests/trips_spec.rb index d0e1e794..3f536edc 100644 --- a/spec/requests/trips_spec.rb +++ b/spec/requests/trips_spec.rb @@ -53,6 +53,19 @@ RSpec.describe '/trips', type: :request do expect(response).to be_successful end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'redirects to the root path' do + get new_trip_url + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end end describe 'GET /edit' do @@ -77,6 +90,19 @@ RSpec.describe '/trips', type: :request do post trips_url, params: { trip: valid_attributes } expect(response).to redirect_to(trip_url(Trip.last)) end + + context 'when user is inactive' do + before do + user.update(status: :inactive) + end + + it 'redirects to the root path' do + post trips_url, params: { trip: valid_attributes } + + expect(response).to redirect_to(root_path) + expect(flash[:notice]).to eq('Your account is not active.') + end + end end context 'with invalid parameters' do diff --git a/spec/swagger/api/v1/points_controller_spec.rb b/spec/swagger/api/v1/points_controller_spec.rb index e5b8bf01..7b710f2d 100644 --- a/spec/swagger/api/v1/points_controller_spec.rb +++ b/spec/swagger/api/v1/points_controller_spec.rb @@ -78,6 +78,9 @@ describe 'Points API', type: :request do coordinates: [-122.40530871, 37.74430413] }, properties: { + battery_state: 'full', + battery_level: 0.7, + wifi: 'dawarich_home', timestamp: '2025-01-17T21:03:01Z', horizontal_accuracy: 5, vertical_accuracy: -1, @@ -92,7 +95,7 @@ describe 'Points API', type: :request do } ] } - tags 'Batches' + tags 'Points' consumes 'application/json' parameter name: :locations, in: :body, schema: { type: :object, diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 3ce30e09..57db976d 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -828,7 +828,7 @@ paths: post: summary: Creates a batch of points tags: - - Batches + - Points parameters: - name: api_key in: query @@ -918,6 +918,9 @@ paths: - -122.40530871 - 37.74430413 properties: + battery_state: full + battery_level: 0.7 + wifi: dawarich_home timestamp: '2025-01-17T21:03:01Z' horizontal_accuracy: 5 vertical_accuracy: -1