decidim-core 0.28.4 → 0.28.5

Sign up to get free protection for your applications and to get access to all the features.
Files changed (100) hide show
  1. checksums.yaml +4 -4
  2. data/app/cells/decidim/activity_cell.rb +1 -4
  3. data/app/cells/decidim/author/show.erb +5 -4
  4. data/app/cells/decidim/author_cell.rb +26 -0
  5. data/app/cells/decidim/card_s/show.erb +5 -3
  6. data/app/cells/decidim/diff_cell.rb +4 -0
  7. data/app/cells/decidim/newsletter_templates/image_text_cta_cell.rb +1 -1
  8. data/app/cells/decidim/translation_bar/show.erb +3 -3
  9. data/app/cells/decidim/translation_bar_cell.rb +1 -1
  10. data/app/commands/decidim/destroy_account.rb +3 -0
  11. data/app/controllers/decidim/doorkeeper/credentials_controller.rb +1 -1
  12. data/app/controllers/decidim/links_controller.rb +1 -1
  13. data/app/controllers/decidim/profiles_controller.rb +4 -0
  14. data/app/helpers/concerns/decidim/user_role_checker.rb +46 -0
  15. data/app/helpers/decidim/cta_button_helper.rb +1 -1
  16. data/app/helpers/decidim/map_helper.rb +6 -1
  17. data/app/helpers/decidim/sanitize_helper.rb +11 -2
  18. data/app/models/decidim/attachment.rb +1 -1
  19. data/app/packs/src/decidim/append_redirect_url_to_modals.js +14 -6
  20. data/app/packs/src/decidim/direct_uploads/upload_field.js +21 -8
  21. data/app/packs/src/decidim/index.js +3 -0
  22. data/app/packs/src/decidim/remote_tooltips.js +38 -0
  23. data/app/packs/src/decidim/toggle.js +1 -1
  24. data/app/packs/src/decidim/tooltips.js +42 -22
  25. data/app/packs/stylesheets/decidim/_profile.scss +1 -1
  26. data/app/packs/stylesheets/decidim/_progress-bar.scss +1 -1
  27. data/app/packs/stylesheets/decidim/legacy/conference-diploma.scss +2 -1
  28. data/app/presenters/decidim/attachment_presenter.rb +1 -1
  29. data/app/services/decidim/base_diff_renderer.rb +26 -2
  30. data/app/services/decidim/email_notification_generator.rb +14 -5
  31. data/app/views/decidim/pages/_tabbed.html.erb +2 -2
  32. data/app/views/layouts/decidim/header/_menu_breadcrumb_mobile_tablet.html.erb +1 -1
  33. data/config/locales/ar.yml +12 -0
  34. data/config/locales/bn-BD.yml +1 -0
  35. data/config/locales/bs-BA.yml +98 -0
  36. data/config/locales/ca.yml +13 -9
  37. data/config/locales/cs.yml +5 -0
  38. data/config/locales/de.yml +16 -12
  39. data/config/locales/el.yml +3 -0
  40. data/config/locales/en.yml +4 -0
  41. data/config/locales/es-MX.yml +5 -1
  42. data/config/locales/es-PY.yml +5 -1
  43. data/config/locales/es.yml +11 -7
  44. data/config/locales/eu.yml +198 -181
  45. data/config/locales/fi-plain.yml +4 -0
  46. data/config/locales/fi.yml +39 -35
  47. data/config/locales/fr-CA.yml +5 -1
  48. data/config/locales/fr.yml +4 -0
  49. data/config/locales/ga-IE.yml +5 -0
  50. data/config/locales/gl.yml +4 -0
  51. data/config/locales/hu.yml +1 -1
  52. data/config/locales/id-ID.yml +4 -0
  53. data/config/locales/is-IS.yml +4 -1
  54. data/config/locales/it.yml +40 -0
  55. data/config/locales/ja.yml +15 -13
  56. data/config/locales/lb.yml +5 -0
  57. data/config/locales/lt.yml +1 -1
  58. data/config/locales/lv.yml +4 -0
  59. data/config/locales/nl.yml +6 -1
  60. data/config/locales/no.yml +5 -0
  61. data/config/locales/pl.yml +1 -1
  62. data/config/locales/pt-BR.yml +1 -1
  63. data/config/locales/pt.yml +11 -0
  64. data/config/locales/ro-RO.yml +243 -134
  65. data/config/locales/ru.yml +4 -0
  66. data/config/locales/sk.yml +5 -1
  67. data/config/locales/sv.yml +7 -7
  68. data/config/locales/tr-TR.yml +6 -1
  69. data/config/locales/uk.yml +4 -1
  70. data/config/locales/zh-CN.yml +5 -0
  71. data/config/locales/zh-TW.yml +4 -0
  72. data/config/routes.rb +1 -0
  73. data/decidim-core.gemspec +4 -1
  74. data/lib/decidim/api/functions/component_list.rb +1 -1
  75. data/lib/decidim/api/functions/participatory_space_finder_base.rb +11 -1
  76. data/lib/decidim/api/interfaces/participatory_space_interface.rb +1 -1
  77. data/lib/decidim/api/types/component_type.rb +7 -0
  78. data/lib/decidim/api/types/user_group_type.rb +4 -0
  79. data/lib/decidim/api/types/user_type.rb +4 -0
  80. data/lib/decidim/attributes/rich_text.rb +38 -0
  81. data/lib/decidim/attributes/time_with_zone.rb +11 -1
  82. data/lib/decidim/attributes.rb +2 -0
  83. data/lib/decidim/content_parsers/blob_parser.rb +93 -0
  84. data/lib/decidim/content_parsers.rb +1 -0
  85. data/lib/decidim/content_renderers/blob_renderer.rb +90 -0
  86. data/lib/decidim/content_renderers.rb +1 -0
  87. data/lib/decidim/core/engine.rb +29 -1
  88. data/lib/decidim/core/test/factories.rb +28 -0
  89. data/lib/decidim/core/test/shared_examples/authorable_interface_examples.rb +1 -1
  90. data/lib/decidim/core/test/shared_examples/comments_examples.rb +15 -2
  91. data/lib/decidim/core/version.rb +1 -1
  92. data/lib/decidim/diffy_extension.rb +18 -0
  93. data/lib/decidim/form_builder.rb +1 -1
  94. data/lib/decidim/map/autocomplete.rb +1 -0
  95. data/lib/decidim/participatory_space_user.rb +4 -0
  96. data/lib/decidim/query_extensions.rb +0 -26
  97. data/lib/decidim/settings_manifest.rb +2 -0
  98. data/lib/decidim/translatable_attributes.rb +6 -1
  99. data/lib/tasks/upgrade/decidim_fix_categorization.rake +34 -8
  100. metadata +28 -7
