Merge pull request #2555 from ClearlyClaire/glitch-soc/merge-upstream

Merge upstream changes up to 092bb8a27a
shrike
Claire 2024-01-03 19:36:37 +01:00 committed by GitHub
commit 0cf9f3200c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 186 additions and 24 deletions

View File

@ -544,7 +544,7 @@ GEM
psych (5.1.2)
stringio
public_suffix (5.0.4)
puma (6.4.0)
puma (6.4.1)
nio4r (~> 2.0)
pundit (2.3.1)
activesupport (>= 3.0.0)

View File

@ -91,14 +91,23 @@ module SignatureVerification
raise SignatureVerificationError, "Public key not found for key #{signature_params['keyId']}" if actor.nil?
signature = Base64.decode64(signature_params['signature'])
compare_signed_string = build_signed_string
compare_signed_string = build_signed_string(include_query_string: true)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
# Compatibility quirk with older Mastodon versions
compare_signed_string = build_signed_string(include_query_string: false)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
actor = stoplight_wrap_request { actor_refresh_key!(actor) }
raise SignatureVerificationError, "Could not refresh public key #{signature_params['keyId']}" if actor.nil?
compare_signed_string = build_signed_string(include_query_string: true)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
# Compatibility quirk with older Mastodon versions
compare_signed_string = build_signed_string(include_query_string: false)
return actor unless verify_signature(actor, signature, compare_signed_string).nil?
fail_with! "Verification failed for #{actor.to_log_human_identifier} #{actor.uri} using rsa-sha256 (RSASSA-PKCS1-v1_5 with SHA-256)", signed_string: compare_signed_string, signature: signature_params['signature']
@ -180,11 +189,18 @@ module SignatureVerification
nil
end
def build_signed_string
def build_signed_string(include_query_string: true)
signed_headers.map do |signed_header|
case signed_header
when Request::REQUEST_TARGET
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
if include_query_string
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.original_fullpath}"
else
# Current versions of Mastodon incorrectly omit the query string from the (request-target) pseudo-header.
# Therefore, temporarily support such incorrect signatures for compatibility.
# TODO: remove eventually some time after release of the fixed version
"#{Request::REQUEST_TARGET}: #{request.method.downcase} #{request.path}"
end
when '(created)'
raise SignatureVerificationError, 'Invalid pseudo-header (created) for rsa-sha256' unless signature_algorithm == 'hs2019'
raise SignatureVerificationError, 'Pseudo-header (created) used but corresponding argument missing' if signature_params['created'].blank?

View File

