diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/subscriptions_controller.rb index ef82856a..1b14fde4 100644 --- a/app/controllers/api/v1/subscriptions_controller.rb +++ b/app/controllers/api/v1/subscriptions_controller.rb @@ -1,31 +1,19 @@ # frozen_string_literal: true -class Api::V1::SubscriptionsController < ApplicationController - before_action :authenticate_user! - before_action :authenticate_non_self_hosted! - - # rubocop:disable Metrics/MethodLength +class Api::V1::SubscriptionsController < ApiController + skip_before_action :authenticate_api_key, only: %i[callback] def callback - token = params[:token] + decoded_token = Subscription::DecodeJwtToken.new(params[:token]).call - begin - decoded_token = Subscription::DecodeJwtToken.new(token).call + user = User.find(decoded_token[:user_id]) + user.update!(status: decoded_token[:status], active_until: decoded_token[:active_until]) - unless decoded_token[:user_id] == current_user.id - render json: { message: 'Invalid subscription update request.' }, status: :unauthorized - return - end - - current_user.update!(status: decoded_token[:status], active_until: decoded_token[:active_until]) - - render json: { message: 'Subscription updated successfully' } - rescue JWT::DecodeError => e - Sentry.capture_exception(e) - render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized - rescue ArgumentError => e - Sentry.capture_exception(e) - render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity - end + render json: { message: 'Subscription updated successfully' } + rescue JWT::DecodeError => e + Sentry.capture_exception(e) + render json: { message: 'Failed to verify subscription update.' }, status: :unauthorized + rescue ArgumentError => e + Sentry.capture_exception(e) + render json: { message: 'Invalid subscription data received.' }, status: :unprocessable_entity end - # rubocop:enable Metrics/MethodLength end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index a93affb4..d8d7ef80 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -444,7 +444,7 @@ export default class extends BaseController { maps[this.userSettings.maps.name] = customLayer; } else { // If no custom map is set, ensure a default layer is added - const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"]; + const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"] || maps["Atlas"]; defaultLayer.addTo(this.map); } diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js index 6dc0c544..861ffa56 100644 --- a/app/javascript/controllers/trips_controller.js +++ b/app/javascript/controllers/trips_controller.js @@ -5,17 +5,7 @@ import BaseController from "./base_controller" import L from "leaflet" -import { - osmMapLayer, - osmHotMapLayer, - OPNVMapLayer, - openTopoMapLayer, - cyclOsmMapLayer, - esriWorldStreetMapLayer, - esriWorldTopoMapLayer, - esriWorldImageryMapLayer, - esriWorldGrayCanvasMapLayer -} from "../maps/layers" +import { createAllMapLayers } from "../maps/layers" import { createPopupContent } from "../maps/popups" import { fetchAndDisplayPhotos, @@ -61,7 +51,10 @@ export default class extends BaseController { this.map = L.map(this.containerTarget).setView(center, zoom) // Add base map layer - osmMapLayer(this.map, "OpenStreetMap") + const selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; + const maps = this.baseMaps(); + const defaultLayer = maps[selectedLayerName] || maps["OpenStreetMap"] || maps["Atlas"]; + defaultLayer.addTo(this.map); // Add scale control to bottom right L.control.scale({ @@ -168,18 +161,30 @@ export default class extends BaseController { baseMaps() { let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap"; + let maps = createAllMapLayers(this.map, selectedLayerName); - return { - OpenStreetMap: osmMapLayer(this.map, selectedLayerName), - "OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName), - OPNV: OPNVMapLayer(this.map, selectedLayerName), - openTopo: openTopoMapLayer(this.map, selectedLayerName), - cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName), - esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName), - esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName), - esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName), - esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName) - }; + // Add custom map if it exists in settings + if (this.userSettings.maps && this.userSettings.maps.url) { + const customLayer = L.tileLayer(this.userSettings.maps.url, { + maxZoom: 19, + attribution: "© OpenStreetMap contributors" + }); + + // If this is the preferred layer, add it to the map immediately + if (selectedLayerName === this.userSettings.maps.name) { + customLayer.addTo(this.map); + // Remove any other base layers that might be active + Object.values(maps).forEach(layer => { + if (this.map.hasLayer(layer)) { + this.map.removeLayer(layer); + } + }); + } + + maps[this.userSettings.maps.name] = customLayer; + } + + return maps; } addMarkers() { diff --git a/app/javascript/maps/layers.js b/app/javascript/maps/layers.js index e74303da..750901e3 100644 --- a/app/javascript/maps/layers.js +++ b/app/javascript/maps/layers.js @@ -48,127 +48,3 @@ export function osmMapLayer(map, selectedLayerName) { return layer; } } - -export function osmHotMapLayer(map, selectedLayerName) { - let layerName = "OpenStreetMap.HOT"; - let layer = L.tileLayer("https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png", { - maxZoom: 19, - attribution: "© OpenStreetMap contributors, Tiles style by Humanitarian OpenStreetMap Team hosted by OpenStreetMap France", - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function OPNVMapLayer(map, selectedLayerName) { - let layerName = 'OPNV'; - let layer = L.tileLayer('https://tileserver.memomaps.de/tilegen/{z}/{x}/{y}.png', { - maxZoom: 18, - attribution: 'Map memomaps.de CC-BY-SA, map data © OpenStreetMap contributors' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function openTopoMapLayer(map, selectedLayerName) { - let layerName = 'openTopo'; - let layer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', { - maxZoom: 17, - attribution: 'Map data: © OpenStreetMap contributors, SRTM | Map style: © OpenTopoMap (CC-BY-SA)' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function cyclOsmMapLayer(map, selectedLayerName) { - let layerName = 'cyclOsm'; - let layer = L.tileLayer('https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png', { - maxZoom: 20, - attribution: 'CyclOSM | Map data: © OpenStreetMap contributors' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function esriWorldStreetMapLayer(map, selectedLayerName) { - let layerName = 'esriWorldStreet'; - let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Street_Map/MapServer/tile/{z}/{y}/{x}', { - minZoom: 1, - maxZoom: 19, - bounds: [[-90, -180], [90, 180]], - noWrap: true, - attribution: 'Tiles © Esri — Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function esriWorldTopoMapLayer(map, selectedLayerName) { - let layerName = 'esriWorldTopo'; - let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}', { - minZoom: 1, - maxZoom: 19, - bounds: [[-90, -180], [90, 180]], - noWrap: true, - attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Community' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function esriWorldImageryMapLayer(map, selectedLayerName) { - let layerName = 'esriWorldImagery'; - let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { - minZoom: 1, - maxZoom: 19, - bounds: [[-90, -180], [90, 180]], - noWrap: true, - attribution: 'Tiles © Esri — Source: Esri, i-cubed, USDA, USGS, AEX, GeoEye, Getmapping, Aerogrid, IGN, IGP, UPR-EGP, and the GIS User Community' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} - -export function esriWorldGrayCanvasMapLayer(map, selectedLayerName) { - let layerName = 'esriWorldGrayCanvas'; - let layer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Canvas/World_Light_Gray_Base/MapServer/tile/{z}/{y}/{x}', { - minZoom: 1, - maxZoom: 16, - bounds: [[-90, -180], [90, 180]], - noWrap: true, - attribution: 'Tiles © Esri — Esri, DeLorme, NAVTEQ' - }); - - if (selectedLayerName === layerName) { - return layer.addTo(map); - } else { - return layer; - } -} diff --git a/app/services/google_maps/phone_takeout_parser.rb b/app/services/google_maps/phone_takeout_parser.rb index 97d4626c..dae1441d 100644 --- a/app/services/google_maps/phone_takeout_parser.rb +++ b/app/services/google_maps/phone_takeout_parser.rb @@ -3,6 +3,9 @@ class GoogleMaps::PhoneTakeoutParser include Imports::Broadcaster + DOWNLOAD_TIMEOUT = 300 # 5 minutes timeout + MAX_RETRIES = 3 + attr_reader :import, :user_id def initialize(import, user_id) @@ -48,15 +51,15 @@ class GoogleMaps::PhoneTakeoutParser raw_signals = [] raw_array = [] - import.file.download do |file| - json = Oj.load(file) + file_content = SecureFileDownloader.new(import.file).download_with_verification - if json.is_a?(Array) - raw_array = parse_raw_array(json) - else - semantic_segments = parse_semantic_segments(json['semanticSegments']) if json['semanticSegments'] - raw_signals = parse_raw_signals(json['rawSignals']) if json['rawSignals'] - end + json = Oj.load(file_content) + + if json.is_a?(Array) + raw_array = parse_raw_array(json) + else + semantic_segments = parse_semantic_segments(json['semanticSegments']) if json['semanticSegments'] + raw_signals = parse_raw_signals(json['rawSignals']) if json['rawSignals'] end semantic_segments + raw_signals + raw_array diff --git a/app/services/google_maps/records_storage_importer.rb b/app/services/google_maps/records_storage_importer.rb index 76a7673f..48a7c00c 100644 --- a/app/services/google_maps/records_storage_importer.rb +++ b/app/services/google_maps/records_storage_importer.rb @@ -5,8 +5,6 @@ class GoogleMaps::RecordsStorageImporter BATCH_SIZE = 1000 - MAX_RETRIES = 3 - DOWNLOAD_TIMEOUT = 300 # 5 minutes timeout def initialize(import, user_id) @import = import @@ -25,54 +23,13 @@ class GoogleMaps::RecordsStorageImporter attr_reader :import, :user def process_file_in_batches - file = download_file - verify_file_integrity(file) - locations = parse_file(file) + file_content = SecureFileDownloader.new(import.file).download_with_verification + locations = parse_file(file_content) process_locations_in_batches(locations) if locations.present? end - def download_file - retries = 0 - - begin - Timeout.timeout(DOWNLOAD_TIMEOUT) do - import.file.download - end - rescue Timeout::Error => e - retries += 1 - if retries <= MAX_RETRIES - Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}") - retry - else - Rails.logger.error("Download failed after #{MAX_RETRIES} attempts") - raise - end - rescue StandardError => e - Rails.logger.error("Download error: #{e.message}") - raise - end - end - - def verify_file_integrity(file) - # Verify file size - expected_size = import.file.blob.byte_size - actual_size = file.size - - if expected_size != actual_size - raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes" - end - - # Verify checksum - expected_checksum = import.file.blob.checksum - actual_checksum = Base64.strict_encode64(Digest::MD5.digest(file)) - - return unless expected_checksum != actual_checksum - - raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}" - end - - def parse_file(file) - parsed_file = Oj.load(file, mode: :compat) + def parse_file(file_content) + parsed_file = Oj.load(file_content, mode: :compat) return nil unless parsed_file.is_a?(Hash) && parsed_file['locations'] parsed_file['locations'] diff --git a/app/services/secure_file_downloader.rb b/app/services/secure_file_downloader.rb new file mode 100644 index 00000000..042b0461 --- /dev/null +++ b/app/services/secure_file_downloader.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class SecureFileDownloader + DOWNLOAD_TIMEOUT = 300 # 5 minutes timeout + MAX_RETRIES = 3 + + def initialize(storage_attachment) + @storage_attachment = storage_attachment + end + + def download_with_verification + retries = 0 + file_content = nil + + begin + Timeout.timeout(DOWNLOAD_TIMEOUT) do + # Download the file to a string + tempfile = Tempfile.new("download_#{Time.now.to_i}") + begin + # Try to download block-by-block + storage_attachment.download do |chunk| + tempfile.write(chunk) + end + tempfile.rewind + file_content = tempfile.read + ensure + tempfile.close + tempfile.unlink + end + + # If we didn't get any content but no error occurred, try a different approach + if file_content.nil? || file_content.empty? + Rails.logger.warn('No content received from block download, trying alternative method') + # Some ActiveStorage attachments may work differently, try direct access if possible + file_content = storage_attachment.blob.download + end + end + rescue Timeout::Error => e + retries += 1 + if retries <= MAX_RETRIES + Rails.logger.warn("Download timeout, attempt #{retries} of #{MAX_RETRIES}") + retry + else + Rails.logger.error("Download failed after #{MAX_RETRIES} attempts") + raise + end + rescue StandardError => e + Rails.logger.error("Download error: #{e.message}") + raise + end + + raise 'Download completed but no content was received' if file_content.nil? || file_content.empty? + + verify_file_integrity(file_content) + file_content + end + + private + + attr_reader :storage_attachment + + def verify_file_integrity(file_content) + return if file_content.nil? || file_content.empty? + + # Verify file size + expected_size = storage_attachment.blob.byte_size + actual_size = file_content.bytesize + + if expected_size != actual_size + raise "Incomplete download: expected #{expected_size} bytes, got #{actual_size} bytes" + end + + # Verify checksum + expected_checksum = storage_attachment.blob.checksum + actual_checksum = Base64.strict_encode64(Digest::MD5.digest(file_content)) + + return unless expected_checksum != actual_checksum + + raise "Checksum mismatch: expected #{expected_checksum}, got #{actual_checksum}" + end +end diff --git a/app/services/subscription/decode_jwt_token.rb b/app/services/subscription/decode_jwt_token.rb index 40a97fae..504629b0 100644 --- a/app/services/subscription/decode_jwt_token.rb +++ b/app/services/subscription/decode_jwt_token.rb @@ -9,7 +9,7 @@ class Subscription::DecodeJwtToken # @return [Visit, nil] The merged visit or nil if merge failed def call JWT.decode( - token, + @token, ENV['JWT_SECRET_KEY'], true, { algorithm: 'HS256' } diff --git a/app/views/settings/_navigation.html.erb b/app/views/settings/_navigation.html.erb index 40ec1ddb..4201daed 100644 --- a/app/views/settings/_navigation.html.erb +++ b/app/views/settings/_navigation.html.erb @@ -6,6 +6,6 @@ <% end %> <%= link_to 'Map', settings_maps_path, role: 'tab', class: "tab #{active_tab?(settings_maps_path)}" %> <% if !DawarichSettings.self_hosted? %> - <%= link_to 'Subscriptions', settings_subscriptions_path, role: 'tab', class: "tab #{active_tab?(settings_subscriptions_path)}" %> + <%= link_to 'Subscriptions', "#{MANAGER_URL}/auth/dawarich?token=#{current_user.generate_subscription_token}", role: 'tab', class: "tab" %> <% end %> diff --git a/app/views/shared/_navbar.html.erb b/app/views/shared/_navbar.html.erb index 9ec36f26..fa0c2555 100644 --- a/app/views/shared/_navbar.html.erb +++ b/app/views/shared/_navbar.html.erb @@ -24,7 +24,7 @@ <% end %> - <%= link_to 'Dawarich', root_path, class: 'btn btn-ghost normal-case text-xl'%> + <%= link_to 'Dawarichα'.html_safe, root_path, class: 'btn btn-ghost normal-case text-xl'%>
<% if new_version_available? %> diff --git a/spec/services/google_maps/records_storage_importer_spec.rb b/spec/services/google_maps/records_storage_importer_spec.rb index 2cc6e23b..98ed6c99 100644 --- a/spec/services/google_maps/records_storage_importer_spec.rb +++ b/spec/services/google_maps/records_storage_importer_spec.rb @@ -146,9 +146,17 @@ RSpec.describe GoogleMaps::RecordsStorageImporter do context 'with download issues' do it 'retries on timeout' do call_count = 0 - allow(import.file).to receive(:download) do + + # Mock the SecureFileDownloader instead of the file's download method + mock_downloader = instance_double(SecureFileDownloader) + allow(SecureFileDownloader).to receive(:new).and_return(mock_downloader) + + # Set up the mock to raise timeout twice then return content + allow(mock_downloader).to receive(:download_with_verification) do call_count += 1 - call_count < 3 ? raise(Timeout::Error) : file_content + raise Timeout::Error if call_count < 3 + + file_content end expect(Rails.logger).to receive(:warn).twice diff --git a/spec/services/secure_file_downloader_spec.rb b/spec/services/secure_file_downloader_spec.rb new file mode 100644 index 00000000..14c7d634 --- /dev/null +++ b/spec/services/secure_file_downloader_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe SecureFileDownloader do + let(:file_content) { 'test content' } + let(:file_size) { file_content.bytesize } + let(:checksum) { Base64.strict_encode64(Digest::MD5.digest(file_content)) } + let(:blob) { double('ActiveStorage::Blob', byte_size: file_size, checksum: checksum) } + # Create a mock that mimics ActiveStorage::Attached::One + let(:storage_attachment) { double('ActiveStorage::Attached::One', blob: blob) } + + subject { described_class.new(storage_attachment) } + + describe '#download_with_verification' do + context 'when download is successful' do + before do + # Mock the download method to yield the file content + allow(storage_attachment).to receive(:download) do |&block| + block.call(file_content) + end + end + + it 'returns the file content' do + expect(subject.download_with_verification).to eq(file_content) + end + end + + context 'when timeout occurs but succeeds on retry' do + before do + call_count = 0 + allow(storage_attachment).to receive(:download) do |&block| + call_count += 1 + raise Timeout::Error if call_count == 1 + + block.call(file_content) + end + end + + it 'retries the download and returns the file content' do + expect(Rails.logger).to receive(:warn).with(/Download timeout, attempt 1 of/) + expect(subject.download_with_verification).to eq(file_content) + end + end + + context 'when all download attempts timeout' do + before do + allow(storage_attachment).to receive(:download).and_raise(Timeout::Error) + end + + it 'raises an error after max retries' do + described_class::MAX_RETRIES.times do |i| + expect(Rails.logger).to receive(:warn).with(/Download timeout, attempt #{i + 1} of/) + end + expect(Rails.logger).to receive(:error).with(/Download failed after/) + expect { subject.download_with_verification }.to raise_error(Timeout::Error) + end + end + + context 'when file size does not match' do + let(:blob) { double('ActiveStorage::Blob', byte_size: 100, checksum: checksum) } + + before do + allow(storage_attachment).to receive(:download) do |&block| + block.call(file_content) + end + end + + it 'raises an error' do + expect { subject.download_with_verification }.to raise_error(/Incomplete download/) + end + end + + context 'when checksum does not match' do + let(:blob) { double('ActiveStorage::Blob', byte_size: file_size, checksum: 'invalid_checksum') } + + before do + allow(storage_attachment).to receive(:download) do |&block| + block.call(file_content) + end + end + + it 'raises an error' do + expect { subject.download_with_verification }.to raise_error(/Checksum mismatch/) + end + end + + context 'when download fails with a different error' do + before do + allow(storage_attachment).to receive(:download).and_raise(StandardError, 'Download failed') + end + + it 'logs the error and re-raises it' do + expect(Rails.logger).to receive(:error).with(/Download error: Download failed/) + expect { subject.download_with_verification }.to raise_error(StandardError, 'Download failed') + end + end + end +end