@@ -248,6 +248,10 @@ ru:
248
248
  name: Статистика организации
249
249
  sub_hero:
250
250
  name: Под-богатырский баннер
251
+ core:
252
+ application_helper:
253
+ filter_category_values:
254
+ all: Все
251
255
  devise:
252
256
  omniauth_registrations:
253
257
  new:
@@ -342,6 +342,10 @@ sk:
342
342
  name: Štatistiky organizácie
343
343
  sub_hero:
344
344
  name: Pruh pod hlavným pruhom
345
+ core:
346
+ application_helper:
347
+ filter_category_values:
348
+ all: Všetko
345
349
  devise:
346
350
  omniauth_registrations:
347
351
  new:
@@ -824,7 +828,7 @@ sk:
824
828
  scopes:
825
829
  global: Globální oblast působnosti
826
830
  picker:
827
- cancel: zrušenie
831
+ cancel: Zrušiť
828
832
  choose: Vybrat
829
833
  title: Vyberte %{field}
830
834
  prompt: Vyberte oblast
@@ -595,7 +595,7 @@ sv:
595
595
  already_have_an_account?: Har du redan ett konto?
596
596
  log_in: Logga in
597
597
  newsletter: Få enstaka nyhetsbrev med relevant information
598
- newsletter_title: Kontakttillstånd
598
+ newsletter_title: Tillåtelse att kontakta mig
599
599
  sign_up: Registrera dig
600
600
  subtitle: Registrera dig för att delta i diskussioner och stödja förslag.
601
601
  terms: användarvillkoren
@@ -938,9 +938,9 @@ sv:
938
938
  index:
939
939
  badge_title: "%{name} märke"
940
940
  how: Hur kan du få det