@ -1,23 +1,84 @@
{
"account.add_or_remove_from_list": "Adder o remover ab listas",
"account.badges.group": "Gruppo",
"account.block": "Blocar @{name}",
"account.block_short": "Blocar",
"account.blocked": "Blocate",
"account.copy": "Copiar ligamine a profilo",
"account.edit_profile": "Modificar profilo",
"account.go_to_profile": "Vader al profilo",
"account.moved_to": "{name} indicava que lor nove conto ora es:",
"account.share": "Compartir profilo de @{name}",
"account.unblock": "Disblocar @{name}",
"account.unblock_short": "Disblocar",
"account.unendorse": "Non evidentiar sur le profilo",
"account_note.placeholder": "Clicca pro adder un nota",
"admin.dashboard.retention.cohort_size": "Nove usatores",
"audio.hide": "Celar audio",
"autosuggest_hashtag.per_week": "{count} per septimana",
"bundle_column_error.network.title": "Error de rete",
"bundle_column_error.retry": "Tentar novemente",
"bundle_column_error.return": "Retornar al initio",
"bundle_modal_error.close": "Clauder",
"bundle_modal_error.retry": "Tentar novemente",
"column.blocks": "Usatores blocate",
"column.directory": "Navigar profilos",
"column.favourites": "Favoritos",
"column.home": "Initio",
"column.lists": "Listas",
"column.notifications": "Notificationes",
"column_header.hide_settings": "Celar le parametros",
"column_header.show_settings": "Monstrar le parametros",
"column_subheading.settings": "Parametros",
"compose.language.change": "Cambiar le lingua",
"compose.language.search": "Cercar linguas...",
"compose.published.open": "Aperir",
"compose_form.poll.add_option": "Adder un option",
"compose_form.poll.remove_option": "Remover iste option",
"confirmation_modal.cancel": "Cancellar",
"confirmations.delete.confirm": "Deler",
"confirmations.delete_list.confirm": "Deler",
"confirmations.logout.confirm": "Clauder le session",
"copy_icon_button.copied": "Copiate al area de transferentia",
"copypaste.copy_to_clipboard": "Copiar al area de transferentia",
"disabled_account_banner.account_settings": "Parametros de conto",
"dismissable_banner.dismiss": "Dimitter",
"emoji_button.activity": "Activitate",
"emoji_button.custom": "Personalisate",
"emoji_button.search_results": "Resultatos de recerca",
"empty_column.account_unavailable": "Profilo non disponibile",
"errors.unexpected_crash.report_issue": "Signalar un defecto",
"explore.search_results": "Resultatos de recerca",
"explore.trending_links": "Novas",
"firehose.all": "Toto",
"firehose.local": "Iste servitor",
"firehose.remote": "Altere servitores",
"footer.about": "A proposito de",
"footer.directory": "Directorio de profilos",
"footer.privacy_policy": "Politica de confidentialitate",
"footer.source_code": "Vider le codice fonte",
"footer.status": "Stato",
"home.pending_critical_update.link": "Vider actualisationes",
"keyboard_shortcuts.my_profile": "Aperir tu profilo",
"lightbox.close": "Clauder",
"lightbox.next": "Sequente",
"link_preview.author": "Per {name}",
"lists.account.add": "Adder al lista",
"navigation_bar.about": "A proposito de"
"mute_modal.duration": "Duration",
"mute_modal.hide_notifications": "Celar notificationes de iste usator?",
"navigation_bar.about": "A proposito de",
"navigation_bar.advanced_interface": "Aperir in un interfacie web avantiate",
"navigation_bar.blocks": "Usatores blocate",
"navigation_bar.favourites": "Favoritos",
"navigation_bar.lists": "Listas",
"navigation_bar.logout": "Clauder le session",
"navigation_bar.preferences": "Preferentias",
"navigation_bar.security": "Securitate",
"notifications.column_settings.alert": "Notificationes de scriptorio",
"notifications.column_settings.filter_bar.advanced": "Monstrar tote le categorias",
"notifications.column_settings.sound": "Reproducer sono",
"notifications.filter.all": "Toto",
"onboarding.compose.template": "Salute #Mastodon!",
"onboarding.profile.save_and_continue": "Salvar e continuar",
"onboarding.share.title": "Compartir tu profilo"
}

View File

@ -2,7 +2,7 @@
"about.blocks": "Prižiūrimi serveriai",
"about.contact": "Kontaktuoti:",
"about.disclaimer": "Mastodon nemokama atvirojo kodo programa ir Mastodon gGmbH prekės ženklas.",
"about.domain_blocks.no_reason_available": "Priežastis nežinoma",
"about.domain_blocks.no_reason_available": "Priežastis nepateikta",
"about.domain_blocks.preamble": "Mastodon paprastai leidžia peržiūrėti turinį ir bendrauti su naudotojais iš bet kurio kito fediverse esančio serverio. Šios yra išimtys, kurios buvo padarytos šiame konkrečiame serveryje.",
"about.domain_blocks.silenced.explanation": "Paprastai nematysi profilių ir turinio iš šio serverio, nebent jį aiškiai ieškosi arba pasirinksi jį sekdamas (-a).",
"about.domain_blocks.silenced.title": "Ribota",
@ -35,7 +35,7 @@
"account.follow_back": "Sekti atgal",
"account.followers": "Sekėjai",
"account.followers.empty": "Šio naudotojo dar niekas neseka.",
"account.followers_counter": "{count, plural, one {{counter} sekėjas (-a)} few {{counter} sekėjai} many {{counter} sekėjo} other {{counter} sekėjų}}",
"account.followers_counter": "{count, plural, one {{counter} sekėjas} few {{counter} sekėjai} many {{counter} sekėjo} other {{counter} sekėjų}}",
"account.following": "Seka",
"account.following_counter": "{count, plural, one {{counter} Seka} few {{counter} Seka} many {{counter} Seka} other {{counter} Seka}}",
"account.follows.empty": "Šis (-i) naudotojas (-a) dar nieko neseka.",

View File

@ -78,11 +78,11 @@
"bundle_modal_error.close": "மூடுக",
"bundle_modal_error.message": "இக்கூற்றை ஏற்றம் செய்யும்பொழுது ஏதோ தவறு ஏற்பட்டுள்ளது.",
"bundle_modal_error.retry": "மீண்டும் முயற்சி செய்",
"closed_registrations.other_server_instructions": "மசுடோடன் இரு பரவலாக்கப்பட்ட மென்பொருள் என்பதால், நீங்கள் வேரு ஒரு வழங்கியில் கணக்கை உருவாக்கியிருந்தாலும் இந்த வழங்கியில் பயன்படுத்தலாம்.",
"closed_registrations_modal.description": "{domain} இல் இப்பொழுது கணக்குகள் உருவாக்க முடியாது. நீங்கள் மசுடோடன் பயன்படுத்த, குறிப்பாக {domain} முகவரியில் கணக்கைத் துவங்க வேண்டும் என்ற அவசியமில்லை என்பதை மனதில் வைத்துக் கொள்ளவும்.",
"closed_registrations.other_server_instructions": "மேச்டடான் இரு பரவலாக்கப்பட்ட மென்பொருள் என்பதால், நீங்கள் வேரு ஒரு வழங்கியில் கணக்கை உருவாக்கியிருந்தாலும் இந்த வழங்கியில் பயன்படுத்தலாம்.",
"closed_registrations_modal.description": "{domain} இல் இப்பொழுது கணக்குகள் உருவாக்க முடியாது. நீங்கள் மேச்டடான் பயன்படுத்த, குறிப்பாக {domain} முகவரியில் கணக்கைத் துவங்க வேண்டும் என்ற அவசியமில்லை என்பதை மனதில் வைத்துக் கொள்ளவும்.",
"closed_registrations_modal.find_another_server": "வேறொரு வழங்கியைக் கண்டுபிடி",
"closed_registrations_modal.preamble": "மசுடோடன் ஒரு பரவலாக்கப்பட்ட மென்பொருள். ஆதனால் நீங்கள் எங்குக் கணக்கை உருவாக்கினாலும் இந்த வழங்கியில் உள்ள யாருடன் வேண்டும் என்றாலும் உரவாடலாம். நீங்களே கூட ஒரு வழங்கியை நிறுவலாம்!",
"closed_registrations_modal.title": "மசுடோடன் கணக்கு துவங்கப்படுகிறது",
"closed_registrations_modal.preamble": "மேச்டடான் ஒரு பரவலாக்கப்பட்ட மென்பொருள். ஆதனால் நீங்கள் எங்குக் கணக்கை உருவாக்கினாலும் இந்த வழங்கியில் உள்ள யாருடன் வேண்டும் என்றாலும் உரவாடலாம். நீங்களே கூட ஒரு வழங்கியை நிறுவலாம்!",
"closed_registrations_modal.title": "மேச்டடான் கணக்கு துவங்கப்படுகிறது",
"column.about": "பற்றி",
"column.blocks": "தடுக்கப்பட்ட பயனர்கள்",
"column.bookmarks": "அடையாளக்குறிகள்",
@ -113,7 +113,7 @@
"compose.language.search": "தேடல் மொழிகள்...",
"compose.published.body": "பதிவிடப்பட்டது.",
"compose.published.open": "திற",
"compose.saved.body": "பதிவி சேமிக்கப்பட்டது.",
"compose.saved.body": "பதிவ சேமிக்கப்பட்டது.",
"compose_form.direct_message_warning_learn_more": "மேலும் அறிய",
"compose_form.encryption_warning": "Mastodonல் உள்ள பதிவுகள் முறையாக என்க்ரிப்ட்(encrypt) செய்யபடவில்லை. அதனால் முக்கிய தகவல்களை இங்கே பகிர வேண்டாம்.",
"compose_form.hashtag_warning": "This post won't be listed under any hashtag as it is unlisted. Only public posts can be searched by hashtag.",

View File

@ -77,6 +77,7 @@ class Request
@url = Addressable::URI.parse(url).normalize
@http_client = options.delete(:http_client)
@allow_local = options.delete(:allow_local)
@full_path = options.delete(:with_query_string)
@options = options.merge(socket_class: use_proxy? || @allow_local ? ProxySocket : Socket)
@options = @options.merge(timeout_class: PerOperationWithDeadline, timeout_options: TIMEOUT)
@options = @options.merge(proxy_url) if use_proxy?
@ -146,7 +147,7 @@ class Request
private
def set_common_headers!
@headers[REQUEST_TARGET] = "#{@verb} #{@url.path}"
@headers[REQUEST_TARGET] = request_target
@headers['User-Agent'] = Mastodon::Version.user_agent
@headers['Host'] = @url.host
@headers['Date'] = Time.now.utc.httpdate
@ -157,6 +158,14 @@ class Request
@headers['Digest'] = "SHA-256=#{Digest::SHA256.base64digest(@options[:body])}"
end
def request_target
if @url.query.nil? || !@full_path
"#{@verb} #{@url.path}"
else
"#{@verb} #{@url.path}?#{@url.query}"
end
end
def signature
algorithm = 'rsa-sha256'
signature = Base64.strict_encode64(@keypair.sign(OpenSSL::Digest.new('SHA256'), signed_string))

