Merge commit '491dd9764244c8adf37861f00d916c96bdbfdaf8' into glitch-soc/merge-upstream

Conflicts:
- `app/workers/scheduler/auto_close_registrations_scheduler.rb`:
  Changes were already cherry-picked and updated further in glitch-soc.
  Kept glitch-soc's version.
main-rebase-security-fix
Claire 2024-02-24 15:33:36 +01:00
commit 7901dc9e24
31 changed files with 2270 additions and 1848 deletions

View File

@ -1,4 +1 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
yarn lint-staged yarn lint-staged

View File

@ -966,6 +966,9 @@ da:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Grundet manglende nylig moderatoraktivitet er registreringsproceduren på %{instance} automatisk ændret til at kræve manuel gennemgang for at forhindre, at %{instance} bruges som platform for potentielle dårlige aktører. Proceduren kan til enhver tid ændre igen til åbne registreringer.
subject: Registreringsproceduren for %{instance} er automatisk ændret til at kræve godkendelse
new_appeal: new_appeal:
actions: actions:
delete_statuses: for sletning af vedkommendes indlæg delete_statuses: for sletning af vedkommendes indlæg

View File

@ -966,6 +966,9 @@ de:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Aufgrund fehlender Aktivität von Moderator*innen müssen neue Registrierungen auf %{instance} jetzt manuell genehmigt werden. Dies wurde automatisch umgestellt, damit %{instance} nicht als Plattform für Böswillige missbraucht werden kann. Du kannst jederzeit auf uneingeschränkte Registrierungen zurückwechseln.
subject: Registrierungen auf %{instance} erfordern jetzt eine manuelle Genehmigung (automatisch umgestellt)
new_appeal: new_appeal:
actions: actions:
delete_statuses: das Löschen der Beiträge delete_statuses: das Löschen der Beiträge

View File

@ -966,6 +966,9 @@ es-AR:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Debido a la falta de actividad reciente por parte de moderadores, los registros en %{instance} fueron cambiados automáticamente para requerir revisión manual, para evitar que %{instance} se use como una plataforma para potenciales malos actores. Podés volver a cambiar esto para abrir los registros en cualquier momento.
subject: Los registros de %{instance} se cambiaron automáticamente para requerir aprobación
new_appeal: new_appeal:
actions: actions:
delete_statuses: para eliminar sus mensajes delete_statuses: para eliminar sus mensajes

View File

@ -966,6 +966,9 @@ es-MX:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Debido a la falta de actividad reciente de los moderadores, se ha cambiado automáticamente el registro en %{instance} para requerir revisión manual, para evitar que la instancia %{instance} sea usada como plataforma para malos actores potenciales. Puedes volver a cambiarlo en cualquier momento para abrir de nuevo los registros.
subject: Se ha cambiado automáticamente el registro de %{instance} para requerir aprobación
new_appeal: new_appeal:
actions: actions:
delete_statuses: para eliminar sus mensajes delete_statuses: para eliminar sus mensajes

View File

@ -966,6 +966,9 @@ es:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Debido a la falta de actividad reciente de los moderadores, se ha cambiado automáticamente el registro en %{instance} para requerir revisión manual, para evitar que la instancia %{instance} sea usada como plataforma para malos actores potenciales. Puedes volver a cambiarlo en cualquier momento para abrir de nuevo los registros.
subject: Se ha cambiado automáticamente el registro de %{instance} para requerir aprobación
new_appeal: new_appeal:
actions: actions:
delete_statuses: para eliminar sus mensajes delete_statuses: para eliminar sus mensajes

View File

@ -966,6 +966,9 @@ fo:
title: Webhooks/vevhúkar title: Webhooks/vevhúkar
webhook: Webhook/vevhúkur webhook: Webhook/vevhúkur
admin_mailer: admin_mailer:
auto_close_registrations:
body: Vegna avmarkað virksemi hjá umsjónarfólki eru skrásetingar á %{instance} broyttar sjálvvirkandi til at krevja manuella eftirkanning fyri at forða at %{instance} verður brúktur sum ein pallur fyri ringar aktørar. Tú kanst skifta aftur til opnar skrásetingar tá tú vilt.
subject: Skrásetingar á %{instance} eru sjálvvirkandi broyttar soleiðis at tær krevja váttan
new_appeal: new_appeal:
actions: actions:
delete_statuses: at strika teirra postar delete_statuses: at strika teirra postar

