+ <% @trip.visited_countries.sort.each do |country| %>
+
<%= country %>
+ <% end %>
+
+
+
+ <% end %>
+
+
+ <% if @trip.share_notes? && @trip.notes.present? %>
+
+
+
About This Trip
+
+ <%= @trip.notes.body %>
+
+
+
+ <% end %>
+
+
+ <% if @trip.share_photos? && @photo_previews.any? %>
+
+
+
Photos
+
+ <% @photo_previews.first(8).each do |photo| %>
+
+
+
+ <% end %>
+
+ <% if @photo_previews.count > 8 %>
+
+ +<%= @photo_previews.count - 8 %> more photos
+
+ <% end %>
+
+
+ <% end %>
+
+
+
+
+
+
+ Powered by <%= link_to "Dawarich", "https://dawarich.app", class: "link link-primary", target: "_blank" %>, your personal memories mapper.
+
+
+
diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb
index 023ccf42..07f45639 100644
--- a/app/views/trips/show.html.erb
+++ b/app/views/trips/show.html.erb
@@ -64,4 +64,7 @@
<%= link_to "Back to trips", trips_path, class: "btn" %>
+
+
+ <%= render "trips/sharing", trip: @trip %>
diff --git a/config/routes.rb b/config/routes.rb
index d34aa775..75fd8b2a 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -90,12 +90,16 @@ Rails.application.routes.draw do
as: :update_year_month_stats,
constraints: { year: /\d{4}/, month: /\d{1,2}|all/ }
get 'shared/month/:uuid', to: 'shared/stats#show', as: :shared_stat
+ get 'shared/trips/:trip_uuid', to: 'shared/trips#show', as: :shared_trip
- # Sharing management endpoint (requires auth)
+ # Sharing management endpoints (require auth)
patch 'stats/:year/:month/sharing',
to: 'shared/stats#update',
as: :sharing_stats,
constraints: { year: /\d{4}/, month: /\d{1,2}/ }
+ patch 'trips/:id/sharing',
+ to: 'shared/trips#update',
+ as: :sharing_trip
root to: 'home#index'
diff --git a/db/migrate/20251105153510_add_sharing_to_trips.rb b/db/migrate/20251105153510_add_sharing_to_trips.rb
new file mode 100644
index 00000000..992e623a
--- /dev/null
+++ b/db/migrate/20251105153510_add_sharing_to_trips.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+class AddSharingToTrips < ActiveRecord::Migration[8.0]
+ def change
+ add_column :trips, :sharing_uuid, :uuid
+ add_column :trips, :sharing_settings, :jsonb, default: {}
+ add_index :trips, :sharing_uuid, unique: true
+ end
+end
diff --git a/spec/models/concerns/shareable_spec.rb b/spec/models/concerns/shareable_spec.rb
new file mode 100644
index 00000000..cc97bc7c
--- /dev/null
+++ b/spec/models/concerns/shareable_spec.rb
@@ -0,0 +1,213 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe Shareable do
+ let(:user) { create(:user) }
+ let(:trip) do
+ create(:trip, user: user, name: 'Test Trip',
+ started_at: 1.week.ago, ended_at: Time.current)
+ end
+
+ describe '#generate_sharing_uuid' do
+ it 'generates a UUID before create' do
+ new_trip = build(:trip, user: user)
+ expect(new_trip.sharing_uuid).to be_nil
+ new_trip.save!
+ expect(new_trip.sharing_uuid).to be_present
+ expect(new_trip.sharing_uuid).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
+ end
+ end
+
+ describe '#sharing_enabled?' do
+ it 'returns false by default' do
+ expect(trip.sharing_enabled?).to be false
+ end
+
+ it 'returns true when enabled' do
+ trip.update!(sharing_settings: { 'enabled' => true })
+ expect(trip.sharing_enabled?).to be true
+ end
+
+ it 'returns false when disabled' do
+ trip.update!(sharing_settings: { 'enabled' => false })
+ expect(trip.sharing_enabled?).to be false
+ end
+ end
+
+ describe '#sharing_expired?' do
+ it 'returns false when no expiration is set' do
+ expect(trip.sharing_expired?).to be false
+ end
+
+ it 'returns false when expires_at is in the future' do
+ trip.update!(sharing_settings: {
+ 'expiration' => '24h',
+ 'expires_at' => 1.hour.from_now.iso8601
+ })
+ expect(trip.sharing_expired?).to be false
+ end
+
+ it 'returns true when expires_at is in the past' do
+ trip.update!(sharing_settings: {
+ 'expiration' => '1h',
+ 'expires_at' => 1.hour.ago.iso8601
+ })
+ expect(trip.sharing_expired?).to be true
+ end
+
+ it 'returns true when expiration is set but expires_at is missing' do
+ trip.update!(sharing_settings: {
+ 'expiration' => '24h',
+ 'expires_at' => nil
+ })
+ expect(trip.sharing_expired?).to be true
+ end
+ end
+
+ describe '#public_accessible?' do
+ it 'returns false by default' do
+ expect(trip.public_accessible?).to be false
+ end
+
+ it 'returns true when enabled and not expired' do
+ trip.update!(sharing_settings: {
+ 'enabled' => true,
+ 'expiration' => '24h',
+ 'expires_at' => 1.hour.from_now.iso8601
+ })
+ expect(trip.public_accessible?).to be true
+ end
+
+ it 'returns false when enabled but expired' do
+ trip.update!(sharing_settings: {
+ 'enabled' => true,
+ 'expiration' => '1h',
+ 'expires_at' => 1.hour.ago.iso8601
+ })
+ expect(trip.public_accessible?).to be false
+ end
+
+ it 'returns false when disabled' do
+ trip.update!(sharing_settings: {
+ 'enabled' => false,
+ 'expiration' => '24h',
+ 'expires_at' => 1.hour.from_now.iso8601
+ })
+ expect(trip.public_accessible?).to be false
+ end
+ end
+
+ describe '#enable_sharing!' do
+ it 'enables sharing with default 24h expiration' do
+ trip.enable_sharing!
+ expect(trip.sharing_enabled?).to be true
+ expect(trip.sharing_settings['expiration']).to eq('24h')
+ expect(trip.sharing_settings['expires_at']).to be_present
+ end
+
+ it 'enables sharing with custom expiration' do
+ trip.enable_sharing!(expiration: '1h')
+ expect(trip.sharing_enabled?).to be true
+ expect(trip.sharing_settings['expiration']).to eq('1h')
+ end
+
+ it 'enables sharing with permanent expiration' do
+ trip.enable_sharing!(expiration: 'permanent')
+ expect(trip.sharing_enabled?).to be true
+ expect(trip.sharing_settings['expiration']).to eq('permanent')
+ expect(trip.sharing_settings['expires_at']).to be_nil
+ end
+
+ it 'defaults to 24h for invalid expiration' do
+ trip.enable_sharing!(expiration: 'invalid')
+ expect(trip.sharing_settings['expiration']).to eq('24h')
+ end
+
+ it 'stores additional options like share_notes' do
+ trip.enable_sharing!(expiration: '24h', share_notes: true)
+ expect(trip.sharing_settings['share_notes']).to be true
+ end
+
+ it 'stores additional options like share_photos' do
+ trip.enable_sharing!(expiration: '24h', share_photos: true)
+ expect(trip.sharing_settings['share_photos']).to be true
+ end
+
+ it 'generates a sharing_uuid if not present' do
+ trip.update_column(:sharing_uuid, nil)
+ trip.enable_sharing!
+ expect(trip.sharing_uuid).to be_present
+ end
+
+ it 'keeps existing sharing_uuid' do
+ original_uuid = trip.sharing_uuid
+ trip.enable_sharing!
+ expect(trip.sharing_uuid).to eq(original_uuid)
+ end
+ end
+
+ describe '#disable_sharing!' do
+ before do
+ trip.enable_sharing!(expiration: '24h')
+ end
+
+ it 'disables sharing' do
+ trip.disable_sharing!
+ expect(trip.sharing_enabled?).to be false
+ end
+
+ it 'clears expiration settings' do
+ trip.disable_sharing!
+ expect(trip.sharing_settings['expiration']).to be_nil
+ expect(trip.sharing_settings['expires_at']).to be_nil
+ end
+
+ it 'keeps the sharing_uuid' do
+ original_uuid = trip.sharing_uuid
+ trip.disable_sharing!
+ expect(trip.sharing_uuid).to eq(original_uuid)
+ end
+ end
+
+ describe '#generate_new_sharing_uuid!' do
+ it 'generates a new UUID' do
+ original_uuid = trip.sharing_uuid
+ trip.generate_new_sharing_uuid!
+ expect(trip.sharing_uuid).not_to eq(original_uuid)
+ expect(trip.sharing_uuid).to be_present
+ end
+ end
+
+ describe '#share_notes?' do
+ it 'returns false by default' do
+ expect(trip.share_notes?).to be false
+ end
+
+ it 'returns true when share_notes is enabled' do
+ trip.update!(sharing_settings: { 'share_notes' => true })
+ expect(trip.share_notes?).to be true
+ end
+
+ it 'returns false when share_notes is disabled' do
+ trip.update!(sharing_settings: { 'share_notes' => false })
+ expect(trip.share_notes?).to be false
+ end
+ end
+
+ describe '#share_photos?' do
+ it 'returns false by default' do
+ expect(trip.share_photos?).to be false
+ end
+
+ it 'returns true when share_photos is enabled' do
+ trip.update!(sharing_settings: { 'share_photos' => true })
+ expect(trip.share_photos?).to be true
+ end
+
+ it 'returns false when share_photos is disabled' do
+ trip.update!(sharing_settings: { 'share_photos' => false })
+ expect(trip.share_photos?).to be false
+ end
+ end
+end
diff --git a/spec/models/trip_spec.rb b/spec/models/trip_spec.rb
index 8c46a65a..e42f0573 100644
--- a/spec/models/trip_spec.rb
+++ b/spec/models/trip_spec.rb
@@ -154,4 +154,44 @@ RSpec.describe Trip, type: :model do
end
end
end
+
+ describe 'Shareable concern' do
+ let(:user) { create(:user) }
+ let(:trip) { create(:trip, user: user) }
+
+ describe 'sharing_uuid generation' do
+ it 'generates a sharing_uuid on create' do
+ new_trip = build(:trip, user: user)
+ expect(new_trip.sharing_uuid).to be_nil
+ new_trip.save!
+ expect(new_trip.sharing_uuid).to be_present
+ end
+ end
+
+ describe '#public_accessible?' do
+ it 'returns false by default' do
+ expect(trip.public_accessible?).to be false
+ end
+
+ it 'returns true when sharing is enabled and not expired' do
+ trip.enable_sharing!(expiration: '24h')
+ expect(trip.public_accessible?).to be true
+ end
+
+ it 'returns false when sharing is disabled' do
+ trip.enable_sharing!(expiration: '24h')
+ trip.disable_sharing!
+ expect(trip.public_accessible?).to be false
+ end
+ end
+
+ describe '#enable_sharing!' do
+ it 'enables sharing with notes and photos options' do
+ trip.enable_sharing!(expiration: '24h', share_notes: true, share_photos: true)
+ expect(trip.sharing_enabled?).to be true
+ expect(trip.share_notes?).to be true
+ expect(trip.share_photos?).to be true
+ end
+ end
+ end
end
diff --git a/spec/policies/trip_policy_spec.rb b/spec/policies/trip_policy_spec.rb
new file mode 100644
index 00000000..ba495f11
--- /dev/null
+++ b/spec/policies/trip_policy_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe TripPolicy, type: :policy do
+ let(:user) { create(:user) }
+ let(:other_user) { create(:user, email: 'other@example.com') }
+ let(:trip) { create(:trip, user: user, name: 'My Trip') }
+ let(:other_trip) { create(:trip, user: other_user, name: 'Other Trip') }
+
+ describe 'show?' do
+ it 'allows users to view their own trips' do
+ policy = TripPolicy.new(user, trip)
+
+ expect(policy).to permit(:show)
+ end
+
+ it 'denies users from viewing other users trips by default' do
+ policy = TripPolicy.new(user, other_trip)
+
+ expect(policy).not_to permit(:show)
+ end
+
+ it 'allows anyone to view publicly shared trips' do
+ other_trip.enable_sharing!(expiration: '24h')
+ policy = TripPolicy.new(user, other_trip)
+
+ expect(policy).to permit(:show)
+ end
+
+ it 'allows unauthenticated users to view publicly shared trips' do
+ trip.enable_sharing!(expiration: '24h')
+ policy = TripPolicy.new(nil, trip)
+
+ expect(policy).to permit(:show)
+ end
+
+ it 'denies access to expired shared trips' do
+ other_trip.update!(sharing_settings: {
+ 'enabled' => true,
+ 'expiration' => '1h',
+ 'expires_at' => 2.hours.ago.iso8601
+ })
+ policy = TripPolicy.new(user, other_trip)
+
+ expect(policy).not_to permit(:show)
+ end
+
+ it 'denies unauthenticated users from viewing private trips' do
+ policy = TripPolicy.new(nil, trip)
+
+ expect(policy).not_to permit(:show)
+ end
+ end
+
+ describe 'create?' do
+ it 'allows authenticated users to create trips' do
+ policy = TripPolicy.new(user, Trip.new)
+
+ expect(policy).to permit(:create)
+ end
+
+ it 'denies unauthenticated users from creating trips' do
+ policy = TripPolicy.new(nil, Trip.new)
+
+ expect(policy).not_to permit(:create)
+ end
+ end
+
+ describe 'update?' do
+ it 'allows users to update their own trips' do
+ policy = TripPolicy.new(user, trip)
+
+ expect(policy).to permit(:update)
+ end
+
+ it 'denies users from updating other users trips' do
+ policy = TripPolicy.new(user, other_trip)
+
+ expect(policy).not_to permit(:update)
+ end
+
+ it 'denies unauthenticated users from updating trips' do
+ policy = TripPolicy.new(nil, trip)
+
+ expect(policy).not_to permit(:update)
+ end
+ end
+
+ describe 'destroy?' do
+ it 'allows users to destroy their own trips' do
+ policy = TripPolicy.new(user, trip)
+
+ expect(policy).to permit(:destroy)
+ end
+
+ it 'denies users from destroying other users trips' do
+ policy = TripPolicy.new(user, other_trip)
+
+ expect(policy).not_to permit(:destroy)
+ end
+
+ it 'denies unauthenticated users from destroying trips' do
+ policy = TripPolicy.new(nil, trip)
+
+ expect(policy).not_to permit(:destroy)
+ end
+ end
+
+ describe 'update_sharing?' do
+ it 'allows users to update sharing settings for their own trips' do
+ policy = TripPolicy.new(user, trip)
+
+ expect(policy).to permit(:update_sharing)
+ end
+
+ it 'denies users from updating sharing settings for other users trips' do
+ policy = TripPolicy.new(user, other_trip)
+
+ expect(policy).not_to permit(:update_sharing)
+ end
+
+ it 'denies unauthenticated users from updating sharing settings' do
+ policy = TripPolicy.new(nil, trip)
+
+ expect(policy).not_to permit(:update_sharing)
+ end
+ end
+
+ describe 'Scope' do
+ let!(:user_trip1) { create(:trip, user: user, name: 'Trip 1') }
+ let!(:user_trip2) { create(:trip, user: user, name: 'Trip 2') }
+ let!(:other_user_trip) { create(:trip, user: other_user, name: 'Other Trip') }
+
+ it 'returns only the users trips' do
+ scope = TripPolicy::Scope.new(user, Trip).resolve
+
+ expect(scope).to contain_exactly(user_trip1, user_trip2)
+ expect(scope).not_to include(other_user_trip)
+ end
+
+ it 'returns no trips for unauthenticated users' do
+ scope = TripPolicy::Scope.new(nil, Trip).resolve
+
+ expect(scope).to be_empty
+ end
+ end
+end
diff --git a/spec/requests/shared/trips_spec.rb b/spec/requests/shared/trips_spec.rb
new file mode 100644
index 00000000..a944aef4
--- /dev/null
+++ b/spec/requests/shared/trips_spec.rb
@@ -0,0 +1,240 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'Shared::Trips', type: :request do
+ before do
+ stub_request(:any, 'https://api.github.com/repos/Freika/dawarich/tags')
+ .to_return(status: 200, body: '[{"name": "1.0.0"}]', headers: {})
+ end
+
+ context 'public sharing' do
+ let(:user) { create(:user) }
+ let(:trip) do
+ create(:trip, user: user, name: 'Test Trip',
+ started_at: 1.week.ago, ended_at: Time.current)
+ end
+
+ describe 'GET /shared/trips/:trip_uuid' do
+ context 'with valid sharing UUID' do
+ before do
+ trip.enable_sharing!(expiration: '24h', share_notes: true, share_photos: false)
+ # Create some points for the trip
+ create_list(:point, 5, user: user, timestamp: trip.started_at.to_i + 1.hour)
+ end
+
+ it 'renders the public trip view' do
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Test Trip')
+ end
+
+ it 'includes required content in response' do
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response.body).to include('Test Trip')
+ expect(response.body).to include('Trip Route')
+ expect(response.body).to include('data-controller="public-trip-map"')
+ expect(response.body).to include(trip.sharing_uuid)
+ end
+
+ it 'displays notes when share_notes is true' do
+ trip.update(notes: 'This is a test trip')
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response.body).to include('About This Trip')
+ expect(response.body).to include('This is a test trip')
+ end
+
+ it 'does not display notes when share_notes is false' do
+ trip.disable_sharing!
+ trip.enable_sharing!(expiration: '24h', share_notes: false)
+ trip.update(notes: 'This is a test trip')
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response.body).not_to include('About This Trip')
+ end
+ end
+
+ context 'with invalid sharing UUID' do
+ it 'redirects to root with alert' do
+ get shared_trip_url('invalid-uuid')
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared trip not found or no longer available')
+ end
+ end
+
+ context 'with expired sharing' do
+ before do
+ trip.update!(sharing_settings: {
+ 'enabled' => true,
+ 'expiration' => '1h',
+ 'expires_at' => 2.hours.ago.iso8601
+ })
+ end
+
+ it 'redirects to root with alert' do
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared trip not found or no longer available')
+ end
+ end
+
+ context 'with disabled sharing' do
+ before do
+ trip.enable_sharing!(expiration: '24h')
+ trip.disable_sharing!
+ end
+
+ it 'redirects to root with alert' do
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response).to redirect_to(root_path)
+ expect(flash[:alert]).to eq('Shared trip not found or no longer available')
+ end
+ end
+
+ context 'when trip has no path data' do
+ before do
+ trip.enable_sharing!(expiration: '24h')
+ trip.update_column(:path, nil)
+ end
+
+ it 'renders successfully with placeholder' do
+ get shared_trip_url(trip.sharing_uuid)
+
+ expect(response).to have_http_status(:success)
+ expect(response.body).to include('Route data not yet calculated')
+ end
+ end
+ end
+
+ describe 'PATCH /trips/:id/sharing' do
+ context 'when user is signed in' do
+ before { sign_in user }
+
+ context 'enabling sharing' do
+ it 'enables sharing and returns success' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '1', expiration: '24h' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be(true)
+ expect(json_response['sharing_url']).to be_present
+ expect(json_response['message']).to eq('Sharing enabled successfully')
+
+ trip.reload
+ expect(trip.sharing_enabled?).to be(true)
+ expect(trip.sharing_uuid).to be_present
+ end
+
+ it 'enables sharing with notes option' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '1', expiration: '24h', share_notes: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ trip.reload
+ expect(trip.sharing_enabled?).to be(true)
+ expect(trip.share_notes?).to be(true)
+ end
+
+ it 'enables sharing with photos option' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '1', expiration: '24h', share_photos: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ trip.reload
+ expect(trip.sharing_enabled?).to be(true)
+ expect(trip.share_photos?).to be(true)
+ end
+
+ it 'sets custom expiration when provided' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '1', expiration: '1h' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ trip.reload
+ expect(trip.sharing_enabled?).to be(true)
+ expect(trip.sharing_settings['expiration']).to eq('1h')
+ end
+
+ it 'enables permanent sharing' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '1', expiration: 'permanent' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+ trip.reload
+ expect(trip.sharing_settings['expiration']).to eq('permanent')
+ expect(trip.sharing_settings['expires_at']).to be_nil
+ end
+ end
+
+ context 'disabling sharing' do
+ before do
+ trip.enable_sharing!(expiration: '24h')
+ end
+
+ it 'disables sharing and returns success' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '0' },
+ as: :json
+
+ expect(response).to have_http_status(:success)
+
+ json_response = JSON.parse(response.body)
+ expect(json_response['success']).to be(true)
+ expect(json_response['message']).to eq('Sharing disabled successfully')
+
+ trip.reload
+ expect(trip.sharing_enabled?).to be(false)
+ end
+ end
+
+ context 'when trip does not exist' do
+ it 'returns not found' do
+ patch sharing_trip_path(id: 999999),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+
+ context 'when user is not the trip owner' do
+ let(:other_user) { create(:user, email: 'other@example.com') }
+ let(:other_trip) { create(:trip, user: other_user, name: 'Other Trip') }
+
+ it 'returns not found' do
+ patch sharing_trip_path(other_trip),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:not_found)
+ end
+ end
+ end
+
+ context 'when user is not signed in' do
+ it 'returns unauthorized' do
+ patch sharing_trip_path(trip),
+ params: { enabled: '1' },
+ as: :json
+
+ expect(response).to have_http_status(:unauthorized)
+ end
+ end
+ end
+ end
+end