View File

@ -23,7 +23,7 @@ Rails.application.configure do
if Rails.env.production?
"ws#{https ? 's' : ''}://#{web_host}"
else
"ws://#{ENV['REMOTE_DEV'] == 'true' ? host.split(':').first : 'localhost'}:4000"
"ws://#{host.split(':').first}:4000"
end
end

View File

@ -70,6 +70,10 @@ ie:
invalid_redirect_uri: Li uri de redirection includet ne es valid.
invalid_request:
unknown: Li petition manca un postulat parametre, include un ne apoyat parametre-valore, o es altrimen mal format.
invalid_token:
expired: Li access-clave expirat
revoked: Li access-clave esset revocat
unknown: Li accesse-clave es ínvalid
unsupported_grant_type: Li tip de autorisation concedet ne es subtenet per li autorisant servitor.
unsupported_response_type: Li autorisant servitor ne subtene ti-ci tip de response.
flash:

View File

@ -68,6 +68,7 @@ ie:
form_admin_settings:
backups_retention_period: Mantener usator-generat archives por li specificat quantitá de dies.
bootstrap_timeline_accounts: Ti-ci contos va esser pinglat al parte superiori del recomandationes por nov usatores.
mascot: Substitue li ilustration in li avansat interfacie web.
peers_api_enabled: Un liste de nómines de dominia queles ti-ci servitor ha incontrat in li fediverse. Ci null data es includet pri ca tu confedera con un cert servitor o ne; it indica solmen que tui servitor conosse it. Usat per servicies colectent general statisticas pri federation.
profile_directory: Li profilarium monstra omni usatores volent esser decovribil.
site_contact_email: Qualmen on posse contacter te por inquestes legal o de apoy.
@ -110,6 +111,10 @@ ie:
name: Etiquette
value: Contenete
indexable: Includer public postas in resultates de sercha
account_alias:
acct: Usator-nómine del anteyan conto
account_migration:
acct: Usator-nómine del nov conto
account_warning_preset:
text: Textu prefigurat
title: Titul
@ -240,6 +245,7 @@ ie:
url: URL de punctu terminal
'no': 'No'
not_recommended: Ne recomandat
overridden: Substituet
recommended: Recomandat
required:
mark: "*"

View File

@ -192,7 +192,7 @@
"eslint-import-resolver-typescript": "^3.5.5",
"eslint-plugin-formatjs": "^4.10.1",
"eslint-plugin-import": "~2.29.0",
"eslint-plugin-jsdoc": "^47.0.0",
"eslint-plugin-jsdoc": "^48.0.0",
"eslint-plugin-jsx-a11y": "~6.8.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-promise": "~6.1.1",

View File

@ -20,7 +20,7 @@ describe 'Content-Security-Policy' do
"form-action 'self'",
"child-src 'self' blob: https://cb6e6126.ngrok.io",
"worker-src 'self' blob: https://cb6e6126.ngrok.io",
"connect-src 'self' data: blob: https://cb6e6126.ngrok.io ws://localhost:4000",
"connect-src 'self' data: blob: https://cb6e6126.ngrok.io ws://cb6e6126.ngrok.io:4000",
"script-src 'self' https://cb6e6126.ngrok.io 'wasm-unsafe-eval'"
)
end

View File