View File

@ -966,6 +966,9 @@ hu:
title: Webhookok title: Webhookok
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: A közelmúlt moderátori tevékenységnek hiánya miatt %{instance} regisztráció automatikusan kézi ellenőrzést igénylőre vált, hogy megakadályozz, hogy a %{instance} platformot potenciális rossz szereplők számára használhasson. Bármikor visszakapcsolhatjuk a nyitott regisztrációkhoz.
subject: "%{instance} regisztráció automatikusan átállt jóváhagyást igénylőre."
new_appeal: new_appeal:
actions: actions:
delete_statuses: bejegyzések törléséről delete_statuses: bejegyzések törléséről

View File

@ -966,6 +966,9 @@ it:
title: Webhook title: Webhook
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: A causa della mancanza di attività recente da parte dei moderatori, le registrazioni su %{instance} sono passate automaticamente alla richiesta di revisione manuale, per evitare che %{instance} venga utilizzata come piattaforma per potenziali malintenzionati. Puoi ripristinarlo per aprire le registrazioni in qualsiasi momento.
subject: Le registrazioni per %{instance} sono passate automaticamente alla richiesta di approvazione
new_appeal: new_appeal:
actions: actions:
delete_statuses: per cancellare i loro post delete_statuses: per cancellare i loro post

View File

@ -948,6 +948,9 @@ ja:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: "%{instance} のモデレーターによる活動がしばらくなかったため、%{instance} のアカウント作成は手動での承認を必要とするように自動的に変更されました。これには %{instance} が悪意ある者の踏み台として使われることを防ぐ役割があります。アカウント作成は必要に応じていつでも再び開放できます。"
subject: "%{instance} のアカウント作成は自動的に承認制に変更されました"
new_appeal: new_appeal:
actions: actions:
delete_statuses: 投稿を削除する delete_statuses: 投稿を削除する

View File

@ -966,6 +966,9 @@ nl:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: In verband met een gebrek aan recentelijke moderator-activiteit, is de registratie-modus op %{instance} automatisch veranderd naar handmatige beoordeling door moderatoren. Dit om te voorkomen dat %{instance} als platform voor eventueel misbruik kan worden gebruikt. Je kunt op elk gewenst moment veel terugschakelen naar open registraties.
subject: De registratie-modus op %{instance} is automatisch veranderd naar handmatige beoordeling door moderatoren
new_appeal: new_appeal:
actions: actions:
delete_statuses: het verwijderen van diens berichten delete_statuses: het verwijderen van diens berichten

View File

@ -966,6 +966,9 @@ nn:
title: Webhooker title: Webhooker
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: På grunn av mangel på nyleg moderatoraktivitet, er registreringar på %{instance} automatisk bytt til å krevje manuell gjennomgang, for å hindre at %{instance} vert brukt som ein plattform for potensielle dårlege aktørar. Du kan byte tilbake for å opne registreringar når som helst.
subject: Registreringar for %{instance} er automatisk bytt til å krevje godkjenning
new_appeal: new_appeal:
actions: actions:
delete_statuses: å slette sine innlegg delete_statuses: å slette sine innlegg

View File

@ -1002,6 +1002,9 @@ pl:
title: Webhooki title: Webhooki
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Z powodu braku niedawnych działań moderacyjnych, rejestracje na %{instance} wymagają ręcznej weryfikacji (by uniknąć dystrybucji spamu itp.). W dowolnym momencie możesz przywrócić politykę otwartej rejestracji.
subject: "%{instance} zostało automatycznie przełączone na zatwierdzanie rejestracji"
new_appeal: new_appeal:
actions: actions:
delete_statuses: aby usunąć ich wpisy delete_statuses: aby usunąć ich wpisy

View File

@ -1002,6 +1002,9 @@ sl:
title: Spletne zanke title: Spletne zanke
webhook: Spletna zanka webhook: Spletna zanka
admin_mailer: admin_mailer:
auto_close_registrations:
body: Zaradi pomanjkanja moderiranja v zadnjem času, se je za strežnik %{instance} samodejno vklopilo ročno preverjanje in potrjevanje prijav. S tem se prepreči morebitno zlorabo strežnika %{instance}. Prijave lahko kadarkoli spet spremenite nazaj v odprte.
subject: Za strežnik %{instance} se je samodejno vklopilo ročno potrjevanje prijav
new_appeal: new_appeal:
actions: actions:
delete_statuses: brisanje njihovih objav, delete_statuses: brisanje njihovih objav,

