mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
commit
537cbb7cb5
17 changed files with 319 additions and 30 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
21
app/controllers/auth/ios_controller.rb
Normal file
21
app/controllers/auth/ios_controller.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"webcredentials": {
|
||||
"apps": [
|
||||
"2A275P77DQ.app.dawarich.Dawarich"
|
||||
"2A275P77DQ.app.dawarich.Dawarich",
|
||||
"3DJN84WAS8.app.dawarich.Dawarich"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue