From 683526b38442a9873d83a542b5cba1d15efc14d5 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 31 Jul 2026 18:55:35 +0200 Subject: Bind aggregates over scoped tags to their subtree CccConventions::TAG_SCOPES maps "update" and "pressemitteilung" to /updates and "disclosure" to /disclosure. Page.aggregate applies the mapping, so an aggregate over one of those tags is bounded by its subtree regardless of what the shortcode says. Tags stay unrestricted; positions are publish-gated. Both RSS actions call Page.aggregate rather than repeating its tag join. Empty feeds no longer crash: Atom falls back to the current time for the required , and the optional dc:date is omitted. --- app/controllers/rss_controller.rb | 34 ++++++++++++----------------- app/helpers/content_helper.rb | 2 +- app/models/page.rb | 7 +++++- app/views/rss/updates.rdf.builder | 2 +- app/views/rss/updates.xml.builder | 2 +- lib/ccc_conventions.rb | 10 +++++++++ test/controllers/content_controller_test.rb | 23 ++++++++++--------- test/controllers/rss_controller_test.rb | 20 ++++++++++++++++- test/models/page_test.rb | 20 +++++++++++++++-- 9 files changed, 81 insertions(+), 39 deletions(-) diff --git a/app/controllers/rss_controller.rb b/app/controllers/rss_controller.rb index 655ccba4..c2149d90 100644 --- a/app/controllers/rss_controller.rb +++ b/app/controllers/rss_controller.rb @@ -1,19 +1,12 @@ class RssController < ApplicationController before_action :get_host - + def updates expires_in 31.minutes, :public => true - I18n.locale = I18n.default_locale - - @items = Page.heads - .joins("JOIN taggings ON taggings.taggable_id = pages.id - AND taggings.taggable_type = 'Page' - AND taggings.context = 'tags'") - .joins("JOIN tags ON tags.id = taggings.tag_id") - .where("LOWER(tags.name) = ?", "update") - .order("published_at DESC").limit(20) + + @items = feed_items("update") respond_to do |format| format.xml {} @@ -23,16 +16,10 @@ class RssController < ApplicationController def tag_updates expires_in 31.minutes, :public => true - I18n.locale = I18n.default_locale - @tag = params[:tag] - @items = Page.heads - .joins("JOIN taggings ON taggings.taggable_id = pages.id - AND taggings.taggable_type = 'Page' - AND taggings.context = 'tags'") - .joins("JOIN tags ON tags.id = taggings.tag_id") - .where("LOWER(tags.name) = ?", @tag.downcase) - .order("published_at DESC").limit(20) + + @tag = params[:tag] + @items = feed_items(@tag) respond_to do |format| format.xml {} @@ -40,7 +27,14 @@ class RssController < ApplicationController end protected - + + def feed_items tag + Page.aggregate(:tags => tag.to_s.downcase, + :limit => 20, + :order_by => "published_at", + :order_direction => "DESC") + end + def get_host @host = request.protocol + request.host_with_port end diff --git a/app/helpers/content_helper.rb b/app/helpers/content_helper.rb index 57f8c960..9d52c110 100644 --- a/app/helpers/content_helper.rb +++ b/app/helpers/content_helper.rb @@ -123,7 +123,7 @@ module ContentHelper sanitize(content, :attributes => cccms_attributes) end - rescue + rescue => e Rails.logger.error("aggregate shortcode failed on page #{@page&.id}: #{e.class}: #{e.message}") fallback = content.sub(/\[aggregate[^\]]*\]/, "") fallback = sanitize(fallback, :attributes => cccms_attributes) diff --git a/app/models/page.rb b/app/models/page.rb index cf003f74..a66527da 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -61,7 +61,7 @@ class Page < ApplicationRecord unless options[:tags].blank? tag_names = options[:tags].gsub(/\s/, ",").split(",").map(&:strip).map(&:downcase).uniq.reject(&:blank?) - unless tag_names.empty? + unless tag_names.empty? scope = scope .joins("JOIN taggings ON taggings.taggable_id = pages.id AND taggings.taggable_type = 'Page' @@ -71,6 +71,11 @@ class Page < ApplicationRecord .group("pages.id") .having("COUNT(DISTINCT tags.id) = ?", tag_names.length) end + + CccConventions::TAG_SCOPES.values_at(*tag_names).compact.uniq.each do |root| + scope = scope.where("nodes.unique_name = ? OR nodes.unique_name LIKE ?", + root, "#{root}/%") + end end if options[:node] && options[:children] == "direct" diff --git a/app/views/rss/updates.rdf.builder b/app/views/rss/updates.rdf.builder index b02d34ff..b4fecdb0 100644 --- a/app/views/rss/updates.rdf.builder +++ b/app/views/rss/updates.rdf.builder @@ -6,7 +6,7 @@ xml.tag!("rdf:RDF", "xmlns:rdf" => "http://www.w3.org/1999/02/22-rdf-syntax-ns#" xml.title("Chaos Computer Club: Updates") xml.link("https://www.ccc.de") xml.description("Kabelsalat ist gesund.") - xml.tag!("dc:date", @items.first.published_at.xmlschema) + xml.tag!("dc:date", @items.first&.published_at&.xmlschema) if @items.any? end xml.image( "rdf:about" => "https://www.ccc.de/images/chaosknoten.gif") do diff --git a/app/views/rss/updates.xml.builder b/app/views/rss/updates.xml.builder index 27845c4c..f261de89 100644 --- a/app/views/rss/updates.xml.builder +++ b/app/views/rss/updates.xml.builder @@ -4,7 +4,7 @@ xml.feed(:xmlns => "http://www.w3.org/2005/Atom", "xml:base" => @host) do xml.title("Chaos Computer Club Updates") xml.link(:href => "https://www.ccc.de/") xml.link(:rel => "self", :href => "#{@host}/rss/updates.xml") - xml.updated(@items.first.published_at.xmlschema) + xml.updated((@items.first&.published_at || Time.now).xmlschema) xml.author do xml.name("Chaos Computer Club e.V.") end diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index b840dee2..52549c9e 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -52,4 +52,14 @@ module CccConventions hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." } }.freeze + + # Tags whose aggregates are forced into a subtree. Editors may apply any + # tag anywhere, but a page tagged "update" outside /updates will never + # appear in the front-page widget or the RSS feed, because every aggregate + # over that tag is scoped here rather than in the shortcode. + TAG_SCOPES = { + "update" => "updates", + "pressemitteilung" => "updates", + "disclosure" => "disclosure" + }.freeze end diff --git a/test/controllers/content_controller_test.rb b/test/controllers/content_controller_test.rb index 5bb02f3d..39fe276b 100644 --- a/test/controllers/content_controller_test.rb +++ b/test/controllers/content_controller_test.rb @@ -239,17 +239,16 @@ class ContentControllerTest < ActionController::TestCase end def fill_pages_with_content - d1 = find_or_create_draft(@first_child, @user1) - d1.title = "one" - d1.tag_list = "update" - d1.save - @first_child.publish_draft! - - d2 = find_or_create_draft(@second_child, @user1) - d2.title = "two" - d2.tag_list = "update" - d2.save - @second_child.publish_draft! + updates = Node.root.children.find_by(:slug => "updates") || + Node.root.children.create!(:slug => "updates") + + [["one", "aggregated_one"], ["two", "aggregated_two"]].each do |title, slug| + node = updates.children.create!(:slug => slug) + draft = find_or_create_draft(node, @user1) + draft.title = title + draft.tag_list = "update" + draft.save + node.publish_draft! + end end - end diff --git a/test/controllers/rss_controller_test.rb b/test/controllers/rss_controller_test.rb index 3f4b4fbf..cf50903a 100644 --- a/test/controllers/rss_controller_test.rb +++ b/test/controllers/rss_controller_test.rb @@ -5,7 +5,9 @@ class RssControllerTest < ActionController::TestCase def setup @user = User.create :login => 'rsstest', :email => 'rsstest@example.com', :password => 'foobar', :password_confirmation => 'foobar' - @node = Node.root.children.create! :slug => 'rss_test_node' + updates = Node.root.children.find_by(:slug => "updates") || + Node.root.children.create!(:slug => "updates") + @node = updates.children.create! :slug => 'rss_test_node' draft = find_or_create_draft(@node, @user) draft.title = "RSS Update Article" draft.tag_list = "update" @@ -31,4 +33,20 @@ class RssControllerTest < ActionController::TestCase assert assigns(:items).length <= 20 end + test "the update feed excludes a page tagged update outside /updates" do + updates = Node.root.children.find_by(:slug => "updates") + inside = updates.children.create!(:slug => "feed-inside") + outside = Node.root.children.create!(:slug => "feed-outside") + + [inside, outside].each do |node| + node.reload.draft.update!(:title => node.slug, :tag_list => "update") + node.publish_draft! + end + + get :updates, params: { :format => :xml } + + assert_response :success + assert_includes @response.body, "feed-inside" + assert_not_includes @response.body, "feed-outside" + end end diff --git a/test/models/page_test.rb b/test/models/page_test.rb index 98a00d21..395b6315 100644 --- a/test/models/page_test.rb +++ b/test/models/page_test.rb @@ -9,8 +9,9 @@ class PageTest < ActiveSupport::TestCase def test_aggregation # Create two nodes and move them beneath the root node - n1 = Node.root.children.create! :slug => "one" - n2 = Node.root.children.create! :slug => "two" + updates = Node.root.children.create! :slug => "updates" + n1 = updates.children.create! :slug => "one" + n2 = updates.children.create! :slug => "two" # get the drafts and assign a user to it assert_not_nil d1 = find_or_create_draft(n1, @user1) @@ -409,4 +410,19 @@ class PageTest < ActiveSupport::TestCase page.reload assert page.update(:abstract => "still saveable") end + + test "an aggregate over a scoped tag ignores pages outside that subtree" do + updates = Node.root.children.create!(:slug => "updates") + inside = updates.children.create!(:slug => "inside-post") + outside = Node.root.children.create!(:slug => "outside-post") + + [inside, outside].each do |node| + node.reload.draft.update!(:title => node.slug, :tag_list => "update") + node.publish_draft! + end + + names = Page.aggregate({ :tags => "update" }).map { |p| p.node.unique_name } + assert_includes names, "updates/inside-post" + assert_not_includes names, "outside-post" + end end -- cgit v1.3