View File

@ -962,6 +962,9 @@ sq:
title: Webhook-ë title: Webhook-ë
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Për shkak mungese veprimtarie moderatori së fundi, regjistrimet te %{instance} janë kaluar automatikisht të kërkojnë shqyrtim dorazi, për të penguar përdorimin e %{instance} si një platformë për aktorë të këqij. Mund të kaloni kurdo te regjistrime të hapura.
subject: Regjistrimet te %{instance} janë kaluar automatikisht të kërkojnë miratim
new_appeal: new_appeal:
actions: actions:
delete_statuses: fshirje e postimeve të tij delete_statuses: fshirje e postimeve të tij

View File

@ -1002,6 +1002,9 @@ uk:
title: Вебхуки title: Вебхуки
webhook: Вебхук webhook: Вебхук
admin_mailer: admin_mailer:
auto_close_registrations:
body: Через нестачу нещодавньої активності модератора реєстрація на %{instance} автоматично переключена на вимагу ручного відгуку, для запобігання використанню %{instance} як платформи для потенційних поганих гравців. Ви можете будь-коли переключитися на відкриті реєстрації.
subject: Реєстрації для %{instance} автоматично перейшли на такі, що вимагають схвалення
new_appeal: new_appeal:
actions: actions:
delete_statuses: щоб видалити їхні дописи delete_statuses: щоб видалити їхні дописи

View File

@ -948,6 +948,9 @@ vi:
title: Webhook title: Webhook
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: Do gần đây thiếu kiểm duyệt viên nên việc đăng ký trên %{instance} sẽ tự động chuyển thành duyệt thủ công, để tránh %{instance} bị sử dụng làm nền tảng cho những kẻ xấu. Bạn có thể chuyển nó trở lại trạng thái đăng ký mở bất kỳ lúc nào.
subject: Đăng ký mới %{instance} đã được tự động chuyển sang duyệt thủ công
new_appeal: new_appeal:
actions: actions:
delete_statuses: xóa tút của họ delete_statuses: xóa tút của họ

View File

@ -948,6 +948,9 @@ zh-CN:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: 由于近期缺乏管理员活动, %{instance} 上的注册已自动切换为需要手动审核,以防止 %{instance} 被潜在的不良行为者用作平台。您可以随时将其切换回开放注册。
subject: "%{instance} 的注册已自动切换为需要批准"
new_appeal: new_appeal:
actions: actions:
delete_statuses: 删除其嘟文 delete_statuses: 删除其嘟文

View File

@ -950,6 +950,9 @@ zh-TW:
title: Webhooks title: Webhooks
webhook: Webhook webhook: Webhook
admin_mailer: admin_mailer:
auto_close_registrations:
body: 由於近日缺少管理員活動,%{instance} 上之註冊已自動切換為需要人工審核,以防止 %{instance} 被作為潛在不良行為者之跳板。您隨時能將其切換回開放註冊。
subject: "%{instance} 之註冊已自動切換為需要審核"
new_appeal: new_appeal:
actions: actions:
delete_statuses: 要刪除他們的嘟文 delete_statuses: 要刪除他們的嘟文

View File

