Merge pull request #1786 from Freika/feature/ios-auth

Feature/ios auth
This commit is contained in:
Evgenii Burmakin 2025-09-22 22:20:19 +02:00 committed by GitHub
commit 537cbb7cb5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 319 additions and 30 deletions

View file

@ -12,12 +12,18 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Hexagons for the stats page are now being calculated a lot faster.
- Prometheus exporter is now not being started when console is being run.
- Stats will now properly reflect countries and cities visited after importing new points.
- `GET /api/v1/points will now return correct latitude and longitude values. #1502
- Deleting an import will now trigger stats recalculation for affected months. #1789
## Changed
- Onboarding modal window now features a link to the App Store and a QR code to configure the Dawarich iOS app.
- A permanent option was removed from stats sharing options. Now, stats can be shared for 1, 12 or 24 hours only.
## Added
- Added foundation for upcoming authentication from iOS app.
# [0.32.0] - 2025-09-13

View file

@ -5,7 +5,7 @@ class ApplicationController < ActionController::Base
rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
before_action :unread_notifications, :set_self_hosted_status
before_action :unread_notifications, :set_self_hosted_status, :store_client_header
protected
@ -39,12 +39,36 @@ class ApplicationController < ActionController::Base
user_not_authorized
end
def after_sign_in_path_for(resource)
# Check both current request header and stored session value
client_type = request.headers['X-Dawarich-Client'] || session[:dawarich_client]
case client_type
when 'ios'
payload = { api_key: resource.api_key, exp: 5.minutes.from_now.to_i }
token = Subscription::EncodeJwtToken.new(
payload, ENV['AUTH_JWT_SECRET_KEY']
).call
ios_success_path(token:)
else
super
end
end
private
def set_self_hosted_status
@self_hosted = DawarichSettings.self_hosted?
end
def store_client_header
return unless request.headers['X-Dawarich-Client']
session[:dawarich_client] = request.headers['X-Dawarich-Client']
end
def user_not_authorized
redirect_back fallback_location: root_path,
alert: 'You are not authorized to perform this action.',

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Auth
class IosController < ApplicationController
def success
# If token is provided, this is the final callback for ASWebAuthenticationSession
if params[:token].present?
# ASWebAuthenticationSession will capture this URL and extract the token
render plain: "Authentication successful! You can close this window.", status: :ok
else
# This should not happen with our current flow, but keeping for safety
render json: {
success: true,
message: 'iOS authentication successful',
redirect_url: root_url
}, status: :ok
end
end
end
end

View file

@ -11,6 +11,7 @@ class Import < ApplicationRecord
after_commit -> { Import::ProcessJob.perform_later(id) unless skip_background_processing }, on: :create
after_commit :remove_attached_file, on: :destroy
before_commit :recalculate_stats, on: :destroy, if: -> { points.exists? }
validates :name, presence: true, uniqueness: { scope: :user_id }
validate :file_size_within_limit, if: -> { user.trial? }
@ -63,8 +64,14 @@ class Import < ApplicationRecord
def file_size_within_limit
return unless file.attached?
if file.blob.byte_size > 11.megabytes
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
return unless file.blob.byte_size > 11.megabytes
errors.add(:file, 'is too large. Trial users can only upload files up to 10MB.')
end
def recalculate_stats
years_and_months_tracked.each do |year, month|
Stats::CalculatingJob.perform_later(user.id, year, month)
end
end
end

View file

@ -1,9 +1,26 @@
# frozen_string_literal: true
class Api::PointSerializer < PointSerializer
EXCLUDED_ATTRIBUTES = %w[created_at updated_at visit_id import_id user_id raw_data country_id].freeze
class Api::PointSerializer
EXCLUDED_ATTRIBUTES = %w[
created_at updated_at visit_id import_id user_id raw_data
country_id
].freeze
def initialize(point)
@point = point
end
def call
point.attributes.except(*EXCLUDED_ATTRIBUTES)
point.attributes.except(*EXCLUDED_ATTRIBUTES).tap do |attributes|
lat = point.lat
lon = point.lon
attributes['latitude'] = lat.nil? ? nil : lat.to_s
attributes['longitude'] = lon.nil? ? nil : lon.to_s
end
end
private
attr_reader :point
end

View file

@ -6,15 +6,19 @@ class Api::UserSerializer
end
def call
{
data = {
user: {
email: user.email,
theme: user.theme,
created_at: user.created_at,
updated_at: user.updated_at,
settings: settings,
settings: settings
}
}
data.merge!(subscription: subscription) unless DawarichSettings.self_hosted?
data
end
private
@ -41,4 +45,11 @@ class Api::UserSerializer
fog_of_war_threshold: user.safe_settings.fog_of_war_threshold
}
end
def subscription
{
status: user.status,
active_until: user.active_until
}
end
end

View file

@ -48,15 +48,16 @@ module LocationSearch
last_point = sorted_points.last
# Calculate visit duration
duration_minutes = if sorted_points.length > 1
((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round
else
# Single point visit - estimate based on typical stay time
15 # minutes
end
duration_minutes =
if sorted_points.any?
((last_point[:timestamp] - first_point[:timestamp]) / 60.0).round
else
# Single point visit - estimate based on typical stay time
15 # minutes
end
# Find the most accurate point (lowest accuracy value means higher precision)
most_accurate_point = points.min_by { |p| p[:accuracy] || 999999 }
most_accurate_point = points.min_by { |p| p[:accuracy] || 999_999 }
# Calculate average distance from search center
average_distance = (points.sum { |p| p[:distance_meters] } / points.length).round(2)
@ -86,7 +87,7 @@ module LocationSearch
hours = minutes / 60
remaining_minutes = minutes % 60
if remaining_minutes == 0
if remaining_minutes.zero?
"~#{pluralize(hours, 'hour')}"
else
"~#{pluralize(hours, 'hour')} #{pluralize(remaining_minutes, 'minute')}"

View file

@ -5,7 +5,7 @@
<p class="py-6">and take control over your location data.</p>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body ') do |f| %>
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<div class="form-control">
<%= f.label :email, class: 'label' do %>
<span class="label-text">Email</span>

View file

@ -10,7 +10,7 @@
<% end %>
</div>
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5">
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body ') do |f| %>
<%= form_for(resource, as: resource_name, url: session_path(resource_name), class: 'form-body', html: { data: { turbo: session[:dawarich_client] == 'ios' ? false : true } }) do |f| %>
<div class="form-control">
<%= f.label :email, class: 'label' do %>
<span class="label-text">Email</span>

View file

@ -76,7 +76,11 @@
<div class="join">
<%= link_to "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}" do %>
<span class="join-item btn btn-sm <%= trial_button_class(current_user) %>">
<span class="tooltip tooltip-bottom" data-tip="Your trial will end in <%= distance_of_time_in_words(current_user.active_until, Time.current) %>"><%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining</span>
<% if current_user.active_until.past? %>
<span class="tooltip tooltip-bottom">Trial expired 🥺</span>
<% else %>
<span class="tooltip tooltip-bottom" data-tip="Your trial will end in <%= distance_of_time_in_words(current_user.active_until, Time.current) %>"><%= (current_user.active_until.to_date - Time.current.to_date).to_i %> days remaining</span>
<% end %>
</span><span class="join-item btn btn-sm btn-success">
Subscribe
</span>

View file

@ -85,6 +85,9 @@ Rails.application.routes.draw do
root to: 'home#index'
# iOS mobile auth success endpoint
get 'auth/ios/success', to: 'auth/ios#success', as: :ios_success
if SELF_HOSTED
devise_for :users, skip: [:registrations]
as :user do

View file

@ -4,9 +4,11 @@ class AddH3HexIdsToStats < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_column :stats, :h3_hex_ids, :jsonb, default: {}
add_index :stats, :h3_hex_ids, using: :gin,
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
algorithm: :concurrently
add_column :stats, :h3_hex_ids, :jsonb, default: {}, if_not_exists: true
safety_assured do
add_index :stats, :h3_hex_ids, using: :gin,
where: "(h3_hex_ids IS NOT NULL AND h3_hex_ids != '{}'::jsonb)",
algorithm: :concurrently, if_not_exists: true
end
end
end

View file

@ -1,7 +1,8 @@
{
"webcredentials": {
"apps": [
"2A275P77DQ.app.dawarich.Dawarich"
"2A275P77DQ.app.dawarich.Dawarich",
"3DJN84WAS8.app.dawarich.Dawarich"
]
}
}

View file

@ -31,7 +31,7 @@ RSpec.describe Import, type: :model do
describe 'file size validation' do
context 'when user is a trial user' do
let(:user) do
let(:user) do
user = create(:user)
user.update!(status: :trial)
user
@ -116,4 +116,28 @@ RSpec.describe Import, type: :model do
end
end
end
describe '#recalculate_stats' do
let(:import) { create(:import, user:) }
let!(:point1) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 11, 15).to_i) }
let!(:point2) { create(:point, import:, user:, timestamp: Time.zone.local(2024, 12, 5).to_i) }
it 'enqueues stats calculation jobs for each tracked month' do
expect do
import.send(:recalculate_stats)
end.to have_enqueued_job(Stats::CalculatingJob)
.with(user.id, 2024, 11)
.and have_enqueued_job(Stats::CalculatingJob).with(user.id, 2024, 12)
end
context 'when import has no points' do
let(:empty_import) { create(:import, user:) }
it 'does not enqueue any jobs' do
expect do
empty_import.send(:recalculate_stats)
end.not_to have_enqueued_job(Stats::CalculatingJob)
end
end
end
end

View file

@ -6,9 +6,9 @@ RSpec.describe 'Authentication', type: :request do
let(:user) { create(:user, password: 'password123') }
before do
stub_request(:get, "https://api.github.com/repos/Freika/dawarich/tags")
.with(headers: { 'Accept'=>'*/*', 'Accept-Encoding'=>/.*/,
'Host'=>'api.github.com', 'User-Agent'=>/.*/})
stub_request(:get, 'https://api.github.com/repos/Freika/dawarich/tags')
.with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => /.*/,
'Host' => 'api.github.com', 'User-Agent' => /.*/ })
.to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
end
@ -66,4 +66,104 @@ RSpec.describe 'Authentication', type: :request do
expect(response).to redirect_to(new_user_session_path)
end
end
describe 'Mobile iOS Authentication' do
it 'redirects to iOS success path when signing in with iOS client header' do
# Make a login request with the iOS client header (user NOT pre-signed in)
post user_session_path, params: {
user: { email: user.email, password: 'password123' }
}, headers: {
'X-Dawarich-Client' => 'ios',
'Accept' => 'text/html'
}
# Should redirect to iOS success endpoint after successful login
# The redirect will include a token parameter generated by after_sign_in_path_for
expect(response).to redirect_to(%r{auth/ios/success\?token=})
expect(response.location).to include('token=')
end
it 'stores iOS client header in session' do
# Test that the header gets stored when accessing sign-in page
get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' }
expect(session[:dawarich_client]).to eq('ios')
end
it 'redirects to iOS success path using stored session value' do
# Simulate iOS app accessing sign-in page first (stores header in session)
get new_user_session_path, headers: { 'X-Dawarich-Client' => 'ios' }
# Then sign-in POST request without header (relies on session)
post user_session_path, params: {
user: { email: user.email, password: 'password123' }
}, headers: {
'Accept' => 'text/html'
}
# Should still redirect to iOS success endpoint using session value
expect(response).to redirect_to(%r{auth/ios/success\?token=})
expect(response.location).to include('token=')
end
it 'returns plain text response for iOS success endpoint with token' do
# Generate a test JWT token using the same service as the controller
payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i }
test_token = Subscription::EncodeJwtToken.new(
payload, ENV['AUTH_JWT_SECRET_KEY']
).call
get ios_success_path, params: { token: test_token }
expect(response).to be_successful
expect(response.content_type).to include('text/plain')
expect(response.body).to eq('Authentication successful! You can close this window.')
end
it 'returns JSON response when no token is provided to iOS success endpoint' do
get ios_success_path
expect(response).to be_successful
expect(response.content_type).to include('application/json')
json_response = JSON.parse(response.body)
expect(json_response['success']).to be true
expect(json_response['message']).to eq('iOS authentication successful')
expect(json_response['redirect_url']).to eq(root_url)
end
it 'generates JWT token with correct payload for iOS authentication' do
# Test JWT token generation directly using the same logic as after_sign_in_path_for
payload = { api_key: user.api_key, exp: 5.minutes.from_now.to_i }
# Create JWT token using the same service
token = Subscription::EncodeJwtToken.new(
payload, ENV['AUTH_JWT_SECRET_KEY']
).call
expect(token).to be_present
# Decode the token to verify the payload
decoded_payload = JWT.decode(
token,
ENV['AUTH_JWT_SECRET_KEY'],
true,
{ algorithm: 'HS256' }
).first
expect(decoded_payload['api_key']).to eq(user.api_key)
expect(decoded_payload['exp']).to be_present
end
it 'uses default path for non-iOS clients' do
# Make a login request without iOS client header (user NOT pre-signed in)
post user_session_path, params: {
user: { email: user.email, password: 'password123' }
}
# Should redirect to default path (not iOS success)
expect(response).not_to redirect_to(%r{auth/ios/success})
expect(response.location).not_to include('auth/ios/success')
end
end
end

View file

@ -7,14 +7,26 @@ RSpec.describe Api::PointSerializer do
subject(:serializer) { described_class.new(point).call }
let(:point) { create(:point) }
let(:expected_json) { point.attributes.except(*Api::PointSerializer::EXCLUDED_ATTRIBUTES) }
let(:all_excluded) { PointSerializer::EXCLUDED_ATTRIBUTES + Api::PointSerializer::ADDITIONAL_EXCLUDED_ATTRIBUTES }
let(:expected_json) do
point.attributes.except(*all_excluded).tap do |attributes|
# API serializer extracts coordinates from PostGIS geometry
attributes['latitude'] = point.lat.to_s
attributes['longitude'] = point.lon.to_s
end
end
it 'returns JSON with correct attributes' do
expect(serializer.to_json).to eq(expected_json.to_json)
end
it 'does not include excluded attributes' do
expect(serializer).not_to include(*Api::PointSerializer::EXCLUDED_ATTRIBUTES)
expect(serializer).not_to include(*all_excluded)
end
it 'extracts coordinates from PostGIS geometry' do
expect(serializer['latitude']).to eq(point.lat.to_s)
expect(serializer['longitude']).to eq(point.lon.to_s)
end
end
end

View file

@ -81,5 +81,61 @@ RSpec.describe Api::UserSerializer do
expect(settings[:maps]).to eq({ 'distance_unit' => 'mi' })
end
end
context 'subscription data' do
context 'when not self-hosted (hosted instance)' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(false)
end
it 'includes subscription data' do
expect(serializer).to have_key(:subscription)
expect(serializer[:subscription]).to include(:status, :active_until)
end
it 'returns correct subscription values' do
subscription = serializer[:subscription]
expect(subscription[:status]).to eq(user.status)
expect(subscription[:active_until]).to eq(user.active_until)
end
context 'with specific subscription values' do
it 'serializes trial user status correctly' do
# When not self-hosted, users start with trial status via start_trial callback
test_user = create(:user)
serializer_result = described_class.new(test_user).call
subscription = serializer_result[:subscription]
expect(subscription[:status]).to eq('trial')
expect(subscription[:active_until]).to be_within(1.second).of(7.days.from_now)
end
it 'serializes subscription data with all expected fields' do
test_user = create(:user)
serializer_result = described_class.new(test_user).call
subscription = serializer_result[:subscription]
expect(subscription).to include(:status, :active_until)
expect(subscription[:status]).to be_a(String)
expect(subscription[:active_until]).to be_a(ActiveSupport::TimeWithZone)
end
end
end
context 'when self-hosted' do
before do
allow(DawarichSettings).to receive(:self_hosted?).and_return(true)
end
it 'does not include subscription data' do
expect(serializer).not_to have_key(:subscription)
end
it 'still includes user and settings data' do
expect(serializer).to have_key(:user)
expect(serializer[:user]).to include(:email, :theme, :settings)
end
end
end
end
end