941
- page_description: Märken visar att du varit aktiv som deltagare på plattformen och deltagit i behandlingen. Medan du upptäcker, deltar och samverkar på plattformen får du olika märken. Här är listan med märken och hur du kan få dem.
941
+ page_description: Märkena visar att du varit en aktiv deltagare på plattformen. Genom att utforska, delta och samverka på plattformen får du olika märken. Här är alla märken och hur du kan få dem.
942
942
  title: Märken
943
- description: Märken visar att du varit aktiv som deltagare på plattformen och deltagit i behandlingen. Medan du upptäcker, deltar och samverkar på plattformen får du olika märken.
943
+ description: Märkena visar att du varit en aktiv deltagare på plattformen. Genom att utforska, delta och samverka på plattformen får du olika märken.
944
944
  level: Nivå %{level}
945
945
  reached_top: Du har nått den högsta nivån för detta märke.
946
946
  title: Vilka är märkena?
@@ -1033,7 +1033,7 @@ sv:
1033
1033
  help:
1034
1034
  main_topic:
1035
1035
  default_page:
1036
- content: "<p>I %{organization} kan du delta och besluta i de deltagandeprocesser som visas i toppmenyn: processer, samråd, namninsamlingar och konsultationer.</p> <p>Inom varje område kan du delta på olika sätt: lämna förslag – enskilt eller med andra människor – delta i debatter, prioritera projekt att genomföra, delta i möten med mera.</p>\n"
1036
+ content: "<p>I %{organization} kan du delta och besluta i de deltagandeprocesser som visas i toppmenyn: processer, samråd, initiativ och konsultationer.</p> <p>Inom varje område kan du delta på olika sätt: lämna förslag – enskilt eller med andra människor – delta i debatter, prioritera projekt att genomföra, delta i möten med mera.</p>\n"
1037
1037
  title: Vad kan jag göra i %{organization}?
1038
1038
  description: Läs mer om %{organization}.
1039
1039
  title: Allmän hjälp
@@ -1436,7 +1436,7 @@ sv:
1436
1436
  jump_to: 'Gå till:'
1437
1437
  search: Sök
1438
1438
  state:
1439
- active: Aktiva
1439
+ active: Pågående
1440
1440
  all: Alla
1441
1441
  future: Framtida
1442
1442
  past: Tidigare
@@ -1716,7 +1716,7 @@ sv:
1716
1716
  already_signed_out: Du är utloggad.
1717
1717
  new:
1718
1718
  log_in: Logga in
1719
- signed_in: Loggade in.
1719
+ signed_in: Du är inloggad.
1720
1720
  signed_out: Du är utloggad.
1721
1721
  shared:
1722
1722
  links:
@@ -1969,7 +1969,7 @@ sv:
1969
1969
  account: Konto
1970
1970
  authorizations: Auktoriseringar
1971
1971
  delete_my_account: Radera mitt konto
1972
- my_data: Mitt data
1972
+ my_data: Min data
1973
1973
  my_interests: Mina intressen
1974
1974
  notifications_settings: Meddelandeinställningar
1975
1975
  profile: Profil
@@ -379,6 +379,10 @@ tr:
379
379
  name: Organizasyon istatistikleri
380
380
  sub_hero:
381
381
  name: Alt ana afişi
382
+ core:
383
+ application_helper:
384
+ filter_category_values:
385
+ all: Herşey
382
386
  devise:
383
387
  omniauth_registrations:
384
388
  new:
@@ -957,7 +961,7 @@ tr:
957
961
  scopes:
958
962
  global: Küresel kapsamı
959
963
  picker:
960
- cancel: İptal etmek
964
+ cancel: İptal Et
961
965
  change: Seçili kapsamı değiştir
962
966
  choose: seçmek
963
967
  currently_selected: Şu anda seçili kapsam
@@ -1027,6 +1031,7 @@ tr:
1027
1031
  participatory_space_filters:
1028
1032
  filters:
1029
1033
  areas: alanlar
1034
+ scope: Kapsam
1030
1035
  select_an_area: Bir bölge seçin
1031
1036
  progress: "Süreç\nİlerleme"
1032
1037
  reference:
@@ -191,6 +191,10 @@ uk:
191
191
  name: Статистика організації
192
192
  sub_hero:
193
193
  name: Під-багатирський банер
194
+ core:
195
+ application_helper:
196
+ filter_category_values:
197
+ all: Усі
194
198
  devise:
195
199
  omniauth_registrations:
196
200
  new:
@@ -417,7 +421,6 @@ uk:
417
421
  scopes:
418
422
  global: Всеохопний обсяг
419
423
  picker:
420
- cancel: Скасувати
421
424
  choose: Оберіть
422
425
  title: Оберіть %{field}
423
426
  prompt: Оберіть обсяг
@@ -324,6 +324,10 @@ zh-CN:
324
324
  name: 组织统计
325
325
  sub_hero:
326
326
  name: 子英雄广告
327
+ core:
328
+ application_helper:
329
+ filter_category_values:
330
+ all: 所有的
327
331
  devise:
328
332
  omniauth_registrations:
329
333
  new:
@@ -923,6 +927,7 @@ zh-CN:
923
927
  participatory_space_filters:
924
928
  filters:
925
929
  areas: 地区
930
+ scope: 范围
926
931
  select_an_area: 选择区域
927
932
  reference:
928
933
  reference: '引用: %{reference}'
@@ -500,6 +500,9 @@ zh-TW:
500
500
  actions:
501
501
  login_before_access: 請先登入您的帳戶再進行訪問.
502
502
  unauthorized: 您無權限執行此操作.
503
+ application_helper:
504
+ filter_category_values:
505
+ all: 全部
503
506
  devise:
504
507
  omniauth_registrations:
505
508
  create:
@@ -1368,6 +1371,7 @@ zh-TW:
1368
1371
  participatory_space_filters:
1369
1372
  filters:
1370
1373
  areas: 區域
1374
+ scope: 範圍
1371
1375
  select_an_area: 選擇一個地區
1372
1376
  public_participation:
1373
1377
  public_participation: 公開展示我的出席情況。
data/config/routes.rb CHANGED
@@ -137,6 +137,7 @@ Decidim::Core::Engine.routes.draw do
137
137
  get "group_members", to: "profiles#group_members", as: "profile_group_members"
138
138
  get "group_admins", to: "profiles#group_admins", as: "profile_group_admins"
139
139
  get "activity", to: "user_activities#index", as: "profile_activity"
140
+ get "tooltip", to: "profiles#tooltip", as: "profile_tooltip"
140
141
  resources :conversations, except: [:destroy], controller: "user_conversations", as: "profile_conversations"
141
142
  end
142
143
 
data/decidim-core.gemspec CHANGED
@@ -21,7 +21,7 @@ Gem::Specification.new do |s|
21
21
  }
22
22
  s.summary = "The core of the Decidim framework."
23
23
  s.description = "Adds core features so other engines can hook into the framework."
24
- s.license = "AGPL-3.0"
24
+ s.license = "AGPL-3.0-or-later"
25
25
  s.required_ruby_version = "~> 3.1.0"
26
26
 
27
27
  s.files = Dir.chdir(__dir__) do
@@ -31,6 +31,9 @@ Gem::Specification.new do |s|
31
31
  end
32
32
  end
33
33
 
34
+ # Lock Temporarily as it is failing in 0.29 branch. More info: https://github.com/rails/rails/pull/54264
35
+ s.add_dependency "concurrent-ruby", "= 1.2.2"
36
+
34
37
  s.add_dependency "active_link_to", "~> 1.0"
35
38
  s.add_dependency "acts_as_list", "~> 1.0"
36
39
  s.add_dependency "batch-loader", "~> 1.2"
@@ -27,7 +27,7 @@ module Decidim
27
27
  @query = Decidim::Component
28
28
  # remove default ordering if custom order required
29
29
  @query = @query.unscoped if args[:order]
30
- @query = @query.where(participatory_space:).published
30
+ @query = @query.where(participatory_space:)
31
31
  add_filter_keys(args[:filter])
32
32
  add_order_keys(args[:order].to_h)
33
33
  add_default_order
@@ -22,7 +22,17 @@ module Decidim
22
22
  args.compact.keys.each do |key|
23
23
  query[key] = args[key]
24
24
  end
25
- model_class.public_spaces.find_by(query)
25
+
26
+ @query =
27
+ if ctx[:current_user]&.admin?
28
+ model_class
29
+ elsif model_class.respond_to?(:visible_for)
30
+ model_class.visible_for(ctx[:current_user])
31
+ else
32
+ model_class.public_spaces
33
+ end
34
+
35
+ @query.find_by(query)
26
36
  end
27
37
  end
28
38
  end
@@ -23,7 +23,7 @@ module Decidim
23
23
  ParticipatorySpaceManifestPresenter.new(object.manifest, object.organization)
24
24
  end
25
25
 
26
- field :components, [ComponentInterface], null: true, description: "Lists the components this space contains." do
26
+ field :components, [ComponentInterface, { null: true }], null: true, description: "Lists the components this space contains." do
27
27
  argument :filter, ComponentInputFilter, "Provides several methods to filter the results", required: false
28
28
  argument :order, ComponentInputSort, "Provides several methods to order the results", required: false
29
29
  end
@@ -5,6 +5,13 @@ module Decidim
5
5
  class ComponentType < Decidim::Api::Types::BaseObject
6
6
  implements Decidim::Core::ComponentInterface
7
7
  description "A base component with no particular specificities."
8
+
9
+ def self.authorized?(object, context)
10
+ context[:component] = object
11
+ context[:current_component] = object
12
+
13
+ super && allowed_to?(:read, :component, object, context)
14
+ end
8
15
  end
9
16
  end
10
17
  end
@@ -59,6 +59,10 @@ module Decidim
59
59
  def members_count
60
60
  object.accepted_memberships.count
61
61
  end
62
+
63
+ def self.authorized?(object, context)
64
+ super && !object.blocked?
65
+ end
62
66
  end
63
67
  end
64
68
  end
@@ -62,6 +62,10 @@ module Decidim
62
62
  def groups
63
63
  object.accepted_user_groups
64
64
  end
65
+
66
+ def self.authorized?(object, context)
67
+ super && object.confirmed? && !object.blocked? && !object.deleted?
68
+ end
65
69
  end
66
70
  end
67
71
  end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Attributes
5
+ # Custom attributes value to convert rich text strings in the database, i.e.
6
+ # strings that originate from the editor.
7
+ class RichText < Decidim::Attributes::CleanString
8
+ def type
9
+ :"decidim/attributes/rich_text"
10
+ end
11
+
12
+ # Serializes the value to the database.
13
+ def serialize(value)
14
+ serialize_value(value)
15
+ end
16
+
17
+ private
18
+
19
+ # From form to database
20
+ def serialize_value(value)
21
+ return value unless value.is_a?(String)
22
+
23
+ context = {}
24
+ parsed = Decidim::ContentProcessor.parse_with_processor(:blob, value, context)
25
+ parsed.rewrite
26
+ end
27
+
28
+ # From database to form
29
+ def cast_value(value)
30
+ clean_string = super
31
+ return clean_string unless clean_string.is_a?(String)
32
+
33
+ renderer = Decidim::ContentProcessor.renderer_klass(:blob).constantize.new(clean_string)
34
+ renderer.render
35
+ end
36
+ end
37
+ end
38
+ end
@@ -5,6 +5,15 @@ module Decidim
5
5
  # Custom attributes value to parse a String representing a Time using
6
6
  # the app TimeZone.
7
7
  class TimeWithZone < ActiveModel::Type::DateTime
8
+ # Date format: 2020-06-20T, 2020-06-20, 20/06/2020T or 20/06/2020
9
+ # Time format: 10:20, 10:20:30 or 10:20:30.123456
10
+ ISO_DATETIME_WITHOUT_TIMEZONE = %r{
11
+ \A
12
+ ((\d{4})-(\d\d)-(\d\d)|(\d\d)/(\d\d)/(\d{4}))(?:T|\s)
13
+ (\d\d):(\d\d)(:(\d\d)(?:\.(\d{1,6})\d*)?)?
14
+ \z
15
+ }x
16
+
8
17
  def type
9
18
  :"decidim/attributes/time_with_zone"
10
19
  end
@@ -18,8 +27,9 @@ module Decidim
18
27
  rescue ArgumentError
19
28
  fallback = super
20
29
  return fallback unless fallback.is_a?(Time)
30
+ return Time.zone.parse(fallback.strftime("%F %T")) if ISO_DATETIME_WITHOUT_TIMEZONE.match?(value)
21
31
 
22
- Time.zone.parse(fallback.strftime("%F %T"))
32
+ ActiveSupport::TimeWithZone.new(fallback, Time.zone)
23
33
  end
24
34
  end
25
35
  end
@@ -5,6 +5,7 @@ module Decidim
5
5
  autoload :TimeWithZone, "decidim/attributes/time_with_zone"
6
6
  autoload :LocalizedDate, "decidim/attributes/localized_date"
7
7
  autoload :CleanString, "decidim/attributes/clean_string"
8
+ autoload :RichText, "decidim/attributes/rich_text"
8
9
  autoload :Blob, "decidim/attributes/blob"
9
10
  autoload :Array, "decidim/attributes/array"
10
11
  autoload :Hash, "decidim/attributes/hash"
@@ -27,6 +28,7 @@ module Decidim
27
28
  ActiveModel::Type.register(:"decidim/attributes/time_with_zone", Decidim::Attributes::TimeWithZone)
28
29
  ActiveModel::Type.register(:"decidim/attributes/localized_date", Decidim::Attributes::LocalizedDate)
29
30
  ActiveModel::Type.register(:"decidim/attributes/clean_string", Decidim::Attributes::CleanString)
31
+ ActiveModel::Type.register(:"decidim/attributes/rich_text", Decidim::Attributes::RichText)
30
32
  ActiveModel::Type.register(:"decidim/attributes/blob", Decidim::Attributes::Blob)
31
33
 
32
34
  # Overrides
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module ContentParsers
5
+ # Parses any blob URLs from the content and replaces them with references
6
+ # to those blobs.
7
+ class BlobParser < BaseParser
8
+ # Matches all possible URLs pointing to ActiveStorage::Blob objects.
9
+ #
10
+ # Possible routes:
11
+ # get "/blobs/redirect/:signed_id/*filename"
12
+ # get "/blobs/proxy/:signed_id/*filename"
13
+ # get "/blobs/:signed_id/*filename"
14
+ # get "/representations/redirect/:signed_blob_id/:variation_key/*filename"
15
+ # get "/representations/proxy/:signed_blob_id/:variation_key/*filename"
16
+ # get "/representations/:signed_blob_id/:variation_key/*filename"
17
+ # get "/disk/:encoded_key/*filename"
18
+ #
19
+ # See:
20
+ # https://github.com/rails/rails/blob/a7e379896552ce43b822385c03c37f2bd47739d3/activestorage/config/routes.rb#L5-L14
21
+ BLOB_REGEX = %r{
22
+ # Group 1: Host part
23
+ (
24
+ # Group 2: Domain and subpath part
25
+ https?://((?!/rails).)+
26
+ )?
27
+ /rails/active_storage
28
+ # Group 3: Blob path, representation path or disk service path
29
+ /(blobs/redirect|blobs/proxy|blobs|representations/redirect|representations/proxy|representations|disk)
30
+ # Group 4: Signed ID for blobs or encoded key for disk service
31
+ /([^/]+)
32
+ # Group 5: Variation part (only for representations)
33
+ (
34
+ # Group 6: Variation key for representations
35
+ /([\w.=-]+)
36
+ )?
37
+ # Group 7: Filename
38
+ /([\w.=-]+)
39
+ }x
40
+
41
+ def rewrite
42
+ replace_blobs(content)
43
+ end
44
+
45
+ private
46
+
47
+ def replace_blobs(text)
48
+ text.gsub(BLOB_REGEX) do |match|
49
+ type_part = Regexp.last_match(3)
50
+ key_part = Regexp.last_match(4)
51
+
52
+ variation_key = nil
53
+ blob =
54
+ if type_part == "disk"
55
+ # Disk service URL
56
+ decoded = ActiveStorage.verifier.verified(key_part, purpose: :blob_key)
57
+ ActiveStorage::Blob.find_by(key: decoded[:key]) if decoded
58
+ else
59
+ # Representation or blob
60
+ if type_part.start_with?("representations")
61
+ # Representation
62
+ variation_part = Regexp.last_match(6)
63
+ variation_key = generate_variation_key(variation_part)
64
+ end
65
+
66
+ ActiveStorage::Blob.find_signed(key_part)
67
+ end
68
+ next match unless blob
69
+
70
+ "#{blob.to_global_id}#{"/#{variation_key}" if variation_key}"
71
+ end
72
+ end
73
+
74
+ def generate_variation_key(variation_part)
75
+ # The variation part has to be decoded because it will eventually
76
+ # expire. This way we can preserve the variation information
77
+ # longer.
78
+ variation = ActiveStorage.verifier.verify(variation_part, purpose: :variation)
79
+ return unless variation
80
+
81
+ # Convert to base64 encoded JSON string for better representation within
82
+ # the URLs. This manually encoded part will not expire as it is
83
+ # persisted to the database.
84
+ Base64.strict_encode64(ActiveSupport::JSON.encode(variation))
85
+ rescue ActiveSupport::MessageVerifier::InvalidSignature
86
+ # This happens if the variation key is already expired in which
87
+ # case it cannot be represented and instead a URL to the blob is
88
+ # created.
89
+ variation_part
90
+ end
91
+ end
92
+ end
93
+ end
@@ -3,6 +3,7 @@
3
3
  module Decidim