@ -1,7 +1,7 @@
{ {
"name": "@mastodon/mastodon", "name": "@mastodon/mastodon",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.0.2", "packageManager": "yarn@4.1.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@ -27,7 +27,7 @@
"lint:yml": "prettier --check \"**/*.{yaml,yml}\"", "lint:yml": "prettier --check \"**/*.{yaml,yml}\"",
"lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml", "lint": "yarn lint:js && yarn lint:json && yarn lint:sass && yarn lint:yml",
"postversion": "git push --tags", "postversion": "git push --tags",
"prepare": "husky install", "prepare": "husky",
"start": "node ./streaming/index.js", "start": "node ./streaming/index.js",
"test": "yarn lint && yarn run typecheck && yarn jest", "test": "yarn lint && yarn run typecheck && yarn jest",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
@ -176,8 +176,8 @@
"@types/redux-immutable": "^4.0.3", "@types/redux-immutable": "^4.0.3",
"@types/requestidlecallback": "^0.3.5", "@types/requestidlecallback": "^0.3.5",
"@types/webpack": "^4.41.33", "@types/webpack": "^4.41.33",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^6.17.0", "@typescript-eslint/parser": "^7.0.0",
"babel-jest": "^29.5.0", "babel-jest": "^29.5.0",
"eslint": "^8.41.0", "eslint": "^8.41.0",
"eslint-config-prettier": "^9.0.0", "eslint-config-prettier": "^9.0.0",
@ -191,7 +191,7 @@
"eslint-plugin-promise": "~6.1.1", "eslint-plugin-promise": "~6.1.1",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"husky": "^8.0.3", "husky": "^9.0.11",
"jest": "^29.5.0", "jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0", "jest-environment-jsdom": "^29.5.0",
"lint-staged": "^15.0.0", "lint-staged": "^15.0.0",

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ApproveAppealService do
describe '#call' do
context 'with an existing appeal' do
let(:appeal) { Fabricate(:appeal) }
let(:account) { Fabricate(:account) }
it 'processes the appeal approval' do
expect { subject.call(appeal, account) }
.to mark_overruled
.and record_approver
end
def mark_overruled
change(appeal.strike, :overruled_at)
.from(nil)
.to(be > 1.minute.ago)
end
def record_approver
change(appeal, :approved_by_account)
.from(nil)
.to(account)
end
end
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe RemoveDomainsFromFollowersService do
describe '#call' do
context 'with account followers' do
let(:account) { Fabricate(:account, domain: nil) }
let(:good_domain_account) { Fabricate(:account, domain: 'good.example', protocol: :activitypub) }
let(:bad_domain_account) { Fabricate(:account, domain: 'bad.example', protocol: :activitypub) }
before do
Fabricate :follow, target_account: account, account: good_domain_account
Fabricate :follow, target_account: account, account: bad_domain_account
end
it 'removes followers from supplied domains and sends a notification' do
subject.call(account, ['bad.example'])
expect(account.followers)
.to include(good_domain_account)
.and not_include(bad_domain_account)
expect(ActivityPub::DeliveryWorker)
.to have_enqueued_sidekiq_job(anything, account.id, bad_domain_account.inbox_url)
end
end
end
end

View File

@ -0,0 +1,37 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe RemoveFeaturedTagService do
describe '#call' do
context 'with a featured tag' do
let(:featured_tag) { Fabricate(:featured_tag) }
context 'when called by a local account' do
let(:account) { Fabricate(:account, domain: nil) }
it 'destroys the featured tag and sends a distribution' do
subject.call(account, featured_tag)
expect { featured_tag.reload }
.to raise_error(ActiveRecord::RecordNotFound)
expect(ActivityPub::AccountRawDistributionWorker)
.to have_enqueued_sidekiq_job(anything, account.id)
end
end
context 'when called by a non local account' do
let(:account) { Fabricate(:account, domain: 'host.example') }
it 'destroys the featured tag and does not send a distribution' do
subject.call(account, featured_tag)
expect { featured_tag.reload }
.to raise_error(ActiveRecord::RecordNotFound)
expect(ActivityPub::AccountRawDistributionWorker)
.to_not have_enqueued_sidekiq_job
end
end
end
end
end

View File

@ -0,0 +1,36 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe UnfavouriteService do
describe '#call' do
context 'with a favourited status' do
let(:status) { Fabricate(:status, account: account) }
let!(:favourite) { Fabricate(:favourite, status: status) }
context 'when the status account is local' do
let(:account) { Fabricate(:account, domain: nil) }
it 'destroys the favourite' do
subject.call(favourite.account, status)
expect { favourite.reload }
.to raise_error(ActiveRecord::RecordNotFound)
end
end
context 'when the status account is a remote activitypub account' do
let(:account) { Fabricate(:account, domain: 'host.example', protocol: :activitypub) }
it 'destroys the favourite and sends a notification' do
subject.call(favourite.account, status)
expect { favourite.reload }
.to raise_error(ActiveRecord::RecordNotFound)
expect(ActivityPub::DeliveryWorker)
.to have_enqueued_sidekiq_job(anything, favourite.account.id, status.account.inbox_url)
end
end
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe UnmuteService do
describe '#call' do
let!(:account) { Fabricate(:account) }
let!(:target_account) { Fabricate(:account) }
context 'when account is muting target account' do
before { Fabricate :mute, account: account, target_account: target_account }
context 'when account follows target_account' do
before { Fabricate :follow, account: account, target_account: target_account }
it 'removes the account mute and sets up a merge' do
expect { subject.call(account, target_account) }
.to remove_account_mute
expect(MergeWorker).to have_enqueued_sidekiq_job(target_account.id, account.id)
end
end
context 'when account does not follow target_account' do
it 'removes the account mute and does not create a merge' do
expect { subject.call(account, target_account) }
.to remove_account_mute
expect(MergeWorker).to_not have_enqueued_sidekiq_job
end
end
def remove_account_mute
change { account.reload.muting?(target_account) }
.from(true)
.to(false)
end
end
context 'when account is not muting target account' do
it 'does nothing and returns' do
expect { subject.call(account, target_account) }
.to_not(change { account.reload.muting?(target_account) })
expect(MergeWorker).to_not have_enqueued_sidekiq_job
end
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe VoteService do
describe '#call' do
subject { described_class.new.call(voter, poll, [0]) }
context 'with a poll and poll options' do
let(:poll) { Fabricate(:poll, account: account, options: %w(Fun UnFun)) }
let(:fun_vote) { Fabricate(:poll_vote, poll: poll) }
let(:not_fun_vote) { Fabricate(:poll_vote, poll: poll) }
let(:voter) { Fabricate(:account, domain: nil) }
context 'when the poll was created by a local account' do
let(:account) { Fabricate(:account, domain: nil) }
it 'stores the votes and distributes the poll' do
expect { subject }
.to change(PollVote, :count).by(1)
expect(ActivityPub::DistributePollUpdateWorker)
.to have_enqueued_sidekiq_job(poll.status.id)
end
end
context 'when the poll was created by a remote account' do
let(:account) { Fabricate(:account, domain: 'host.example') }
it 'stores the votes and processes delivery' do
expect { subject }
.to change(PollVote, :count).by(1)
expect(ActivityPub::DeliveryWorker)
.to have_enqueued_sidekiq_job(anything, voter.id, poll.account.inbox_url)
end
end
end
end
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe WebhookService do
describe '#call' do
context 'with a relevant event webhook' do
let!(:report) { Fabricate(:report) }
let!(:webhook) { Fabricate(:webhook, events: ['report.created']) }
it 'finds and delivers webhook payloads' do
expect { subject.call('report.created', report) }
.to enqueue_sidekiq_job(Webhooks::DeliveryWorker)
.with(
webhook.id,
anything
)
end
end
context 'without any relevant event webhooks' do
let!(:report) { Fabricate(:report) }
it 'does not deliver webhook payloads' do
expect { subject.call('report.created', report) }
.to_not enqueue_sidekiq_job(Webhooks::DeliveryWorker)
end
end
end
end

51
streaming/errors.js Normal file
View File

@ -0,0 +1,51 @@
// @ts-check
/**
* Typed as a string because otherwise it's a const string, which means we can't
* override it in let statements.
* @type {string}
*/
const UNEXPECTED_ERROR_MESSAGE = 'An unexpected error occurred';
exports.UNKNOWN_ERROR_MESSAGE = UNEXPECTED_ERROR_MESSAGE;
/**
* Extracts the status and message properties from the error object, if
* available for public use. The `unknown` is for catch statements
* @param {Error | AuthenticationError | RequestError | unknown} err
*/
exports.extractStatusAndMessage = function(err) {
let statusCode = 500;
let errorMessage = UNEXPECTED_ERROR_MESSAGE;
if (err instanceof AuthenticationError || err instanceof RequestError) {
statusCode = err.status;
errorMessage = err.message;
}
return { statusCode, errorMessage };
};
class RequestError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message);
this.name = "RequestError";
this.status = 400;
}
}
exports.RequestError = RequestError;
class AuthenticationError extends Error {
/**
* @param {string} message
*/
constructor(message) {
super(message);
this.name = "AuthenticationError";
this.status = 401;
}
}
exports.AuthenticationError = AuthenticationError;

