diff --git a/.env.production.sample b/.env.production.sample index 5939c12146..1b3c511042 100644 --- a/.env.production.sample +++ b/.env.production.sample @@ -1,5 +1,5 @@ # This is a sample configuration file. You can generate your configuration -# with the `rake mastodon:setup` interactive setup wizard, but to customize +# with the `bundle exec rails mastodon:setup` interactive setup wizard, but to customize # your setup even further, you'll need to edit it manually. This sample does # not demonstrate all available configuration options. Please look at # https://docs.joinmastodon.org/admin/config/ for the full documentation. @@ -68,7 +68,7 @@ DB_PORT=5432 # Secrets # ------- -# Generate each with the `RAILS_ENV=production bundle exec rake secret` task (`docker-compose run --rm web bundle exec rake secret` if you use docker compose) +# Generate each with the `RAILS_ENV=production bundle exec rails secret` task (`docker-compose run --rm web bundle exec rails secret` if you use docker compose) # ------- SECRET_KEY_BASE= OTP_SECRET= @@ -76,7 +76,7 @@ OTP_SECRET= # Web Push # -------- -# Generate with `rake mastodon:webpush:generate_vapid_key` (first is the private key, second is the public one) +# Generate with `bundle exec rails mastodon:webpush:generate_vapid_key` (first is the private key, second is the public one) # You should only generate this once per instance. If you later decide to change it, all push subscription will # be invalidated, requiring the users to access the website again to resubscribe. # -------- diff --git a/Gemfile.lock b/Gemfile.lock index eb6720e454..c0fa8a6039 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -793,10 +793,10 @@ GEM redis (>= 4.5.0, < 5) sidekiq-bulk (0.2.0) sidekiq - sidekiq-scheduler (5.0.3) + sidekiq-scheduler (5.0.5) rufus-scheduler (~> 3.2) sidekiq (>= 6, < 8) - tilt (>= 1.4.0) + tilt (>= 1.4.0, < 3) sidekiq-unique-jobs (7.1.33) brpoplpush-redis_script (> 0.1.1, <= 2.0.0) concurrent-ruby (~> 1.0, >= 1.0.5) diff --git a/app/javascript/mastodon/features/status/components/card.jsx b/app/javascript/mastodon/features/status/components/card.jsx index f0ae40cbc4..ee1fbe0f8f 100644 --- a/app/javascript/mastodon/features/status/components/card.jsx +++ b/app/javascript/mastodon/features/status/components/card.jsx @@ -141,7 +141,7 @@ export default class Card extends PureComponent { const showAuthor = !!card.getIn(['authors', 0, 'accountId']); const description = ( -
+
{provider} {card.get('published_at') && <> · } diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json index 575c68de03..60788baff8 100644 --- a/app/javascript/mastodon/locales/ja.json +++ b/app/javascript/mastodon/locales/ja.json @@ -411,6 +411,7 @@ "limited_account_hint.action": "構わず表示する", "limited_account_hint.title": "このプロフィールは{domain}のモデレーターによって非表示にされています。", "link_preview.author": "{name}", + "link_preview.more_from_author": "{name}さんの投稿をもっと読む", "lists.account.add": "リストに追加", "lists.account.remove": "リストから外す", "lists.delete": "リストを削除", @@ -693,6 +694,7 @@ "server_banner.administered_by": "管理者", "server_banner.server_stats": "サーバーの情報", "sign_in_banner.create_account": "アカウント作成", + "sign_in_banner.follow_anyone": "連合内の誰でもフォローして投稿を時系列で見ることができます。アルゴリズム、広告、クリックベイトはありません。", "sign_in_banner.sign_in": "ログイン", "sign_in_banner.sso_redirect": "ログインまたは登録", "status.admin_account": "@{name}さんのモデレーション画面を開く", diff --git a/app/javascript/mastodon/locales/uk.json b/app/javascript/mastodon/locales/uk.json index 150b808f89..5d7d040fd9 100644 --- a/app/javascript/mastodon/locales/uk.json +++ b/app/javascript/mastodon/locales/uk.json @@ -32,7 +32,7 @@ "account.featured_tags.last_status_never": "Немає дописів", "account.featured_tags.title": "{name} виділяє хештеґи", "account.follow": "Підписатися", - "account.follow_back": "Підписатися взаємно", + "account.follow_back": "Стежити також", "account.followers": "Підписники", "account.followers.empty": "Ніхто ще не підписаний на цього користувача.", "account.followers_counter": "{count, plural, one {{counter} підписник} few {{counter} підписники} many {{counter} підписників} other {{counter} підписники}}", diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss index ed99ec9773..8c7b7e52ae 100644 --- a/app/javascript/styles/mastodon/components.scss +++ b/app/javascript/styles/mastodon/components.scss @@ -10453,7 +10453,7 @@ noscript { gap: 4px; dt { - flex: 0 0 auto; + flex: 0 1 auto; color: $dark-text-color; min-width: 0; overflow: hidden; diff --git a/app/lib/link_details_extractor.rb b/app/lib/link_details_extractor.rb index dbfdd33fcc..a62ede2bb1 100644 --- a/app/lib/link_details_extractor.rb +++ b/app/lib/link_details_extractor.rb @@ -156,11 +156,11 @@ class LinkDetailsExtractor end def title - html_entities.decode(structured_data&.headline || opengraph_tag('og:title') || document.xpath('//title').map(&:content).first).strip + html_entities_decode(structured_data&.headline || opengraph_tag('og:title') || document.xpath('//title').map(&:content).first)&.strip end def description - html_entities.decode(structured_data&.description || opengraph_tag('og:description') || meta_tag('description')) + html_entities_decode(structured_data&.description || opengraph_tag('og:description') || meta_tag('description')) end def published_at @@ -180,7 +180,7 @@ class LinkDetailsExtractor end def provider_name - html_entities.decode(structured_data&.publisher_name || opengraph_tag('og:site_name')) + html_entities_decode(structured_data&.publisher_name || opengraph_tag('og:site_name')) end def provider_url @@ -188,7 +188,7 @@ class LinkDetailsExtractor end def author_name - html_entities.decode(structured_data&.author_name || opengraph_tag('og:author') || opengraph_tag('og:author:username')) + html_entities_decode(structured_data&.author_name || opengraph_tag('og:author') || opengraph_tag('og:author:username')) end def author_url @@ -257,7 +257,7 @@ class LinkDetailsExtractor next if json_ld.blank? - structured_data = StructuredData.new(html_entities.decode(json_ld)) + structured_data = StructuredData.new(html_entities_decode(json_ld)) next unless structured_data.valid? @@ -273,10 +273,11 @@ class LinkDetailsExtractor end def detect_encoding_and_parse_document - [detect_encoding, nil, @html_charset, 'UTF-8'].uniq.each do |encoding| + [detect_encoding, nil, @html_charset].uniq.each do |encoding| document = Nokogiri::HTML(@html, nil, encoding) return document if document.to_s.valid_encoding? end + Nokogiri::HTML(@html, nil, 'UTF-8') end def detect_encoding @@ -290,6 +291,15 @@ class LinkDetailsExtractor end end + def html_entities_decode(string) + return if string.nil? + + unicode_string = string.encode('UTF-8') + raise EncodingError, 'cannot convert string to valid UTF-8' unless unicode_string.valid_encoding? + + html_entities.decode(unicode_string) + end + def html_entities @html_entities ||= HTMLEntities.new(:expanded) end diff --git a/app/services/fetch_link_card_service.rb b/app/services/fetch_link_card_service.rb index 8bc9f912c5..436e024c99 100644 --- a/app/services/fetch_link_card_service.rb +++ b/app/services/fetch_link_card_service.rb @@ -32,7 +32,7 @@ class FetchLinkCardService < BaseService end attach_card if @card&.persisted? - rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, Encoding::UndefinedConversionError => e + rescue HTTP::Error, OpenSSL::SSL::SSLError, Addressable::URI::InvalidURIError, Mastodon::HostValidationError, Mastodon::LengthValidationError, EncodingError => e Rails.logger.debug { "Error fetching link #{@original_url}: #{e}" } nil end diff --git a/config/initializers/vapid.rb b/config/initializers/vapid.rb index 7dd870c8b7..551ede34fb 100644 --- a/config/initializers/vapid.rb +++ b/config/initializers/vapid.rb @@ -5,7 +5,7 @@ Rails.application.configure do # You should only generate this once per instance. If you later decide to change it, all push subscription will # be invalidated, requiring the users to access the website again to resubscribe. # - # Generate with `rake mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web rake mastodon:webpush:generate_vapid_key` if you use docker compose) + # Generate with `bundle exec rails mastodon:webpush:generate_vapid_key` task (`docker-compose run --rm web bundle exec rails mastodon:webpush:generate_vapid_key` if you use docker compose) # # For more information visit https://rossta.net/blog/using-the-web-push-api-with-vapid.html diff --git a/config/locales/gl.yml b/config/locales/gl.yml index ad4744e15b..c9f08dcad7 100644 --- a/config/locales/gl.yml +++ b/config/locales/gl.yml @@ -31,18 +31,18 @@ gl: created_msg: Nota de moderación creada correctamente! destroyed_msg: Nota de moderación eliminada de xeito correcto! accounts: - add_email_domain_block: Bloquear o dominio do email + add_email_domain_block: Bloquear o dominio do enderezo approve: Aprobar approved_msg: Aprobada a solicitude de rexistro de %{username} are_you_sure: Está segura? avatar: Imaxe de perfil by_domain: Dominio change_email: - changed_msg: Email mudado de xeito correcto! - current_email: Email actual - label: Mudar email - new_email: Novo email - submit: Mudar email + changed_msg: Correo cambiado de xeito correcto! + current_email: Enderezo actual + label: Cambiar de enderezo + new_email: Novo enderezo + submit: Cambiar de enderezo title: Mudar email de %{username} change_role: changed_msg: Rol mudado correctamente! @@ -64,10 +64,10 @@ gl: display_name: Nome a amosar domain: Dominio edit: Editar - email: Email - email_status: Estado do email + email: Enderezo de correo + email_status: Estado do correo enable: Activar - enable_sign_in_token_auth: Activar autenticación cun token no email + enable_sign_in_token_auth: Activar autenticación cun token no correo enabled: Activado enabled_msg: Desbloqueada a conta de %{username} followers: Seguidoras @@ -132,7 +132,7 @@ gl: resubscribe: Resubscribir role: Rol search: Procurar - search_same_email_domain: Outras usuarias co mesmo dominio de email + search_same_email_domain: Outras usuarias co mesmo dominio de correo search_same_ip: Outras usuarias co mesmo IP security: Seguridade security_measures: @@ -154,9 +154,9 @@ gl: suspension_irreversible: Elimináronse de xeito irreversible os datos desta conta. Podes reactivar a conta para facela usable novamente pero non recuperará os datos eliminados. suspension_reversible_hint_html: Esta conta foi suspendida, e os datos serán totalmente eliminados o %{date}. Ata entón, a conta pode ser restaurada sen danos. Se desexas eliminar agora mesmo todos os datos da conta, podes facelo aquí embaixo. title: Contas - unblock_email: Desbloquear enderezo de email - unblocked_email_msg: Enderezo de email de %{username} desbloqueado - unconfirmed_email: Email non confirmado + unblock_email: Desbloquear enderezo de correo + unblocked_email_msg: Enderezo de correo de %{username} desbloqueado + unconfirmed_email: Enderezo de correo sen confirmar undo_sensitized: Desmarcar como sensible undo_silenced: Desfacer acalar undo_suspension: Desfacer suspensión @@ -173,12 +173,12 @@ gl: approve_appeal: Aprobar apelación approve_user: Aprobar Usuaria assigned_to_self_report: Asignar denuncia - change_email_user: Editar email da usuaria + change_email_user: Editar correo electrónico da usuaria change_role_user: Cambiar Rol da Usuaria confirm_user: Confirmar usuaria create_account_warning: Crear aviso create_announcement: Crear anuncio - create_canonical_email_block: Crear Bloqueo de email + create_canonical_email_block: Crear Bloqueo de correo electrónico create_custom_emoji: Crear emoticonas personalizadas create_domain_allow: Crear Dominio Permitido create_domain_block: Crear bloquedo do Dominio @@ -188,7 +188,7 @@ gl: create_user_role: Crear Rol demote_user: Degradar usuaria destroy_announcement: Eliminar anuncio - destroy_canonical_email_block: Eliminar Bloqueo de email + destroy_canonical_email_block: Eliminar Bloqueo de correo electrónico destroy_custom_emoji: Eliminar emoticona personalizada destroy_domain_allow: Eliminar Dominio permitido destroy_domain_block: Eliminar bloqueo do Dominio @@ -200,7 +200,7 @@ gl: destroy_user_role: Eliminar Rol disable_2fa_user: Desactivar 2FA disable_custom_emoji: Desactivar emoticona personalizada - disable_sign_in_token_auth_user: Desactivar Autenticación por token no email para Usuaria + disable_sign_in_token_auth_user: Desactivar Autenticación con token no correo para Usuaria disable_user: Desactivar usuaria enable_custom_emoji: Activar emoticona personalizada enable_sign_in_token_auth_user: Activar Autenticación con token no email para Usuaria @@ -211,14 +211,14 @@ gl: reject_user: Rexeitar Usuaria remove_avatar_user: Eliminar avatar reopen_report: Reabrir denuncia - resend_user: Reenviar o email de confirmación + resend_user: Reenviar o correo de confirmación reset_password_user: Restabelecer contrasinal resolve_report: Resolver denuncia sensitive_account: Marca o multimedia da túa conta como sensible silence_account: Silenciar conta suspend_account: Suspender conta unassigned_report: Desasignar denuncia - unblock_email_account: Desbloquear enderezo de email + unblock_email_account: Desbloquear enderezo de correo unsensitive_account: Retira a marca de sensible do multimedia da conta unsilence_account: Deixar de silenciar conta unsuspend_account: Retirar suspensión de conta @@ -660,7 +660,7 @@ gl: delete_data_html: Eliminar o perfil e contidos de @%{acct} para os próximos 30 días a non ser que sexa suspendida nese período preview_preamble_html: "@%{acct} vai recibir un aviso co seguinte contido:" record_strike_html: Anotar un aviso contra @%{acct} para axudarche a xestionar futuros problemas con esta conta - send_email_html: Enviar un email de aviso a @%{acct} + send_email_html: Enviar un correo de aviso a @%{acct} warning_placeholder: Razóns adicionais optativas para a acción de moderación. target_origin: Orixe da conta denunciada title: Denuncias @@ -1060,7 +1060,7 @@ gl: redirect_to_app_html: Ímoste redirixir á app %{app_name}. Se iso non acontece, proba %{clicking_this_link} ou volve ti manualmente á app. registration_complete: Completouse a creación da conta en %{domain}! welcome_title: Benvida, %{name}! - wrong_email_hint: Se o enderezo de email non é correcto, podes cambialo nos axustes da conta. + wrong_email_hint: Se o enderezo de correo non é correcto, podes cambialo nos axustes da conta. delete_account: Eliminar conta delete_account_html: Se queres eliminar a túa conta, podes facelo aquí. Deberás confirmar a acción. description: diff --git a/config/locales/simple_form.gl.yml b/config/locales/simple_form.gl.yml index 0411c45bc1..e46ccb8737 100644 --- a/config/locales/simple_form.gl.yml +++ b/config/locales/simple_form.gl.yml @@ -255,7 +255,7 @@ gl: require_invite_text: Pedir unha razón para unirse show_domain_blocks: Amosar dominios bloqueados show_domain_blocks_rationale: Explicar porque están bloqueados os dominios - site_contact_email: Email de contacto + site_contact_email: Correo de contacto site_contact_username: Nome do contacto site_extended_description: Descrición ampla site_short_description: Descrición do servidor diff --git a/config/routes/settings.rb b/config/routes/settings.rb index 9aac8ef690..1679c689c8 100644 --- a/config/routes/settings.rb +++ b/config/routes/settings.rb @@ -37,13 +37,13 @@ namespace :settings do end end - resource :otp_authentication, only: [:show, :create], controller: 'two_factor_authentication/otp_authentication' + scope module: :two_factor_authentication do + resource :otp_authentication, only: [:show, :create], controller: :otp_authentication - resources :webauthn_credentials, only: [:index, :new, :create, :destroy], - path: 'security_keys', - controller: 'two_factor_authentication/webauthn_credentials' do - collection do - get :options + resources :webauthn_credentials, only: [:index, :new, :create, :destroy], path: 'security_keys' do + collection do + get :options + end end end diff --git a/spec/fixtures/requests/latin1_posing_as_utf8_broken.txt b/spec/fixtures/requests/latin1_posing_as_utf8_broken.txt new file mode 100644 index 0000000000..ed8a4716a3 --- /dev/null +++ b/spec/fixtures/requests/latin1_posing_as_utf8_broken.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +server: nginx +date: Thu, 13 Jun 2024 14:33:13 GMT +content-type: text/html; charset=utf-8 +content-length: 158 +accept-ranges: bytes + + + + + + Tofu l'orange + + +

Tofu l'orange

+ + diff --git a/spec/fixtures/requests/latin1_posing_as_utf8_recoverable.txt b/spec/fixtures/requests/latin1_posing_as_utf8_recoverable.txt new file mode 100644 index 0000000000..a24985832c --- /dev/null +++ b/spec/fixtures/requests/latin1_posing_as_utf8_recoverable.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +server: nginx +date: Thu, 13 Jun 2024 14:33:13 GMT +content-type: text/html; charset=utf-8 +content-length: 158 +accept-ranges: bytes + + + + + + Tofu with orange sauce + + +

Tofu l'orange

+ + diff --git a/spec/fixtures/requests/page_without_title.txt b/spec/fixtures/requests/page_without_title.txt new file mode 100644 index 0000000000..0054aa3b7e --- /dev/null +++ b/spec/fixtures/requests/page_without_title.txt @@ -0,0 +1,17 @@ +HTTP/1.1 200 OK +server: nginx +date: Thu, 13 Jun 2024 14:33:13 GMT +content-type: text/html; charset=utf-8 +content-length: 171 +accept-ranges: bytes + + + + + + + +

I am not a valid page

+

Thankfully, browsers do not care

+ + diff --git a/spec/services/fetch_link_card_service_spec.rb b/spec/services/fetch_link_card_service_spec.rb index d83a527514..b2cd99cea6 100644 --- a/spec/services/fetch_link_card_service_spec.rb +++ b/spec/services/fetch_link_card_service_spec.rb @@ -27,7 +27,10 @@ RSpec.describe FetchLinkCardService do stub_request(:get, 'http://example.com/koi8-r').to_return(request_fixture('koi8-r.txt')) stub_request(:get, 'http://example.com/windows-1251').to_return(request_fixture('windows-1251.txt')) stub_request(:get, 'http://example.com/low_confidence_latin1').to_return(request_fixture('low_confidence_latin1.txt')) + stub_request(:get, 'http://example.com/latin1_posing_as_utf8_broken').to_return(request_fixture('latin1_posing_as_utf8_broken.txt')) + stub_request(:get, 'http://example.com/latin1_posing_as_utf8_recoverable').to_return(request_fixture('latin1_posing_as_utf8_recoverable.txt')) stub_request(:get, 'http://example.com/aergerliche-umlaute').to_return(request_fixture('redirect_with_utf8_url.txt')) + stub_request(:get, 'http://example.com/page_without_title').to_return(request_fixture('page_without_title.txt')) Rails.cache.write('oembed_endpoint:example.com', oembed_cache) if oembed_cache @@ -110,6 +113,14 @@ RSpec.describe FetchLinkCardService do end end + context 'with a page that has no title' do + let(:status) { Fabricate(:status, text: 'http://example.com/page_without_title') } + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end + context 'with a 404 URL' do let(:status) { Fabricate(:status, text: 'http://example.com/not-found') } @@ -159,10 +170,30 @@ RSpec.describe FetchLinkCardService do end context 'with a URL of a page in ISO-8859-1 encoding, that charlock_holmes cannot detect' do - let(:status) { Fabricate(:status, text: 'Check out http://example.com/low_confidence_latin1') } + context 'when encoding in http header is correct' do + let(:status) { Fabricate(:status, text: 'Check out http://example.com/low_confidence_latin1') } - it 'decodes the HTML' do - expect(status.preview_card.title).to eq("Tofu á l'orange") + it 'decodes the HTML' do + expect(status.preview_card.title).to eq("Tofu á l'orange") + end + end + + context 'when encoding in http header is incorrect' do + context 'when encoding problems appear in unrelated tags' do + let(:status) { Fabricate(:status, text: 'Check out http://example.com/latin1_posing_as_utf8_recoverable') } + + it 'decodes the HTML' do + expect(status.preview_card.title).to eq('Tofu with orange sauce') + end + end + + context 'when encoding problems appear in title tag' do + let(:status) { Fabricate(:status, text: 'Check out http://example.com/latin1_posing_as_utf8_broken') } + + it 'does not create a preview card' do + expect(status.preview_card).to be_nil + end + end end end diff --git a/yarn.lock b/yarn.lock index 59bee13c08..6e9a3fc617 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6752,16 +6752,16 @@ __metadata: languageName: node linkType: hard -"cssnano-preset-default@npm:^7.0.3": - version: 7.0.3 - resolution: "cssnano-preset-default@npm:7.0.3" +"cssnano-preset-default@npm:^7.0.4": + version: 7.0.4 + resolution: "cssnano-preset-default@npm:7.0.4" dependencies: browserslist: "npm:^4.23.1" css-declaration-sorter: "npm:^7.2.0" cssnano-utils: "npm:^5.0.0" postcss-calc: "npm:^10.0.0" postcss-colormin: "npm:^7.0.1" - postcss-convert-values: "npm:^7.0.1" + postcss-convert-values: "npm:^7.0.2" postcss-discard-comments: "npm:^7.0.1" postcss-discard-duplicates: "npm:^7.0.0" postcss-discard-empty: "npm:^7.0.0" @@ -6788,7 +6788,7 @@ __metadata: postcss-unique-selectors: "npm:^7.0.1" peerDependencies: postcss: ^8.4.31 - checksum: 10c0/ab3e51003efed6542a12d43c10ca693ab26138a1d035697b9be8f07e084e37a78617cbb8028b0a7e7841302ec151f4ecf35cbd763efe291846b62c35ea4c0bb4 + checksum: 10c0/0083821e778bdf7b8aa9589408a01a717be730f73584e7b81756a6fcf87af05b8f17342025e666572a8d573cc30783f2d817b0f7ad63670398bc3135b017ccad languageName: node linkType: hard @@ -6802,14 +6802,14 @@ __metadata: linkType: hard "cssnano@npm:^7.0.0": - version: 7.0.3 - resolution: "cssnano@npm:7.0.3" + version: 7.0.4 + resolution: "cssnano@npm:7.0.4" dependencies: - cssnano-preset-default: "npm:^7.0.3" + cssnano-preset-default: "npm:^7.0.4" lilconfig: "npm:^3.1.2" peerDependencies: postcss: ^8.4.31 - checksum: 10c0/4cbcd1e0ebe0bd83196cc5b16b3a60d3ebc98326c79b2f71df597bb73c8e3ee1f42b89159d7a038acc398251184d648d9dd516f4194e46746f3af6fa74b4aec7 + checksum: 10c0/3939a0b37b11cb4bae92f7916517c7ba21257551f92517b49a640d5df32e855fb7e73321f4be44d2c2de578309c05d711cdcb1976e95607b1b7f92bd4cbd1350 languageName: node linkType: hard @@ -13363,15 +13363,15 @@ __metadata: languageName: node linkType: hard -"postcss-convert-values@npm:^7.0.1": - version: 7.0.1 - resolution: "postcss-convert-values@npm:7.0.1" +"postcss-convert-values@npm:^7.0.2": + version: 7.0.2 + resolution: "postcss-convert-values@npm:7.0.2" dependencies: browserslist: "npm:^4.23.1" postcss-value-parser: "npm:^4.2.0" peerDependencies: postcss: ^8.4.31 - checksum: 10c0/612f025f179f0f2ad7365db8c0b423614dcb8e1e4061875a4691a39dede0bca758d1a8f9f5c8b08e12af053e9e884f65ca5626ccc723d5b3f420650d67fe3046 + checksum: 10c0/beb59faf6aae97e6d3c233c5e6ed06cc60d65c49eec576036e3d0da1a831a1e827e3d41f5e81d016440b4f0bdf1406268ae069c4d5b38a6667b310c3da079d22 languageName: node linkType: hard