@ -94,6 +94,72 @@ describe 'signature verification concern' do
end
end
context 'with a valid signature on a GET request that has a query string' do
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
end
it 'successfuly verifies signature', :aggregate_failures do
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
get '/activitypub/success?foo=42', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Signature' => signature_header,
}
expect(response).to have_http_status(200)
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: actor.id.to_s
)
end
end
context 'when the query string is missing from the signature verification (compatibility quirk)' do
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="Z8ilar3J7bOwqZkMp7sL8sRs4B1FT+UorbmvWoE+A5UeoOJ3KBcUmbsh+k3wQwbP5gMNUrra9rEWabpasZGphLsbDxfbsWL3Cf0PllAc7c1c7AFEwnewtExI83/qqgEkfWc2z7UDutXc2NfgAx89Ox8DXU/fA2GG0jILjB6UpFyNugkY9rg6oI31UnvfVi3R7sr3/x8Ea3I9thPvqI2byF6cojknSpDAwYzeKdngX3TAQEGzFHz3SDWwyp3jeMWfwvVVbM38FxhvAnSumw7YwWW4L7M7h4M68isLimoT3yfCn2ucBVL5Dz8koBpYf/40w7QidClAwCafZQFC29yDOg=="' # rubocop:disable Layout/LineLength
end
it 'successfuly verifies signature', :aggregate_failures do
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
get '/activitypub/success?foo=42', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Signature' => signature_header,
}
expect(response).to have_http_status(200)
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: actor.id.to_s
)
end
end
context 'with mismatching query string' do
let(:signature_header) do
'keyId="https://remote.domain/users/bob#main-key",algorithm="rsa-sha256",headers="date host (request-target)",signature="SDMa4r/DQYMXYxVgYO2yEqGWWUXugKjVuz0I8dniQAk+aunzBaF2aPu+4grBfawAshlx1Xytl8lhb0H2MllEz16/tKY7rUrb70MK0w8ohXgpb0qs3YvQgdj4X24L1x2MnkFfKHR/J+7TBlnivq0HZqXm8EIkPWLv+eQxu8fbowLwHIVvRd/3t6FzvcfsE0UZKkoMEX02542MhwSif6cu7Ec/clsY9qgKahb9JVGOGS1op9Lvg/9y1mc8KCgD83U5IxVygYeYXaVQ6gixA9NgZiTCwEWzHM5ELm7w5hpdLFYxYOHg/3G3fiqJzpzNQAcCD4S4JxfE7hMI0IzVlNLT6A=="' # rubocop:disable Layout/LineLength
end
it 'fails to verify signature', :aggregate_failures do
expect(signature_header).to eq build_signature_string(actor_keypair, 'https://remote.domain/users/bob#main-key', 'get /activitypub/success?foo=42', { 'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT', 'Host' => 'www.example.com' })
get '/activitypub/success?foo=43', headers: {
'Host' => 'www.example.com',
'Date' => 'Wed, 20 Dec 2023 10:00:00 GMT',
'Signature' => signature_header,
}
expect(body_as_json).to match(
signed_request: true,
signature_actor_id: nil,
error: anything
)
end
end
context 'with a mismatching path' do
it 'fails to verify signature', :aggregate_failures do
get '/activitypub/alternative-path', headers: {

View File

@ -2369,7 +2369,7 @@ __metadata:
eslint-import-resolver-typescript: "npm:^3.5.5"
eslint-plugin-formatjs: "npm:^4.10.1"
eslint-plugin-import: "npm:~2.29.0"
eslint-plugin-jsdoc: "npm:^47.0.0"
eslint-plugin-jsdoc: "npm:^48.0.0"
eslint-plugin-jsx-a11y: "npm:~6.8.0"
eslint-plugin-prettier: "npm:^5.0.0"
eslint-plugin-promise: "npm:~6.1.1"
@ -7417,9 +7417,9 @@ __metadata:
languageName: node
linkType: hard
"eslint-plugin-jsdoc@npm:^47.0.0":
version: 47.0.1
resolution: "eslint-plugin-jsdoc@npm:47.0.1"
"eslint-plugin-jsdoc@npm:^48.0.0":
version: 48.0.1
resolution: "eslint-plugin-jsdoc@npm:48.0.1"
dependencies:
"@es-joy/jsdoccomment": "npm:~0.41.0"
are-docs-informative: "npm:^0.0.2"
@ -7432,7 +7432,7 @@ __metadata:
spdx-expression-parse: "npm:^4.0.0"
peerDependencies:
eslint: ^7.0.0 || ^8.0.0 || ^9.0.0
checksum: f2657cfc1394af0fca2c9c079065ffbc4e5fd41678299afb9ae261aff246cbd861d0ff38b57258caf6c9e9c57eb9975c75cd47c9f8c92bfefdaa4924eef62c24
checksum: 9b211cfb2e07e076dad12681cd2045c65766dd24fe9399fd0adeaf6f8785f9a4dd58608f1183195f63d3c6c91013aa1cf9edc9101580cff9cb60e1e688f456f9
languageName: node
linkType: hard
@ -14578,15 +14578,15 @@ __metadata:
linkType: hard
"sass@npm:^1.62.1":
version: 1.69.6
resolution: "sass@npm:1.69.6"
version: 1.69.7
resolution: "sass@npm:1.69.7"
dependencies:
chokidar: "npm:>=3.0.0 <4.0.0"
immutable: "npm:^4.0.0"
source-map-js: "npm:>=0.6.2 <2.0.0"
bin:
sass: sass.js
checksum: 8153db8e51e74a9007bb54332e14d122c34288c7d21a5f2eaefef753a1b7bb13f35e042dc6247253dab5b1550b05cea27970371e7548286b4f50f23dd1147d89
checksum: 773d0938e7d4ff3972d3fda3132f34fe98a2f712e028a58e28fecd615434795eff3266eddc38d5e13f03b90c0d6360d0e737b30bff2949a47280c64a18e0fb18
languageName: node
linkType: hard