View File

@ -14,6 +14,8 @@ const pg = require('pg');
const dbUrlToConfig = require('pg-connection-string').parse; const dbUrlToConfig = require('pg-connection-string').parse;
const WebSocket = require('ws'); const WebSocket = require('ws');
const errors = require('./errors');
const { AuthenticationError, RequestError } = require('./errors');
const { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } = require('./logging'); const { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } = require('./logging');
const { setupMetrics } = require('./metrics'); const { setupMetrics } = require('./metrics');
const { isTruthy, normalizeHashtag, firstParam } = require("./utils"); const { isTruthy, normalizeHashtag, firstParam } = require("./utils");
@ -324,7 +326,7 @@ const startServer = async () => {
// Unfortunately for using the on('upgrade') setup, we need to manually // Unfortunately for using the on('upgrade') setup, we need to manually
// write a HTTP Response to the Socket to close the connection upgrade // write a HTTP Response to the Socket to close the connection upgrade
// attempt, so the following code is to handle all of that. // attempt, so the following code is to handle all of that.
const statusCode = err.status ?? 401; const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
/** @type {Record<string, string | number | import('pino-http').ReqId>} */ /** @type {Record<string, string | number | import('pino-http').ReqId>} */
const headers = { const headers = {
@ -332,7 +334,7 @@ const startServer = async () => {
'Content-Type': 'text/plain', 'Content-Type': 'text/plain',
'Content-Length': 0, 'Content-Length': 0,
'X-Request-Id': request.id, 'X-Request-Id': request.id,
'X-Error-Message': err.status ? err.toString() : 'An unexpected error occurred' 'X-Error-Message': errorMessage
}; };
// Ensure the socket is closed once we've finished writing to it: // Ensure the socket is closed once we've finished writing to it:
@ -350,7 +352,7 @@ const startServer = async () => {
statusCode, statusCode,
headers headers
} }
}, err.toString()); }, errorMessage);
return; return;
} }
@ -535,11 +537,7 @@ const startServer = async () => {
} }
if (result.rows.length === 0) { if (result.rows.length === 0) {
err = new Error('Invalid access token'); reject(new AuthenticationError('Invalid access token'));
// @ts-ignore
err.status = 401;
reject(err);
return; return;
} }
@ -570,11 +568,7 @@ const startServer = async () => {
const accessToken = location.query.access_token || req.headers['sec-websocket-protocol']; const accessToken = location.query.access_token || req.headers['sec-websocket-protocol'];
if (!authorization && !accessToken) { if (!authorization && !accessToken) {
const err = new Error('Missing access token'); reject(new AuthenticationError('Missing access token'));
// @ts-ignore
err.status = 401;
reject(err);
return; return;
} }
@ -651,11 +645,7 @@ const startServer = async () => {
return; return;
} }
const err = new Error('Access token does not cover required scopes'); reject(new AuthenticationError('Access token does not have the required scopes'));
// @ts-ignore
err.status = 401;
reject(err);
}); });
/** /**
@ -731,11 +721,7 @@ const startServer = async () => {
// If no channelName can be found for the request, then we should terminate // If no channelName can be found for the request, then we should terminate
// the connection, as there's nothing to stream back // the connection, as there's nothing to stream back
if (!channelName) { if (!channelName) {
const err = new Error('Unknown channel requested'); next(new RequestError('Unknown channel requested'));
// @ts-ignore
err.status = 400;
next(err);
return; return;
} }
@ -762,10 +748,7 @@ const startServer = async () => {
return; return;
} }
const hasStatusCode = Object.hasOwnProperty.call(err, 'status'); const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
// @ts-ignore
const statusCode = hasStatusCode ? err.status : 500;
const errorMessage = hasStatusCode ? err.toString() : 'An unexpected error occurred';
res.writeHead(statusCode, { 'Content-Type': 'application/json' }); res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: errorMessage })); res.end(JSON.stringify({ error: errorMessage }));
@ -1147,7 +1130,7 @@ const startServer = async () => {
}; };
/** /**
* @param {any} res * @param {http.ServerResponse} res
*/ */
const httpNotFound = res => { const httpNotFound = res => {
res.writeHead(404, { 'Content-Type': 'application/json' }); res.writeHead(404, { 'Content-Type': 'application/json' });
@ -1162,16 +1145,29 @@ const startServer = async () => {
api.use(errorMiddleware); api.use(errorMiddleware);
api.get('/api/v1/streaming/*', (req, res) => { api.get('/api/v1/streaming/*', (req, res) => {
// @ts-ignore const channelName = channelNameFromPath(req);
channelNameToIds(req, channelNameFromPath(req), req.query).then(({ channelIds, options }) => {
// FIXME: In theory we'd never actually reach here due to
// authenticationMiddleware catching this case, however, we need to refactor
// how those middlewares work, so I'm adding the extra check in here.
if (!channelName) {
httpNotFound(res);
return;
}
channelNameToIds(req, channelName, req.query).then(({ channelIds, options }) => {
const onSend = streamToHttp(req, res); const onSend = streamToHttp(req, res);
const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds)); const onEnd = streamHttpEnd(req, subscriptionHeartbeat(channelIds));
// @ts-ignore // @ts-ignore
streamFrom(channelIds, req, req.log, onSend, onEnd, 'eventsource', options.needsFiltering, options.allowLocalOnly); streamFrom(channelIds, req, req.log, onSend, onEnd, 'eventsource', options.needsFiltering, options.allowLocalOnly);
}).catch(err => { }).catch(err => {
res.log.info({ err }, 'Subscription error:', err.toString()); const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
httpNotFound(res);
res.log.info({ err }, 'Eventsource subscription error');
res.writeHead(statusCode, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: errorMessage }));
}); });
}); });
@ -1286,8 +1282,8 @@ const startServer = async () => {
break; break;
case 'hashtag': case 'hashtag':
if (!params.tag || params.tag.length === 0) { if (!params.tag) {
reject('No tag for stream provided'); reject(new RequestError('Missing tag name parameter'));
} else { } else {
resolve({ resolve({
channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`], channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}`],
@ -1297,8 +1293,8 @@ const startServer = async () => {
break; break;
case 'hashtag:local': case 'hashtag:local':
if (!params.tag || params.tag.length === 0) { if (!params.tag) {
reject('No tag for stream provided'); reject(new RequestError('Missing tag name parameter'));
} else { } else {
resolve({ resolve({
channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}:local`], channelIds: [`timeline:hashtag:${normalizeHashtag(params.tag)}:local`],
@ -1308,19 +1304,23 @@ const startServer = async () => {
break; break;
case 'list': case 'list':
// @ts-ignore if (!params.list) {
reject(new RequestError('Missing list name parameter'));
return;
}
authorizeListAccess(params.list, req).then(() => { authorizeListAccess(params.list, req).then(() => {
resolve({ resolve({
channelIds: [`timeline:list:${params.list}`], channelIds: [`timeline:list:${params.list}`],
options: { needsFiltering: false, allowLocalOnly: true }, options: { needsFiltering: false, allowLocalOnly: true },
}); });
}).catch(() => { }).catch(() => {
reject('Not authorized to stream this list'); reject(new AuthenticationError('Not authorized to stream this list'));
}); });
break; break;
default: default:
reject('Unknown stream type'); reject(new RequestError('Unknown stream type'));
} }
}); });
@ -1374,8 +1374,17 @@ const startServer = async () => {
stopHeartbeat, stopHeartbeat,
}; };
}).catch(err => { }).catch(err => {
logger.error({ err }, 'Subscription error'); const {statusCode, errorMessage } = errors.extractStatusAndMessage(err);
websocket.send(JSON.stringify({ error: err.toString() }));
logger.error({ err }, 'Websocket subscription error');
// If we have a socket that is alive and open still, send the error back to the client:
if (websocket.isAlive && websocket.readyState === websocket.OPEN) {
websocket.send(JSON.stringify({
error: errorMessage,
status: statusCode
}));
}
}); });
}; };
@ -1414,10 +1423,11 @@ const startServer = async () => {
channelNameToIds(request, channelName, params).then(({ channelIds }) => { channelNameToIds(request, channelName, params).then(({ channelIds }) => {
removeSubscription(session, channelIds); removeSubscription(session, channelIds);
}).catch(err => { }).catch(err => {
logger.error({err}, 'Unsubscribe error'); logger.error({err}, 'Websocket unsubscribe error');
// If we have a socket that is alive and open still, send the error back to the client: // If we have a socket that is alive and open still, send the error back to the client:
if (websocket.isAlive && websocket.readyState === websocket.OPEN) { if (websocket.isAlive && websocket.readyState === websocket.OPEN) {
// TODO: Use a better error response here
websocket.send(JSON.stringify({ error: "Error unsubscribing from channel" })); websocket.send(JSON.stringify({ error: "Error unsubscribing from channel" }));
} }
}); });

View File

@ -1,7 +1,7 @@
{ {
"name": "@mastodon/streaming", "name": "@mastodon/streaming",
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"packageManager": "yarn@4.0.2", "packageManager": "yarn@4.1.0",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },

3657
yarn.lock

File diff suppressed because it is too large Load Diff