4
4
  module ContentParsers
5
5
  autoload :BaseParser, "decidim/content_parsers/base_parser"
6
+ autoload :BlobParser, "decidim/content_parsers/blob_parser"
6
7
  autoload :UserParser, "decidim/content_parsers/user_parser"
7
8
  autoload :UserGroupParser, "decidim/content_parsers/user_group_parser"
8
9
  autoload :HashtagParser, "decidim/content_parsers/hashtag_parser"
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module ContentRenderers
5
+ # A renderer that searches Global IDs representing blobs in content and
6
+ # replaces it with a URL to these blobs.
7
+ #
8
+ # e.g. gid://<APP_NAME>/ActiveStorage::Blob/1
9
+ #
10
+ # OR for representations
11
+ #
12
+ # e.g. gid://<APP_NAME>/ActiveStorage::Blob/1/<encoded variant transformations>
13
+ #
14
+ # The `<encoded variant transformations>` part of the URL is a Base64
15
+ # encoded string that contains an unencrypted JSON-encoded value about the
16
+ # blob transformations. This way the specific representations can be stored
17
+ # in the database without having these values expiring.
18
+ #
19
+ # @see BaseRenderer Examples of how to use a content renderer
20
+ class BlobRenderer < BaseRenderer
21
+ # Matches a global id representing a Decidim::User
22
+ GLOBAL_ID_REGEX = %r{(gid://[\w-]+/ActiveStorage::Blob/\d+)(/([\w=-]+))?}
23
+
24
+ # Replaces found Global IDs matching an existing blob with a URL to
25
+ # that blob. The Global IDs representing an invalid ActiveStorage::Blob
26
+ # are replaced with an empty string.
27
+ #
28
+ # @return [String] the content ready to display (contains HTML)
29
+ def render(_options = nil)
30
+ replace_pattern(content, GLOBAL_ID_REGEX)
31
+ end
32
+
33
+ protected
34
+
35
+ def replace_pattern(text, pattern)
36
+ return text unless text.respond_to?(:gsub)
37
+
38
+ text.gsub(pattern) do
39
+ blob_gid = Regexp.last_match(1)
40
+ variation_key = Regexp.last_match(3)
41
+
42
+ blob = GlobalID::Locator.locate(blob_gid)
43
+ if variation_key
44
+ variation = begin
45
+ ActiveSupport::JSON.decode(Base64.strict_decode64(variation_key))
46
+ rescue JSON::ParseError
47
+ variation_key
48
+ end
49
+ blob_url(blob, variation)
50
+ else
51
+ blob_url(blob)
52
+ end
53
+ rescue ActiveRecord::RecordNotFound => _e
54
+ ""
55
+ end
56
+ end
57
+
58
+ def blob_url(blob, variation = nil)
59
+ url = begin
60
+ if variation
61
+ blob.variant(variation).url
62
+ else
63
+ blob.url
64
+ end
65
+ rescue ArgumentError
66
+ # ArgumentError is raised in case the blob's service is set to
67
+ # ActiveStorage::Service::DiskService and
68
+ # `ActiveStorage::Current.url_options` is not set.
69
+ end
70
+ raise URI::InvalidURIError if url.blank?
71
+
72
+ url
73
+ rescue URI::InvalidURIError
74
+ local_blob_url(blob, variation)
75
+ end
76
+
77
+ def local_blob_url(blob, variation = nil)
78
+ if variation
79
+ routes.rails_representation_url(blob.variant(variation), only_path: true)
80
+ else
81
+ routes.rails_blob_url(blob, only_path: true)
82
+ end
83
+ end
84
+
85
+ def routes
86
+ @routes ||= Rails.application.routes.url_helpers
87
+ end
88
+ end
89
+ end
90
+ end
@@ -3,6 +3,7 @@
3
3
  module Decidim
4
4
  module ContentRenderers
5
5
  autoload :BaseRenderer, "decidim/content_renderers/base_renderer"
6
+ autoload :BlobRenderer, "decidim/content_renderers/blob_renderer"
6
7
  autoload :UserRenderer, "decidim/content_renderers/user_renderer"
7
8
  autoload :UserGroupRenderer, "decidim/content_renderers/user_group_renderer"
8
9
  autoload :HashtagRenderer, "decidim/content_renderers/hashtag_renderer"
@@ -237,6 +237,34 @@ module Decidim
237
237
  app.config.action_mailer.deliver_later_queue_name = :mailers
238
238
  end
239
239
 
240
+ initializer "decidim_core.active_storage", before: "active_storage.configs" do |app|
241
+ next if app.config.active_storage.service_urls_expire_in.present?
242
+
243
+ # Ensure that the ActiveStorage URLs are valid long enough because with
244
+ # the default configuration they would expire in 5 minutes which is a
245
+ # problem:
246
+ # a) for the backend blob URL replacement
247
+ # and
248
+ # b) for caching
249
+ #
250
+ # Note the limitations for each storage service regarding the signed URL
251
+ # expiration times. This limitation has to be also considered when
252
+ # defining a caching strategy, otherwise e.g. images or files may not
253
+ # display correctly when caching is enabled.
254
+ #
255
+ # ActiveStorage disk service (default): no limitation
256
+ #
257
+ # S3: maximum is 7 days from the creation of the URL
258
+ # https://docs.aws.amazon.com/AmazonS3/latest/userguide/PresignedUrlUploadObject.html
259
+ #
260
+ # Google: maximum is 7 days (604800 seconds)
261
+ # https://cloud.google.com/storage/docs/access-control/signed-urls
262
+ #
263
+ # Azure: no limitation
264
+ # https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview#best-practices-when-using-sas
265
+ app.config.active_storage.service_urls_expire_in = 7.days
266
+ end
267
+
240
268
  initializer "decidim_core.signed_global_id", after: "global_id" do |app|
241
269
  next if app.config.global_id.fetch(:expires_in, nil).present?
242
270
 
@@ -417,7 +445,7 @@ module Decidim
417
445
 
418
446
  initializer "decidim_core.content_processors" do |_app|
419
447
  Decidim.configure do |config|
420
- config.content_processors += [:user, :user_group, :hashtag, :link]
448
+ config.content_processors += [:user, :user_group, :hashtag, :link, :blob]
421
449
  end
422
450
  end
423
451
 
@@ -203,8 +203,15 @@ FactoryBot.define do
203
203
  end
204
204
 
205
205
  trait :deleted do
206
+ name { "" }
207
+ nickname { "" }
206
208
  email { "" }
209
+ delete_reason { "I want to delete my account" }
210
+ admin { false }
207
211
  deleted_at { Time.current }
212
+ avatar { nil }
213
+ personal_url { "" }
214
+ about { "" }
208
215
  end
209
216
 
210
217
  trait :admin_terms_accepted do
@@ -1001,4 +1008,25 @@ FactoryBot.define do
1001
1008
  end
1002
1009
  end
1003
1010
  end
1011
+
1012
+ factory :blob, class: "ActiveStorage::Blob" do
1013
+ transient do
1014
+ filepath { Decidim::Dev.asset("city.jpeg") }
1015
+ end
1016
+
1017
+ filename { File.basename(filepath) }
1018
+ content_type { MiniMime.lookup_by_filename(filepath)&.content_type || "text/plain" }
1019
+
1020
+ before(:create) do |object, evaluator|
1021
+ object.upload(File.open(evaluator.filepath))
1022
+ end
1023
+
1024
+ trait :image do
1025
+ filepath { Decidim::Dev.asset("city.jpeg") }
1026
+ end
1027
+
1028
+ trait :document do
1029
+ filepath { Decidim::Dev.asset("Exampledocument.pdf") }
1030
+ end
1031
+ end
1004
1032
  end
@@ -18,7 +18,7 @@ shared_examples_for "authorable interface" do
18
18
  end
19
19
 
20
20
  describe "with a regular user" do
21
- let(:author) { create(:user, organization: model.participatory_space.organization) }
21
+ let(:author) { create(:user, :confirmed, organization: model.participatory_space.organization) }
22
22
  let(:query) { "{ author { name } }" }
23
23
 
24
24
  before do