diff --git a/Gemfile b/Gemfile
index 9cbdba2f7c1..c5ed5b0ec77 100644
--- a/Gemfile
+++ b/Gemfile
@@ -67,6 +67,7 @@ gem "rails_param"
gem "rinku", ">= 2.0.6", :require => "rails_rinku"
gem "strong_migrations"
gem "validates_email_format_of", ">= 1.5.1"
+gem "validate_url"
# Native OSM extensions
gem "quad_tile", "~> 1.0.1"
diff --git a/Gemfile.lock b/Gemfile.lock
index 01055f57256..a8f24f3e6da 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -581,6 +581,9 @@ GEM
unf_ext (0.0.9.1)
unicode-display_width (2.5.0)
uri (0.13.0)
+ validate_url (1.0.15)
+ activemodel (>= 3.0.0)
+ public_suffix
validates_email_format_of (1.8.2)
i18n (>= 0.8.0)
simpleidn
@@ -700,6 +703,7 @@ DEPENDENCIES
terser
turbo-rails
unicode-display_width
+ validate_url
validates_email_format_of (>= 1.5.1)
vendorer
webmock
diff --git a/app/abilities/ability.rb b/app/abilities/ability.rb
index 04c0e5ffe39..35a7daa0ba1 100644
--- a/app/abilities/ability.rb
+++ b/app/abilities/ability.rb
@@ -17,9 +17,10 @@ def initialize(user)
if Settings.status != "database_offline"
can [:index, :feed, :show], Changeset
can :index, ChangesetComment
+ can [:index, :show], Community
+ can [:index], CommunityLink
can [:confirm, :confirm_resend, :confirm_email], :confirmation
can [:index, :rss, :show, :comments], DiaryEntry
- can [:index, :show], Community
can [:index], Note
can [:new, :create, :edit, :update], :password
can [:index, :show], Redaction
@@ -47,6 +48,7 @@ def initialize(user)
can [:new, :create, :reply, :show, :inbox, :outbox, :muted, :mark, :unmute, :destroy], Message
can [:create, :new], Community
can [:edit, :update], Community, { :organizer_id => user.id }
+ can [:edit, :create, :destroy, :new, :update], CommunityLink, { :community => { :organizer_id => user.id } }
can [:close, :reopen], Note
can [:show, :edit, :update], :preference
can [:edit, :update], :profile
diff --git a/app/assets/stylesheets/communities.scss b/app/assets/stylesheets/communities.scss
index f25fb70fd3e..7b6bd73c4e3 100644
--- a/app/assets/stylesheets/communities.scss
+++ b/app/assets/stylesheets/communities.scss
@@ -9,10 +9,4 @@
label {
font-weight: bold;
}
- ul {
- display: inline-block;
- }
- ul > li {
- display: inline-block;
- }
}
diff --git a/app/controllers/community_links_controller.rb b/app/controllers/community_links_controller.rb
new file mode 100644
index 00000000000..87874ec55ab
--- /dev/null
+++ b/app/controllers/community_links_controller.rb
@@ -0,0 +1,61 @@
+class CommunityLinksController < ApplicationController
+ layout "site"
+ before_action :authorize_web
+
+ before_action :set_link, :only => [:destroy, :edit, :update]
+
+ load_and_authorize_resource :except => [:create, :new]
+ authorize_resource
+
+ def index
+ @community = Community.friendly.find(params[:community_id])
+ @links = @community.community_links
+ end
+
+ def new
+ return "missing parameter community_id" unless params.key?(:community_id)
+
+ @community = Community.friendly.find(params[:community_id])
+ @title = t ".title"
+ @link = CommunityLink.new
+ @link.community_id = params[:community_id]
+ end
+
+ def edit; end
+
+ def create
+ @community = Community.friendly.find(params[:community_id])
+ @link = @community.community_links.build(link_params)
+ if @link.save
+ response.set_header("link_id", @link.id) # for testing
+ redirect_to @link.community, :notice => t(".success")
+ else
+ render "new"
+ end
+ end
+
+ def update
+ if @link.update(link_params)
+ redirect_to @link.community, :notice => t(".success")
+ else
+ flash.now[:alert] = t(".failure")
+ render :edit
+ end
+ end
+
+ def destroy
+ community_id = @link.community_id
+ @link.delete
+ redirect_to community_path(community_id)
+ end
+
+ private
+
+ def set_link
+ @link = CommunityLink.find(params[:id])
+ end
+
+ def link_params
+ params.require(:community_link).permit(:community_id, :site, :url)
+ end
+end
diff --git a/app/models/community.rb b/app/models/community.rb
index f64f25bcc57..a76864c51b4 100644
--- a/app/models/community.rb
+++ b/app/models/community.rb
@@ -25,6 +25,7 @@ class Community < ApplicationRecord
friendly_id :name, :use => :slugged
belongs_to :organizer, :class_name => "User"
+ has_many :community_links
validates :name, :presence => true, :length => 1..255, :characters => true
validates :description, :presence => true, :length => 1..1023, :characters => true
diff --git a/app/models/community_link.rb b/app/models/community_link.rb
new file mode 100644
index 00000000000..95e7f1bba9b
--- /dev/null
+++ b/app/models/community_link.rb
@@ -0,0 +1,21 @@
+# == Schema Information
+#
+# Table name: community_links
+#
+# id :bigint(8) not null, primary key
+# community_id :integer not null
+# site :string not null
+# url :string not null
+# created_at :datetime not null
+# updated_at :datetime not null
+#
+# Indexes
+#
+# index_community_links_on_community_id (community_id)
+#
+
+class CommunityLink < ApplicationRecord
+ belongs_to :community
+ validates :site, :presence => true, :length => 1..255, :characters => true
+ validates :url, :presence => true, :length => 1..255, :url => { :schemes => ["https"] }
+end
diff --git a/app/views/communities/show.html.erb b/app/views/communities/show.html.erb
index e0505b9283a..86b3ac26569 100644
--- a/app/views/communities/show.html.erb
+++ b/app/views/communities/show.html.erb
@@ -29,10 +29,36 @@
<%= auto_link @community.description %>
+
+
+
+ <% @community.community_links.each do |link| %>
+ -
+ <%= link.site %>
+
+ <% end %>
+
+
<%= link_to @community.organizer.display_name, user_path(@community.organizer) %>
+
+
+
+ <% @community.community_links.each do |link| %>
+ -
+ <%= link.site %>
+
+ <% end %>
+
+
diff --git a/app/views/community_links/_form.html.erb b/app/views/community_links/_form.html.erb
new file mode 100644
index 00000000000..97f8ec220ac
--- /dev/null
+++ b/app/views/community_links/_form.html.erb
@@ -0,0 +1,5 @@
+<%= bootstrap_form_for [@community, @link] do |form| %>
+ <%= form.text_field :site %>
+ <%= form.text_field :url %>
+ <%= form.primary %>
+<% end %>
diff --git a/app/views/community_links/edit.html.erb b/app/views/community_links/edit.html.erb
new file mode 100644
index 00000000000..f2d508679a4
--- /dev/null
+++ b/app/views/community_links/edit.html.erb
@@ -0,0 +1,3 @@
+
<%= t(".edit_community_link") %>
+
+<%= render "form", :link => @link %>
diff --git a/app/views/community_links/index.html.erb b/app/views/community_links/index.html.erb
new file mode 100644
index 00000000000..f2941ca60ff
--- /dev/null
+++ b/app/views/community_links/index.html.erb
@@ -0,0 +1,27 @@
+<% content_for :heading do %>
+
<%= t(".title") %>
+
+<% end %>
+
+<% if !@links.empty? %>
+
+
+ <% @links.each do |link| %>
+
+
+ <% link.url.slice! "https://" %> <%# prevent XSS %>
+ <%= link_to link.site, "https://#{link.url}" %>
+ |
+
+ <%= link_to t(".edit"), edit_community_link_path(link) %>
+ <%= link_to t(".delete"), community_link_path(link), :method => :delete %>
+ |
+
+ <% end %>
+
+
+<% end %>
diff --git a/app/views/community_links/new.html.erb b/app/views/community_links/new.html.erb
new file mode 100644
index 00000000000..7c2f12855e2
--- /dev/null
+++ b/app/views/community_links/new.html.erb
@@ -0,0 +1,5 @@
+<% content_for :heading do %>
+
<%= @title %>
+<% end %>
+
+<%= render "form", :link => @link %>
diff --git a/config/locales/en.yml b/config/locales/en.yml
index 812ac83ac2e..61ba92e77c0 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -56,6 +56,7 @@ en:
email_address_not_routable: is not routable
display_name_is_user_n: can't be user_n unless n is your user id
models:
+ url: "is not a valid secure URL"
user_mute:
attributes:
subject:
@@ -518,6 +519,22 @@ en:
title_particular: "OpenStreetMap changeset #%{changeset_id} discussion"
timeout:
sorry: "Sorry, the list of changeset comments you requested took too long to retrieve."
+ community_links:
+ create:
+ success: "Community Link was successfully created."
+ edit:
+ edit_community_link: "Edit Community Link"
+ index:
+ delete: "Delete"
+ edit: "Edit"
+ new: "New"
+ title: "Community Links"
+ new:
+ all: "All"
+ title: "New Community Link"
+ update:
+ failure: "The community link could not be updated."
+ success: "The community link was successfully updated."
communities:
create:
success: "Community was successfully created."
@@ -526,7 +543,6 @@ en:
index:
all: "All Communities"
communities_organized: "Communities Organized"
- longitude: "Longitude"
new: "New"
new_title: "Create a new community"
sorted_by: "Sorted by name"
@@ -534,7 +550,9 @@ en:
new:
title: "New Community"
show:
+ edit: "Edit"
header_title: "Community"
+ links: "Links"
organizer: "Organizer"
recent_changes: "Recent Changes"
report: "Report"
diff --git a/config/routes.rb b/config/routes.rb
index 0f4e788fb2d..3b1ac35f0d6 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -352,7 +352,10 @@
end
# communities
- resources :communities
+ resources :communities do
+ resources :community_links, :only => [:create, :index, :new]
+ end
+ resources :community_links, :only => [:destroy, :edit, :update]
# errors
match "/400", :to => "errors#bad_request", :via => :all
diff --git a/db/migrate/20240525143545_create_community_links.rb b/db/migrate/20240525143545_create_community_links.rb
new file mode 100644
index 00000000000..9fed2274f42
--- /dev/null
+++ b/db/migrate/20240525143545_create_community_links.rb
@@ -0,0 +1,11 @@
+class CreateCommunityLinks < ActiveRecord::Migration[7.0]
+ def change
+ create_table :community_links do |t|
+ t.references :community, :null => false, :foreign_key => true, :index => true
+ t.string :site, :null => false
+ t.string :url, :null => false
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index b051a5adb94..7a2c5ad7f07 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -501,6 +501,39 @@ CREATE SEQUENCE public.communities_id_seq
ALTER SEQUENCE public.communities_id_seq OWNED BY public.communities.id;
+--
+-- Name: community_links; Type: TABLE; Schema: public; Owner: -
+--
+
+CREATE TABLE public.community_links (
+ id bigint NOT NULL,
+ community_id bigint NOT NULL,
+ site character varying NOT NULL,
+ url character varying NOT NULL,
+ created_at timestamp(6) without time zone NOT NULL,
+ updated_at timestamp(6) without time zone NOT NULL
+);
+
+
+--
+-- Name: community_links_id_seq; Type: SEQUENCE; Schema: public; Owner: -
+--
+
+CREATE SEQUENCE public.community_links_id_seq
+ START WITH 1
+ INCREMENT BY 1
+ NO MINVALUE
+ NO MAXVALUE
+ CACHE 1;
+
+
+--
+-- Name: community_links_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: -
+--
+
+ALTER SEQUENCE public.community_links_id_seq OWNED BY public.community_links.id;
+
+
--
-- Name: current_node_tags; Type: TABLE; Schema: public; Owner: -
--
@@ -1756,6 +1789,13 @@ ALTER TABLE ONLY public.client_applications ALTER COLUMN id SET DEFAULT nextval(
ALTER TABLE ONLY public.communities ALTER COLUMN id SET DEFAULT nextval('public.communities_id_seq'::regclass);
+--
+-- Name: community_links id; Type: DEFAULT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.community_links ALTER COLUMN id SET DEFAULT nextval('public.community_links_id_seq'::regclass);
+
+
--
-- Name: current_nodes id; Type: DEFAULT; Schema: public; Owner: -
--
@@ -2025,6 +2065,14 @@ ALTER TABLE ONLY public.communities
ADD CONSTRAINT communities_pkey PRIMARY KEY (id);
+--
+-- Name: community_links community_links_pkey; Type: CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.community_links
+ ADD CONSTRAINT community_links_pkey PRIMARY KEY (id);
+
+
--
-- Name: current_node_tags current_node_tags_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@@ -2657,6 +2705,13 @@ CREATE INDEX index_communities_on_organizer_id ON public.communities USING btree
CREATE UNIQUE INDEX index_communities_on_slug ON public.communities USING btree (slug);
+--
+-- Name: index_community_links_on_community_id; Type: INDEX; Schema: public; Owner: -
+--
+
+CREATE INDEX index_community_links_on_community_id ON public.community_links USING btree (community_id);
+
+
--
-- Name: index_diary_entry_subscriptions_on_diary_entry_id; Type: INDEX; Schema: public; Owner: -
--
@@ -3327,6 +3382,14 @@ ALTER TABLE ONLY public.oauth_access_tokens
ADD CONSTRAINT fk_rails_ee63f25419 FOREIGN KEY (resource_owner_id) REFERENCES public.users(id) NOT VALID;
+--
+-- Name: community_links fk_rails_f60a749c39; Type: FK CONSTRAINT; Schema: public; Owner: -
+--
+
+ALTER TABLE ONLY public.community_links
+ ADD CONSTRAINT fk_rails_f60a749c39 FOREIGN KEY (community_id) REFERENCES public.communities(id);
+
+
--
-- Name: friends friends_friend_user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
@@ -3666,6 +3729,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('23'),
('22'),
('21'),
+('20240525143545'),
('20240525030520'),
('20240525020545'),
('20240405083825'),
diff --git a/test/abilities/abilities_test.rb b/test/abilities/abilities_test.rb
index e7ce60df950..59b10a61dec 100644
--- a/test/abilities/abilities_test.rb
+++ b/test/abilities/abilities_test.rb
@@ -76,7 +76,7 @@ class UserAbilityTest < AbilityTest
test "community permissions for a user" do
ability = Ability.new create(:user)
- [:edit].each do |action|
+ [:create, :edit].each do |action|
assert ability.can?(action, Community), "should be able to #{action} Communities"
end
end
diff --git a/test/controllers/community_links_controller_test.rb b/test/controllers/community_links_controller_test.rb
new file mode 100644
index 00000000000..8fcc6121005
--- /dev/null
+++ b/test/controllers/community_links_controller_test.rb
@@ -0,0 +1,204 @@
+require "test_helper"
+require "minitest/mock"
+
+class CommunityLinksControllerTest < ActionDispatch::IntegrationTest
+ ##
+ # test all routes which lead to this controller
+ # Following guidance from Ruby on Rails Guide
+ # https://guides.rubyonrails.org/testing.html#functional-tests-for-your-controllers
+
+ def test_routes
+ assert_routing(
+ { :path => "/communities/foo/community_links", :method => :get },
+ { :controller => "community_links", :action => "index", :community_id => "foo" }
+ )
+ assert_routing(
+ { :path => "/community_links/1/edit", :method => :get },
+ { :controller => "community_links", :action => "edit", :id => "1" }
+ )
+ assert_routing(
+ { :path => "/community_links/1", :method => :put },
+ { :controller => "community_links", :action => "update", :id => "1" }
+ )
+ assert_routing(
+ { :path => "/communities/foo/community_links/new", :method => :get },
+ { :controller => "community_links", :action => "new", :community_id => "foo" }
+ )
+ assert_routing(
+ { :path => "/communities/foo/community_links", :method => :post },
+ { :controller => "community_links", :action => "create", :community_id => "foo" }
+ )
+ end
+
+ def test_index_get
+ # arrange
+ c = create(:community)
+ link = create(:community_link, :community_id => c.id)
+ # act
+ get community_community_links_path(c.id)
+ # assert
+ assert_response :success
+ assert_template "index"
+ assert_match link.site, response.body
+ end
+
+ def test_edit_get_no_session
+ # arrange
+ l = create(:community_link)
+ # act
+ get edit_community_link_path(l)
+ # assert
+ assert_response :redirect
+ assert_redirected_to login_path(:referer => edit_community_link_path(l))
+ end
+
+ def test_update_as_non_organizer
+ # Should this test be in abilities_test.rb?
+ # arrange
+ link = create(:community_link)
+ session_for(create(:user))
+ # act
+ put community_link_path link, :community_link => link
+ # assert
+ assert_redirected_to :controller => :errors, :action => :forbidden
+ end
+
+ def test_update_put_success
+ # TODO: When community_member is created switch to using that factory.
+ # arrange
+ c = create(:community)
+ link1 = create(:community_link, :community_id => c.id) # original object
+ link2 = build(:community_link, :community_id => c.id) # new data
+ link_2_form = link2.attributes.except("id", "created_at", "updated_at")
+ session_for(c.organizer)
+
+ # act
+ # Update link1 with the values from link2.
+ put community_link_url(link1), :params => { :community_link => link_2_form.as_json }, :xhr => true
+
+ # assert
+ assert_redirected_to community_path(link1.community)
+ assert_equal I18n.t("community_links.update.success"), flash[:notice]
+ link1.reload
+ # Assign the id of link1 to link2, so we can do an equality test easily.
+ link2.id = link1.id
+ assert_equal(link2, link1)
+ end
+
+ def test_update_put_failure
+ # arrange
+ c = create(:community) # original object
+ session_for(c.organizer)
+ link = create(:community_link, :community_id => c.id) # original object
+ def link.update(_params)
+ false
+ end
+
+ controller_mock = CommunityLinksController.new
+ def controller_mock.set_link
+ @link = CommunityLink.new
+ end
+
+ def controller_mock.render(_partial)
+ # Can't do assert_equal here.
+ # assert_equal :edit, partial
+ end
+
+ # act
+ CommunityLinksController.stub :new, controller_mock do
+ CommunityLink.stub :new, link do
+ assert_difference "CommunityLink.count", 0 do
+ put community_link_url(link), :params => { :community_link => link.as_json }, :xhr => true
+ end
+ end
+ end
+
+ # assert
+ assert_equal I18n.t("community_links.update.failure"), flash[:alert]
+ end
+
+ def test_new_no_login
+ # Make sure that you are redirected to the login page when you
+ # are not logged in
+ # arrange
+ c = create(:community)
+ # act
+ get new_community_community_link_path(c)
+ # assert
+ assert_response :redirect
+ assert_redirected_to login_path(:referer => new_community_community_link_path(c))
+ end
+
+ def test_new_form
+ # Now try again when logged in
+ # arrange
+ c = create(:community)
+ session_for(c.organizer)
+ # act
+ get new_community_community_link_path(c)
+ # assert
+ assert_response :success
+ assert_select "div.content-heading", :count => 1 do
+ assert_select "h1", :text => /Community Link/, :count => 1
+ end
+ action = community_community_links_path(c)
+ assert_select "div#content", :count => 1 do
+ assert_select "form[action='#{action}'][method=post]", :count => 1 do
+ assert_select "input#community_link_site[name='community_link[site]']", :count => 1
+ assert_select "input#community_link_url[name='community_link[url]']", :count => 1
+ assert_select "input", :count => 3
+ end
+ end
+ end
+
+ def test_create_when_save_works
+ # arrange
+ c = create(:community)
+ link_orig = create(:community_link, :community => c)
+ form = link_orig.attributes.except("id", "created_at", "updated_at")
+ session_for(c.organizer)
+
+ # act
+ link_new_id = nil
+ assert_difference "CommunityLink.count", 1 do
+ post community_community_links_path c.id, :community_link => form
+ link_new_id = @response.headers["link_id"]
+ end
+
+ # assert
+ # Not sure what's going on with this assigns magic.
+ # assert_redirected_to "/community/#{assigns(:community_link).id}"
+ assert_equal I18n.t("community_links.create.success"), flash[:notice]
+ link_new = CommunityLink.find_by(:id => link_new_id)
+ # Assign the id link_new to link_orig, so we can do an equality test easily.
+ link_orig.id = link_new.id
+ assert_equal(link_orig, link_new)
+ end
+
+ def test_create_when_save_fails
+ # arrange
+ c = create(:community)
+ session_for(c.organizer)
+ link = build(:community_link, :community => c, :url => "invalid url")
+ form = link.attributes.except("id", "created_at", "updated_at")
+
+ # act and assert
+ assert_no_difference "CommunityLink.count", 0 do
+ post community_community_links_path :community_link => form, :community_id => c.id
+ end
+
+ assert_template :new
+ end
+
+ def test_delete
+ # arrange
+ c = create(:community)
+ link = create(:community_link, :community_id => c.id)
+ session_for(c.organizer)
+
+ # act and assert
+ assert_difference "CommunityLink.count", -1 do
+ delete community_link_path(:id => link.id)
+ end
+ end
+end
diff --git a/test/factories/community_links.rb b/test/factories/community_links.rb
new file mode 100644
index 00000000000..715a2c636dc
--- /dev/null
+++ b/test/factories/community_links.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :community_link do
+ community
+ site { "website" }
+ url { "https://example.com" }
+ end
+end
diff --git a/test/models/community_link_test.rb b/test/models/community_link_test.rb
new file mode 100644
index 00000000000..842d65b0e5f
--- /dev/null
+++ b/test/models/community_link_test.rb
@@ -0,0 +1,21 @@
+require "test_helper"
+
+class CommunityLinkTest < ActiveSupport::TestCase
+ def test_community_link_validations
+ validate({}, true)
+
+ validate({ :community_id => nil }, false)
+ validate({ :community_id => "" }, false)
+
+ validate({ :site => nil }, false)
+ validate({ :site => "" }, false)
+
+ validate({ :url => nil }, false)
+ validate({ :url => "" }, false)
+
+ validate({ :url => "foo" }, false)
+ scheme = "https://"
+ validate({ :url => scheme + ("a" * (255 - scheme.length)) }, true)
+ validate({ :url => scheme + ("a" * (256 - scheme.length)) }, false)
+ end
+end