-
-
- Shift +
-
- ),
- }}
+
+
+ {!status.get('reblogged') && (
+
+ )}
+
+
+
+
+
+
- {statusVisibility !== 'private' && !status.get('reblogged') && (
-
- )}
-
);
diff --git a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
index 2648923bfc..407276d126 100644
--- a/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
+++ b/app/javascript/mastodon/features/ui/components/navigation_panel.jsx
@@ -7,16 +7,17 @@ import { Link } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux';
-
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
import BookmarksActiveIcon from '@/material-icons/400-24px/bookmarks-fill.svg?react';
import BookmarksIcon from '@/material-icons/400-24px/bookmarks.svg?react';
import ExploreActiveIcon from '@/material-icons/400-24px/explore-fill.svg?react';
import ExploreIcon from '@/material-icons/400-24px/explore.svg?react';
+import ModerationIcon from '@/material-icons/400-24px/gavel.svg?react';
import HomeActiveIcon from '@/material-icons/400-24px/home-fill.svg?react';
import HomeIcon from '@/material-icons/400-24px/home.svg?react';
import ListAltActiveIcon from '@/material-icons/400-24px/list_alt-fill.svg?react';
import ListAltIcon from '@/material-icons/400-24px/list_alt.svg?react';
+import AdministrationIcon from '@/material-icons/400-24px/manufacturing.svg?react';
import MoreHorizIcon from '@/material-icons/400-24px/more_horiz.svg?react';
import NotificationsActiveIcon from '@/material-icons/400-24px/notifications-fill.svg?react';
import NotificationsIcon from '@/material-icons/400-24px/notifications.svg?react';
@@ -34,7 +35,9 @@ import { NavigationPortal } from 'mastodon/components/navigation_portal';
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
import { timelinePreview, trendsEnabled } from 'mastodon/initial_state';
import { transientSingleColumn } from 'mastodon/is_mobile';
+import { canManageReports, canViewAdminDashboard } from 'mastodon/permissions';
import { selectUnreadNotificationGroupsCount } from 'mastodon/selectors/notifications';
+import { selectUseGroupedNotifications } from 'mastodon/selectors/settings';
import ColumnLink from './column_link';
import DisabledAccountBanner from './disabled_account_banner';
@@ -51,6 +54,8 @@ const messages = defineMessages({
bookmarks: { id: 'navigation_bar.bookmarks', defaultMessage: 'Bookmarks' },
lists: { id: 'navigation_bar.lists', defaultMessage: 'Lists' },
preferences: { id: 'navigation_bar.preferences', defaultMessage: 'Preferences' },
+ administration: { id: 'navigation_bar.administration', defaultMessage: 'Administration' },
+ moderation: { id: 'navigation_bar.moderation', defaultMessage: 'Moderation' },
followsAndFollowers: { id: 'navigation_bar.follows_and_followers', defaultMessage: 'Follows and followers' },
about: { id: 'navigation_bar.about', defaultMessage: 'About' },
search: { id: 'navigation_bar.search', defaultMessage: 'Search' },
@@ -60,7 +65,7 @@ const messages = defineMessages({
});
const NotificationsLink = () => {
- const optedInGroupedNotifications = useSelector((state) => state.getIn(['settings', 'notifications', 'groupingBeta'], false));
+ const optedInGroupedNotifications = useSelector(selectUseGroupedNotifications);
const count = useSelector(state => state.getIn(['notifications', 'unread']));
const intl = useIntl();
@@ -114,7 +119,7 @@ class NavigationPanel extends Component {
render () {
const { intl } = this.props;
- const { signedIn, disabledAccountId } = this.props.identity;
+ const { signedIn, disabledAccountId, permissions } = this.props.identity;
let banner = undefined;
@@ -176,6 +181,9 @@ class NavigationPanel extends Component {
+
+ {canManageReports(permissions) &&
}
+ {canViewAdminDashboard(permissions) &&
}
>
)}
diff --git a/app/javascript/mastodon/initial_state.js b/app/javascript/mastodon/initial_state.js
index 60b35cb31a..cf33b12dd9 100644
--- a/app/javascript/mastodon/initial_state.js
+++ b/app/javascript/mastodon/initial_state.js
@@ -43,6 +43,7 @@
* @property {boolean=} use_pending_items
* @property {string} version
* @property {string} sso_redirect
+ * @property {boolean} force_grouped_notifications
*/
/**
@@ -118,6 +119,7 @@ export const criticalUpdatesPending = initialState?.critical_updates_pending;
// @ts-expect-error
export const statusPageUrl = getMeta('status_page_url');
export const sso_redirect = getMeta('sso_redirect');
+export const forceGroupedNotifications = getMeta('force_grouped_notifications');
/**
* @returns {string | undefined}
diff --git a/app/javascript/mastodon/locales/ca.json b/app/javascript/mastodon/locales/ca.json
index 727c161873..d99b5c7375 100644
--- a/app/javascript/mastodon/locales/ca.json
+++ b/app/javascript/mastodon/locales/ca.json
@@ -97,6 +97,8 @@
"block_modal.title": "Bloquem l'usuari?",
"block_modal.you_wont_see_mentions": "No veureu publicacions que l'esmentin.",
"boost_modal.combo": "Pots prémer {combo} per a evitar-ho el pròxim cop",
+ "boost_modal.reblog": "Voleu impulsar la publicació?",
+ "boost_modal.undo_reblog": "Voleu retirar l'impuls a la publicació?",
"bundle_column_error.copy_stacktrace": "Copia l'informe d'error",
"bundle_column_error.error.body": "No s'ha pogut renderitzar la pàgina sol·licitada. Podria ser per un error en el nostre codi o per un problema de compatibilitat del navegador.",
"bundle_column_error.error.title": "Oh, no!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "No veureu publicacions que els esmentin.",
"mute_modal.you_wont_see_posts": "Encara poden veure les vostres publicacions, però no veureu les seves.",
"navigation_bar.about": "Quant a",
+ "navigation_bar.administration": "Administració",
"navigation_bar.advanced_interface": "Obre en la interfície web avançada",
"navigation_bar.blocks": "Usuaris blocats",
"navigation_bar.bookmarks": "Marcadors",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Seguint i seguidors",
"navigation_bar.lists": "Llistes",
"navigation_bar.logout": "Tanca la sessió",
+ "navigation_bar.moderation": "Moderació",
"navigation_bar.mutes": "Usuaris silenciats",
"navigation_bar.opened_in_classic_interface": "Els tuts, comptes i altres pàgines especifiques s'obren per defecte en la interfície web clàssica.",
"navigation_bar.personal": "Personal",
diff --git a/app/javascript/mastodon/locales/de.json b/app/javascript/mastodon/locales/de.json
index b960649eb2..8e52cf1086 100644
--- a/app/javascript/mastodon/locales/de.json
+++ b/app/javascript/mastodon/locales/de.json
@@ -97,6 +97,8 @@
"block_modal.title": "Profil blockieren?",
"block_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.",
"boost_modal.combo": "Mit {combo} erscheint dieses Fenster beim nächsten Mal nicht mehr",
+ "boost_modal.reblog": "Beitrag teilen?",
+ "boost_modal.undo_reblog": "Beitrag nicht mehr teilen?",
"bundle_column_error.copy_stacktrace": "Fehlerbericht kopieren",
"bundle_column_error.error.body": "Die angeforderte Seite konnte nicht dargestellt werden. Dies könnte auf einen Fehler in unserem Code oder auf ein Browser-Kompatibilitätsproblem zurückzuführen sein.",
"bundle_column_error.error.title": "Oh nein!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "Du wirst keine Beiträge sehen, die dieses Profil erwähnen.",
"mute_modal.you_wont_see_posts": "Deine Beiträge können weiterhin angesehen werden, aber du wirst deren Beiträge nicht mehr sehen.",
"navigation_bar.about": "Über",
+ "navigation_bar.administration": "Administration",
"navigation_bar.advanced_interface": "Im erweiterten Webinterface öffnen",
"navigation_bar.blocks": "Blockierte Profile",
"navigation_bar.bookmarks": "Lesezeichen",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Follower und Folge ich",
"navigation_bar.lists": "Listen",
"navigation_bar.logout": "Abmelden",
+ "navigation_bar.moderation": "Moderation",
"navigation_bar.mutes": "Stummgeschaltete Profile",
"navigation_bar.opened_in_classic_interface": "Beiträge, Konten und andere bestimmte Seiten werden standardmäßig im klassischen Webinterface geöffnet.",
"navigation_bar.personal": "Persönlich",
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 88920431fb..4321acef4b 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -97,6 +97,8 @@
"block_modal.title": "Block user?",
"block_modal.you_wont_see_mentions": "You won't see posts that mention them.",
"boost_modal.combo": "You can press {combo} to skip this next time",
+ "boost_modal.reblog": "Boost post?",
+ "boost_modal.undo_reblog": "Unboost post?",
"bundle_column_error.copy_stacktrace": "Copy error report",
"bundle_column_error.error.body": "The requested page could not be rendered. It could be due to a bug in our code, or a browser compatibility issue.",
"bundle_column_error.error.title": "Oh, no!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "You won't see posts that mention them.",
"mute_modal.you_wont_see_posts": "They can still see your posts, but you won't see theirs.",
"navigation_bar.about": "About",
+ "navigation_bar.administration": "Administration",
"navigation_bar.advanced_interface": "Open in advanced web interface",
"navigation_bar.blocks": "Blocked users",
"navigation_bar.bookmarks": "Bookmarks",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Follows and followers",
"navigation_bar.lists": "Lists",
"navigation_bar.logout": "Logout",
+ "navigation_bar.moderation": "Moderation",
"navigation_bar.mutes": "Muted users",
"navigation_bar.opened_in_classic_interface": "Posts, accounts, and other specific pages are opened by default in the classic web interface.",
"navigation_bar.personal": "Personal",
diff --git a/app/javascript/mastodon/locales/es-AR.json b/app/javascript/mastodon/locales/es-AR.json
index 21e70dd98d..c3915c9b74 100644
--- a/app/javascript/mastodon/locales/es-AR.json
+++ b/app/javascript/mastodon/locales/es-AR.json
@@ -97,6 +97,8 @@
"block_modal.title": "¿Bloquear usuario?",
"block_modal.you_wont_see_mentions": "No verás mensajes que los mencionen.",
"boost_modal.combo": "Podés hacer clic en {combo} para saltar esto la próxima vez",
+ "boost_modal.reblog": "¿Adherir al mensaje?",
+ "boost_modal.undo_reblog": "¿Dejar de adherir al mensaje?",
"bundle_column_error.copy_stacktrace": "Copiar informe de error",
"bundle_column_error.error.body": "La página solicitada no pudo ser cargada. Podría deberse a un error de programación en nuestro código o a un problema de compatibilidad con el navegador web.",
"bundle_column_error.error.title": "¡Epa!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "No verás mensajes que los mencionen.",
"mute_modal.you_wont_see_posts": "Todavía pueden ver tus mensajes, pero vos no verás los suyos.",
"navigation_bar.about": "Información",
+ "navigation_bar.administration": "Administración",
"navigation_bar.advanced_interface": "Abrir en interface web avanzada",
"navigation_bar.blocks": "Usuarios bloqueados",
"navigation_bar.bookmarks": "Marcadores",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Cuentas seguidas y seguidores",
"navigation_bar.lists": "Listas",
"navigation_bar.logout": "Cerrar sesión",
+ "navigation_bar.moderation": "Moderación",
"navigation_bar.mutes": "Usuarios silenciados",
"navigation_bar.opened_in_classic_interface": "Los mensajes, las cuentas y otras páginas específicas se abren predeterminadamente en la interface web clásica.",
"navigation_bar.personal": "Personal",
diff --git a/app/javascript/mastodon/locales/et.json b/app/javascript/mastodon/locales/et.json
index 80c65804fa..5caa258cd2 100644
--- a/app/javascript/mastodon/locales/et.json
+++ b/app/javascript/mastodon/locales/et.json
@@ -33,7 +33,9 @@
"account.follow_back": "Jälgi vastu",
"account.followers": "Jälgijad",
"account.followers.empty": "Keegi ei jälgi veel seda kasutajat.",
+ "account.followers_counter": "{count, plural, one {{counter} jälgija} other {{counter} jälgijat}}",
"account.following": "Jälgib",
+ "account.following_counter": "{count, plural, one {{counter} jälgib} other {{counter} jälgib}}",
"account.follows.empty": "See kasutaja ei jälgi veel kedagi.",
"account.go_to_profile": "Mine profiilile",
"account.hide_reblogs": "Peida @{name} jagamised",
@@ -59,6 +61,7 @@
"account.requested_follow": "{name} on taodelnud sinu jälgimist",
"account.share": "Jaga @{name} profiili",
"account.show_reblogs": "Näita @{name} jagamisi",
+ "account.statuses_counter": "{count, plural, one {{counter} postitus} other {{counter} postitust}}",
"account.unblock": "Eemalda blokeering @{name}",
"account.unblock_domain": "Tee {domain} nähtavaks",
"account.unblock_short": "Eemalda blokeering",
diff --git a/app/javascript/mastodon/locales/fi.json b/app/javascript/mastodon/locales/fi.json
index 10f9d79614..39df3e010f 100644
--- a/app/javascript/mastodon/locales/fi.json
+++ b/app/javascript/mastodon/locales/fi.json
@@ -97,6 +97,8 @@
"block_modal.title": "Estetäänkö käyttäjä?",
"block_modal.you_wont_see_mentions": "Et näe enää julkaisuja, joissa hänet mainitaan.",
"boost_modal.combo": "Ensi kerralla voit ohittaa tämän painamalla {combo}",
+ "boost_modal.reblog": "Tehostetaanko julkaisua?",
+ "boost_modal.undo_reblog": "Perutaanko julkaisun tehostus?",
"bundle_column_error.copy_stacktrace": "Kopioi virheraportti",
"bundle_column_error.error.body": "Pyydettyä sivua ei voitu hahmontaa. Se voi johtua virheestä koodissamme tai selaimen yhteensopivuudessa.",
"bundle_column_error.error.title": "Voi ei!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "Et näe enää julkaisuja, joissa hänet mainitaan.",
"mute_modal.you_wont_see_posts": "Hän voi yhä nähdä julkaisusi, mutta sinä et näe hänen.",
"navigation_bar.about": "Tietoja",
+ "navigation_bar.administration": "Ylläpito",
"navigation_bar.advanced_interface": "Avaa edistyneessä selainkäyttöliittymässä",
"navigation_bar.blocks": "Estetyt käyttäjät",
"navigation_bar.bookmarks": "Kirjanmerkit",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Seuratut ja seuraajat",
"navigation_bar.lists": "Listat",
"navigation_bar.logout": "Kirjaudu ulos",
+ "navigation_bar.moderation": "Moderointi",
"navigation_bar.mutes": "Mykistetyt käyttäjät",
"navigation_bar.opened_in_classic_interface": "Julkaisut, profiilit ja tietyt muut sivut avautuvat oletuksena perinteiseen selainkäyttöliittymään.",
"navigation_bar.personal": "Henkilökohtaiset",
diff --git a/app/javascript/mastodon/locales/gl.json b/app/javascript/mastodon/locales/gl.json
index 9da7438da6..5a5ac1dd38 100644
--- a/app/javascript/mastodon/locales/gl.json
+++ b/app/javascript/mastodon/locales/gl.json
@@ -97,6 +97,8 @@
"block_modal.title": "Bloquear usuaria?",
"block_modal.you_wont_see_mentions": "Non verás publicacións que a mencionen.",
"boost_modal.combo": "Preme {combo} para ignorar isto na seguinte vez",
+ "boost_modal.reblog": "Promover publicación?",
+ "boost_modal.undo_reblog": "Retirar promoción?",
"bundle_column_error.copy_stacktrace": "Copiar informe do erro",
"bundle_column_error.error.body": "Non se puido mostrar a páxina solicitada. Podería deberse a un problema no código, ou incompatiblidade co navegador.",
"bundle_column_error.error.title": "Vaites!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "Non verás as publicacións que a mencionen.",
"mute_modal.you_wont_see_posts": "Seguirá podendo ler as túas publicacións, pero non verás as súas.",
"navigation_bar.about": "Sobre",
+ "navigation_bar.administration": "Administración",
"navigation_bar.advanced_interface": "Abrir coa interface web avanzada",
"navigation_bar.blocks": "Usuarias bloqueadas",
"navigation_bar.bookmarks": "Marcadores",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Seguindo e seguidoras",
"navigation_bar.lists": "Listaxes",
"navigation_bar.logout": "Pechar sesión",
+ "navigation_bar.moderation": "Moderación",
"navigation_bar.mutes": "Usuarias silenciadas",
"navigation_bar.opened_in_classic_interface": "Publicacións, contas e outras páxinas dedicadas ábrense por defecto na interface web clásica.",
"navigation_bar.personal": "Persoal",
diff --git a/app/javascript/mastodon/locales/is.json b/app/javascript/mastodon/locales/is.json
index 49b31755fa..54fbee48e6 100644
--- a/app/javascript/mastodon/locales/is.json
+++ b/app/javascript/mastodon/locales/is.json
@@ -97,6 +97,8 @@
"block_modal.title": "Útiloka notanda?",
"block_modal.you_wont_see_mentions": "Þú munt ekki sjá færslur sem minnast á viðkomandi aðila.",
"boost_modal.combo": "Þú getur ýtt á {combo} til að sleppa þessu næst",
+ "boost_modal.reblog": "Endurbirta færslu?",
+ "boost_modal.undo_reblog": "Taka færslu úr endurbirtingu?",
"bundle_column_error.copy_stacktrace": "Afrita villuskýrslu",
"bundle_column_error.error.body": "Umbeðna síðau var ekki hægt að myndgera. Það gæti verið vegna villu í kóðanum okkar eða vandamáls með samhæfni vafra.",
"bundle_column_error.error.title": "Ó-nei!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "Þú munt ekki sjá færslur sem minnast á viðkomandi aðila.",
"mute_modal.you_wont_see_posts": "Viðkomandi geta áfram séð færslurnar þínar en þú munt ekki sjá færslurnar þeirra.",
"navigation_bar.about": "Um hugbúnaðinn",
+ "navigation_bar.administration": "Stjórnun",
"navigation_bar.advanced_interface": "Opna í ítarlegu vefviðmóti",
"navigation_bar.blocks": "Útilokaðir notendur",
"navigation_bar.bookmarks": "Bókamerki",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Fylgist með og fylgjendur",
"navigation_bar.lists": "Listar",
"navigation_bar.logout": "Útskráning",
+ "navigation_bar.moderation": "Umsjón",
"navigation_bar.mutes": "Þaggaðir notendur",
"navigation_bar.opened_in_classic_interface": "Færslur, notendaaðgangar og aðrar sérhæfðar síður eru sjálfgefið opnaðar í klassíska vefviðmótinu.",
"navigation_bar.personal": "Einka",
diff --git a/app/javascript/mastodon/locales/ja.json b/app/javascript/mastodon/locales/ja.json
index ee4ba949b9..8784c6d1a8 100644
--- a/app/javascript/mastodon/locales/ja.json
+++ b/app/javascript/mastodon/locales/ja.json
@@ -502,7 +502,17 @@
"notification.status": "{name}さんが投稿しました",
"notification.update": "{name}さんが投稿を編集しました",
"notification_requests.accept": "受け入れる",
+ "notification_requests.accept_multiple": "{count, plural, other {選択中の#件を受け入れる}}",
+ "notification_requests.confirm_accept_multiple.button": "{count, plural, other {#件のアカウントを受け入れる}}",
+ "notification_requests.confirm_accept_multiple.message": "{count, plural, other {#件のアカウント}}に対して今後通知を受け入れるようにします。よろしいですか?",
+ "notification_requests.confirm_accept_multiple.title": "保留中のアカウントの受け入れ",
+ "notification_requests.confirm_dismiss_multiple.button": "{count, plural, other {#件のアカウントを無視する}}",
+ "notification_requests.confirm_dismiss_multiple.message": "{count, plural, other {#件のアカウント}}からの通知を今後無視するようにします。一度この操作を行った{count, plural, other {アカウント}}とふたたび出会うことは容易ではありません。よろしいですか?",
+ "notification_requests.confirm_dismiss_multiple.title": "保留中のアカウントを無視しようとしています",
"notification_requests.dismiss": "無視",
+ "notification_requests.dismiss_multiple": "{count, plural, other {選択中の#件を無視する}}",
+ "notification_requests.edit_selection": "選択",
+ "notification_requests.exit_selection": "選択の終了",
"notification_requests.explainer_for_limited_account": "このアカウントはモデレーターにより制限が課されているため、このアカウントによる通知は保留されています",
"notification_requests.explainer_for_limited_remote_account": "このアカウントが所属するサーバーはモデレーターにより制限が課されているため、このアカウントによる通知は保留されています",
"notification_requests.minimize_banner": "「保留中の通知」のバナーを最小化する",
diff --git a/app/javascript/mastodon/locales/ko.json b/app/javascript/mastodon/locales/ko.json
index 32a846308d..3b4434fef7 100644
--- a/app/javascript/mastodon/locales/ko.json
+++ b/app/javascript/mastodon/locales/ko.json
@@ -498,9 +498,13 @@
"notification.admin.report_statuses": "{name} 님이 {target}을 {category}로 신고했습니다",
"notification.admin.report_statuses_other": "{name} 님이 {target}을 신고했습니다",
"notification.admin.sign_up": "{name} 님이 가입했습니다",
+ "notification.admin.sign_up.name_and_others": "{name} 외 {count, plural, other {# 명}}이 가입했습니다",
"notification.favourite": "{name} 님이 내 게시물을 좋아합니다",
+ "notification.favourite.name_and_others_with_link": "{name} 외
{count, plural, other {# 명}}이 내 게시물을 좋아합니다",
"notification.follow": "{name} 님이 나를 팔로우했습니다",
+ "notification.follow.name_and_others": "{name} 외 {count, plural, other {# 명}}이 날 팔로우 했습니다",
"notification.follow_request": "{name} 님이 팔로우 요청을 보냈습니다",
+ "notification.follow_request.name_and_others": "{name} 외 {count, plural, other {# 명}}이 나에게 팔로우 요청을 보냈습니다",
"notification.label.mention": "멘션",
"notification.label.private_mention": "개인 멘션",
"notification.label.private_reply": "개인 답글",
@@ -518,6 +522,7 @@
"notification.own_poll": "설문을 마침",
"notification.poll": "참여한 투표가 끝났습니다",
"notification.reblog": "{name} 님이 부스트했습니다",
+ "notification.reblog.name_and_others_with_link": "{name} 외
{count, plural, other {# 명}}이 내 게시물을 부스트했습니다",
"notification.relationships_severance_event": "{name} 님과의 연결이 끊어졌습니다",
"notification.relationships_severance_event.account_suspension": "{from}의 관리자가 {target}를 정지시켰기 때문에 그들과 더이상 상호작용 할 수 없고 정보를 받아볼 수 없습니다.",
"notification.relationships_severance_event.domain_block": "{from}의 관리자가 {target}를 차단하였고 여기에는 나의 {followersCount} 명의 팔로워와 {followingCount, plural, other {#}} 명의 팔로우가 포함되었습니다.",
@@ -851,7 +856,7 @@
"upload_modal.description_placeholder": "다람쥐 헌 쳇바퀴 타고파",
"upload_modal.detect_text": "사진에서 문자 탐색",
"upload_modal.edit_media": "미디어 수정",
- "upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 포컬 포인트를 맞추세요. 이 점은 썸네일에 항상 보여질 부분을 나타냅니다.",
+ "upload_modal.hint": "미리보기를 클릭하거나 드래그 해서 초점을 맞추세요. 이 점은 썸네일에서 항상 보여질 부분을 나타냅니다.",
"upload_modal.preparing_ocr": "OCR 준비 중…",
"upload_modal.preview_label": "미리보기 ({ratio})",
"upload_progress.label": "업로드 중...",
diff --git a/app/javascript/mastodon/locales/lt.json b/app/javascript/mastodon/locales/lt.json
index 027326f9cb..051fd3717c 100644
--- a/app/javascript/mastodon/locales/lt.json
+++ b/app/javascript/mastodon/locales/lt.json
@@ -97,6 +97,8 @@
"block_modal.title": "Blokuoti naudotoją?",
"block_modal.you_wont_see_mentions": "Nematysi įrašus, kuriuose jie paminimi.",
"boost_modal.combo": "Galima paspausti {combo}, kad praleisti tai kitą kartą",
+ "boost_modal.reblog": "Pasidalinti įrašą?",
+ "boost_modal.undo_reblog": "Panaikinti pasidalintą įrašą?",
"bundle_column_error.copy_stacktrace": "Kopijuoti klaidos ataskaitą",
"bundle_column_error.error.body": "Paprašytos puslapio nepavyko atvaizduoti. Tai gali būti dėl mūsų kodo klaidos arba naršyklės suderinamumo problemos.",
"bundle_column_error.error.title": "O, ne!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "Nematysi įrašus, kuriuose jie paminimi.",
"mute_modal.you_wont_see_posts": "Jie vis tiek gali matyti tavo įrašus, bet tu nematysi jų.",
"navigation_bar.about": "Apie",
+ "navigation_bar.administration": "Administravimas",
"navigation_bar.advanced_interface": "Atidaryti išplėstinę žiniatinklio sąsają",
"navigation_bar.blocks": "Užblokuoti naudotojai",
"navigation_bar.bookmarks": "Žymės",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Sekimai ir sekėjai",
"navigation_bar.lists": "Sąrašai",
"navigation_bar.logout": "Atsijungti",
+ "navigation_bar.moderation": "Prižiūrėjimas",
"navigation_bar.mutes": "Nutildyti naudotojai",
"navigation_bar.opened_in_classic_interface": "Įrašai, paskyros ir kiti konkretūs puslapiai pagal numatytuosius nustatymus atidaromi klasikinėje žiniatinklio sąsajoje.",
"navigation_bar.personal": "Asmeninis",
diff --git a/app/javascript/mastodon/locales/nl.json b/app/javascript/mastodon/locales/nl.json
index b15c09fead..3362cf36ce 100644
--- a/app/javascript/mastodon/locales/nl.json
+++ b/app/javascript/mastodon/locales/nl.json
@@ -97,6 +97,7 @@
"block_modal.title": "Gebruiker blokkeren?",
"block_modal.you_wont_see_mentions": "Je ziet geen berichten meer die dit account vermelden.",
"boost_modal.combo": "Je kunt {combo} klikken om dit de volgende keer over te slaan",
+ "boost_modal.reblog": "Bericht boosten?",
"bundle_column_error.copy_stacktrace": "Foutrapportage kopiëren",
"bundle_column_error.error.body": "De opgevraagde pagina kon niet worden weergegeven. Dit kan het gevolg zijn van een fout in onze broncode, of van een compatibiliteitsprobleem met je webbrowser.",
"bundle_column_error.error.title": "O nee!",
@@ -467,6 +468,7 @@
"mute_modal.you_wont_see_mentions": "Je ziet geen berichten meer die dit account vermelden.",
"mute_modal.you_wont_see_posts": "De persoon kan nog steeds jouw berichten zien, maar diens berichten zie je niet meer.",
"navigation_bar.about": "Over",
+ "navigation_bar.administration": "Beheer",
"navigation_bar.advanced_interface": "In geavanceerde webinterface openen",
"navigation_bar.blocks": "Geblokkeerde gebruikers",
"navigation_bar.bookmarks": "Bladwijzers",
@@ -483,6 +485,7 @@
"navigation_bar.follows_and_followers": "Volgers en gevolgde accounts",
"navigation_bar.lists": "Lijsten",
"navigation_bar.logout": "Uitloggen",
+ "navigation_bar.moderation": "Moderatie",
"navigation_bar.mutes": "Genegeerde gebruikers",
"navigation_bar.opened_in_classic_interface": "Berichten, accounts en andere specifieke pagina’s, worden standaard geopend in de klassieke webinterface.",
"navigation_bar.personal": "Persoonlijk",
diff --git a/app/javascript/mastodon/locales/pl.json b/app/javascript/mastodon/locales/pl.json
index 2a4c5d6e44..1af79127a5 100644
--- a/app/javascript/mastodon/locales/pl.json
+++ b/app/javascript/mastodon/locales/pl.json
@@ -97,6 +97,8 @@
"block_modal.title": "Zablokować użytkownika?",
"block_modal.you_wont_see_mentions": "Nie zobaczysz wpisów, które wspominają tego użytkownika.",
"boost_modal.combo": "Naciśnij {combo}, aby pominąć to następnym razem",
+ "boost_modal.reblog": "Podbić wpis?",
+ "boost_modal.undo_reblog": "Cofnąć podbicie?",
"bundle_column_error.copy_stacktrace": "Skopiuj raport o błędzie",
"bundle_column_error.error.body": "Nie można zrenderować żądanej strony. Może to być spowodowane błędem w naszym kodzie lub problemami z kompatybilnością przeglądarki.",
"bundle_column_error.error.title": "O nie!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "Nie zobaczysz wpisów, które wspominają tego użytkownika.",
"mute_modal.you_wont_see_posts": "Użytkownik dalej będzie widzieć Twoje posty, ale Ty nie będziesz widzieć jego.",
"navigation_bar.about": "O serwerze",
+ "navigation_bar.administration": "Administracja",
"navigation_bar.advanced_interface": "Otwórz w zaawansowanym interfejsie użytkownika",
"navigation_bar.blocks": "Zablokowani użytkownicy",
"navigation_bar.bookmarks": "Zakładki",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Obserwowani i obserwujący",
"navigation_bar.lists": "Listy",
"navigation_bar.logout": "Wyloguj",
+ "navigation_bar.moderation": "Moderacja",
"navigation_bar.mutes": "Wyciszeni użytkownicy",
"navigation_bar.opened_in_classic_interface": "Posty, konta i inne konkretne strony są otwierane domyślnie w klasycznym interfejsie sieciowym.",
"navigation_bar.personal": "Osobiste",
diff --git a/app/javascript/mastodon/locales/sq.json b/app/javascript/mastodon/locales/sq.json
index 48f667086b..51b7a551c1 100644
--- a/app/javascript/mastodon/locales/sq.json
+++ b/app/javascript/mastodon/locales/sq.json
@@ -97,6 +97,8 @@
"block_modal.title": "Të bllokohet përdoruesi?",
"block_modal.you_wont_see_mentions": "S’do të shihni postimet ku përmenden.",
"boost_modal.combo": "Që kjo të anashkalohet herës tjetër, mund të shtypni {combo}",
+ "boost_modal.reblog": "Përforcim postimi?",
+ "boost_modal.undo_reblog": "Të hiqet përforcim për postimin?",
"bundle_column_error.copy_stacktrace": "Kopjo raportim gabimi",
"bundle_column_error.error.body": "Faqja e kërkuar s’u vizatua dot. Kjo mund të vijë nga një e metë në kodin tonë, ose nga një problem përputhshmërie i shfletuesit.",
"bundle_column_error.error.title": "Oh, mos!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "S’do të shihni postime ku përmenden.",
"mute_modal.you_wont_see_posts": "Ata munden ende të shohin postimet tuaja, por ju s’do të shihni të tyret.",
"navigation_bar.about": "Mbi",
+ "navigation_bar.administration": "Administrim",
"navigation_bar.advanced_interface": "Hape në ndërfaqe web të thelluar",
"navigation_bar.blocks": "Përdorues të bllokuar",
"navigation_bar.bookmarks": "Faqerojtës",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "Ndjekje dhe ndjekës",
"navigation_bar.lists": "Lista",
"navigation_bar.logout": "Dalje",
+ "navigation_bar.moderation": "Moderim",
"navigation_bar.mutes": "Përdorues të heshtuar",
"navigation_bar.opened_in_classic_interface": "Postime, llogari dhe të tjera faqe specifike, si parazgjedhje, hapen në ndërfaqe klasike web.",
"navigation_bar.personal": "Personale",
diff --git a/app/javascript/mastodon/locales/zh-TW.json b/app/javascript/mastodon/locales/zh-TW.json
index 107267b5e5..df754f6d68 100644
--- a/app/javascript/mastodon/locales/zh-TW.json
+++ b/app/javascript/mastodon/locales/zh-TW.json
@@ -97,6 +97,8 @@
"block_modal.title": "是否封鎖該使用者?",
"block_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。",
"boost_modal.combo": "下次您可以按 {combo} 跳過",
+ "boost_modal.reblog": "是否要轉嘟?",
+ "boost_modal.undo_reblog": "是否要取消轉嘟?",
"bundle_column_error.copy_stacktrace": "複製錯誤報告",
"bundle_column_error.error.body": "無法繪製請求的頁面。這可能是因為我們程式碼中的臭蟲或是瀏覽器的相容問題。",
"bundle_column_error.error.title": "糟糕!",
@@ -467,6 +469,7 @@
"mute_modal.you_wont_see_mentions": "您不會見到提及他們的嘟文。",
"mute_modal.you_wont_see_posts": "他們仍可讀取您的嘟文,但您不會見到他們的。",
"navigation_bar.about": "關於",
+ "navigation_bar.administration": "管理介面",
"navigation_bar.advanced_interface": "以進階網頁介面開啟",
"navigation_bar.blocks": "已封鎖的使用者",
"navigation_bar.bookmarks": "書籤",
@@ -483,6 +486,7 @@
"navigation_bar.follows_and_followers": "跟隨中與跟隨者",
"navigation_bar.lists": "列表",
"navigation_bar.logout": "登出",
+ "navigation_bar.moderation": "站務",
"navigation_bar.mutes": "已靜音的使用者",
"navigation_bar.opened_in_classic_interface": "預設於經典網頁介面中開啟嘟文、帳號與其他特定頁面。",
"navigation_bar.personal": "個人",
diff --git a/app/javascript/mastodon/permissions.ts b/app/javascript/mastodon/permissions.ts
index b583535c00..8f015610ea 100644
--- a/app/javascript/mastodon/permissions.ts
+++ b/app/javascript/mastodon/permissions.ts
@@ -1,4 +1,23 @@
export const PERMISSION_INVITE_USERS = 0x0000000000010000;
export const PERMISSION_MANAGE_USERS = 0x0000000000000400;
export const PERMISSION_MANAGE_FEDERATION = 0x0000000000000020;
+
export const PERMISSION_MANAGE_REPORTS = 0x0000000000000010;
+export const PERMISSION_VIEW_DASHBOARD = 0x0000000000000008;
+
+// These helpers don't quite align with the names/categories in UserRole,
+// but are likely "good enough" for the use cases at present.
+//
+// See: https://docs.joinmastodon.org/entities/Role/#permission-flags
+
+export function canViewAdminDashboard(permissions: number) {
+ return (
+ (permissions & PERMISSION_VIEW_DASHBOARD) === PERMISSION_VIEW_DASHBOARD
+ );
+}
+
+export function canManageReports(permissions: number) {
+ return (
+ (permissions & PERMISSION_MANAGE_REPORTS) === PERMISSION_MANAGE_REPORTS
+ );
+}
diff --git a/app/javascript/mastodon/selectors/settings.ts b/app/javascript/mastodon/selectors/settings.ts
index 93276c6692..22e7c13b93 100644
--- a/app/javascript/mastodon/selectors/settings.ts
+++ b/app/javascript/mastodon/selectors/settings.ts
@@ -1,3 +1,4 @@
+import { forceGroupedNotifications } from 'mastodon/initial_state';
import type { RootState } from 'mastodon/store';
/* eslint-disable @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
@@ -25,6 +26,10 @@ export const selectSettingsNotificationsQuickFilterAdvanced = (
) =>
state.settings.getIn(['notifications', 'quickFilter', 'advanced']) as boolean;
+export const selectUseGroupedNotifications = (state: RootState) =>
+ forceGroupedNotifications ||
+ (state.settings.getIn(['notifications', 'groupingBeta']) as boolean);
+
export const selectSettingsNotificationsShowUnread = (state: RootState) =>
state.settings.getIn(['notifications', 'showUnread']) as boolean;
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index d9f3a8d7ad..49e1e621fd 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -6142,6 +6142,48 @@ a.status-card {
}
}
+ &__status {
+ border: 1px solid var(--modal-border-color);
+ border-radius: 8px;
+ padding: 8px;
+ cursor: pointer;
+
+ &__account {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-bottom: 8px;
+ color: $dark-text-color;
+
+ bdi {
+ color: inherit;
+ }
+ }
+
+ &__content {
+ display: -webkit-box;
+ font-size: 15px;
+ line-height: 22px;
+ color: $dark-text-color;
+ -webkit-line-clamp: 4;
+ -webkit-box-orient: vertical;
+ max-height: 4 * 22px;
+ overflow: hidden;
+
+ p,
+ a {
+ color: inherit;
+ }
+ }
+
+ .reply-indicator__attachments {
+ margin-top: 0;
+ font-size: 15px;
+ line-height: 22px;
+ color: $dark-text-color;
+ }
+ }
+
&__bullet-points {
display: flex;
flex-direction: column;
@@ -6219,6 +6261,12 @@ a.status-card {
gap: 8px;
justify-content: flex-end;
+ &__hint {
+ font-size: 14px;
+ line-height: 20px;
+ color: $dark-text-color;
+ }
+
.link-button {
padding: 10px 12px;
font-weight: 600;
@@ -6226,6 +6274,18 @@ a.status-card {
}
}
+.hotkey-combination {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+
+ kbd {
+ padding: 3px 5px;
+ border: 1px solid var(--background-border-color);
+ border-radius: 4px;
+ }
+}
+
.boost-modal,
.report-modal,
.actions-modal,
@@ -10579,6 +10639,7 @@ noscript {
}
.reply-indicator__attachments {
+ margin-top: 0;
font-size: 15px;
line-height: 22px;
color: $dark-text-color;
diff --git a/app/serializers/initial_state_serializer.rb b/app/serializers/initial_state_serializer.rb
index 5bbb0fe4b4..c754f79198 100644
--- a/app/serializers/initial_state_serializer.rb
+++ b/app/serializers/initial_state_serializer.rb
@@ -126,6 +126,7 @@ class InitialStateSerializer < ActiveModel::Serializer
trends_as_landing_page: Setting.trends_as_landing_page,
trends_enabled: Setting.trends,
version: instance_presenter.version,
+ force_grouped_notifications: ENV['FORCE_GROUPED_NOTIFICATIONS'] == 'true',
}
end
diff --git a/config/locales/ko.yml b/config/locales/ko.yml
index 7cbcb82019..e919e183d4 100644
--- a/config/locales/ko.yml
+++ b/config/locales/ko.yml
@@ -886,6 +886,7 @@ ko:
name: 이름
newest: 최신
oldest: 오래된 순
+ open: 공개시점으로 보기
reset: 초기화
review: 심사 상태
search: 검색
diff --git a/config/locales/simple_form.et.yml b/config/locales/simple_form.et.yml
index 74660921d2..2109ceb496 100644
--- a/config/locales/simple_form.et.yml
+++ b/config/locales/simple_form.et.yml
@@ -211,6 +211,7 @@ et:
setting_default_privacy: Postituse nähtavus
setting_default_sensitive: Alati märgista meedia tundlikuks
setting_delete_modal: Näita kinnitusdialoogi enne postituse kustutamist
+ setting_disable_hover_cards: Keela profiili eelvaade kui hõljutada
setting_disable_swiping: Keela pühkimisliigutused
setting_display_media: Meedia kuvarežiim
setting_display_media_default: Vaikimisi
@@ -242,11 +243,13 @@ et:
warn: Peida hoiatusega
form_admin_settings:
activity_api_enabled: Avalda agregeeritud statistika kasutajaaktiivsuse kohta API-s
+ app_icon: Äpi ikoon
backups_retention_period: Kasutajate arhiivi talletusperiood
bootstrap_timeline_accounts: Alati soovita neid kontosid uutele kasutajatele
closed_registrations_message: Kohandatud teade, kui liitumine pole võimalik
content_cache_retention_period: Kaugsisu säilitamise aeg
custom_css: Kohandatud CSS
+ favicon: Favicon
mascot: Kohandatud maskott (kunagine)
media_cache_retention_period: Meediapuhvri talletusperiood
peers_api_enabled: Avalda avastatud serverite loetelu API kaudu
diff --git a/docker-compose.yml b/docker-compose.yml
index 05fd9e1887..8053b436ce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -57,7 +57,8 @@ services:
# - '127.0.0.1:9200:9200'
web:
- build: .
+ # You can uncomment the following line if you want to not use the prebuilt image, for example if you have local code changes
+ # build: .
image: ghcr.io/mastodon/mastodon:v4.3.0-beta.1
restart: always
env_file: .env.production
@@ -78,7 +79,10 @@ services:
- ./public/system:/mastodon/public/system
streaming:
- build: .
+ # You can uncomment the following lines if you want to not use the prebuilt image, for example if you have local code changes
+ # build:
+ # dockerfile: ./streaming/Dockerfile
+ # context: .
image: ghcr.io/mastodon/mastodon-streaming:v4.3.0-beta.1
restart: always
env_file: .env.production
@@ -88,7 +92,7 @@ services:
- internal_network
healthcheck:
# prettier-ignore
- test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1'"]
+ test: ['CMD-SHELL', "curl -s --noproxy localhost localhost:4000/api/v1/streaming/health | grep -q 'OK' || exit 1"]
ports:
- '127.0.0.1:4000:4000'
depends_on:
diff --git a/spec/controllers/admin/dashboard_controller_spec.rb b/spec/controllers/admin/dashboard_controller_spec.rb
index 25300fdd90..3e29ce1278 100644
--- a/spec/controllers/admin/dashboard_controller_spec.rb
+++ b/spec/controllers/admin/dashboard_controller_spec.rb
@@ -6,19 +6,30 @@ describe Admin::DashboardController do
render_views
describe 'GET #index' do
+ let(:user) { Fabricate(:user, role: UserRole.find_by(name: 'Owner')) }
+
before do
- allow(Admin::SystemCheck).to receive(:perform).and_return([
- Admin::SystemCheck::Message.new(:database_schema_check),
- Admin::SystemCheck::Message.new(:rules_check, nil, admin_rules_path),
- Admin::SystemCheck::Message.new(:sidekiq_process_check, 'foo, bar'),
- ])
- sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin'))
+ stub_system_checks
+ Fabricate :software_update
+ sign_in(user)
end
- it 'returns 200' do
+ it 'returns http success and body with system check messages' do
get :index
- expect(response).to have_http_status(200)
+ expect(response)
+ .to have_http_status(200)
+ .and have_attributes(
+ body: include(I18n.t('admin.system_checks.software_version_patch_check.message_html'))
+ )
+ end
+
+ private
+
+ def stub_system_checks
+ stub_const 'Admin::SystemCheck::ACTIVE_CHECKS', [
+ Admin::SystemCheck::SoftwareVersionCheck,
+ ]
end
end
end
diff --git a/spec/controllers/api/oembed_controller_spec.rb b/spec/controllers/api/oembed_controller_spec.rb
deleted file mode 100644
index 5f0ca560d2..0000000000
--- a/spec/controllers/api/oembed_controller_spec.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-RSpec.describe Api::OEmbedController do
- render_views
-
- let(:alice) { Fabricate(:account, username: 'alice') }
- let(:status) { Fabricate(:status, text: 'Hello world', account: alice) }
-
- describe 'GET #show' do
- before do
- request.host = Rails.configuration.x.local_domain
- get :show, params: { url: short_account_status_url(alice, status) }, format: :json
- end
-
- it 'returns private cache control headers', :aggregate_failures do
- expect(response).to have_http_status(200)
- expect(response.headers['Cache-Control']).to include('private, no-store')
- end
- end
-end
diff --git a/spec/controllers/api/web/settings_controller_spec.rb b/spec/controllers/api/web/settings_controller_spec.rb
deleted file mode 100644
index 815da04c47..0000000000
--- a/spec/controllers/api/web/settings_controller_spec.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Api::Web::SettingsController do
- render_views
-
- let!(:user) { Fabricate(:user) }
-
- describe 'PATCH #update' do
- it 'redirects to about page' do
- sign_in(user)
- patch :update, format: :json, params: { data: { 'onboarded' => true } }
-
- user.reload
- expect(response).to have_http_status(200)
- expect(user_web_setting.data['onboarded']).to eq('true')
- end
-
- def user_web_setting
- Web::Setting.where(user: user).first
- end
- end
-end
diff --git a/spec/controllers/media_proxy_controller_spec.rb b/spec/controllers/media_proxy_controller_spec.rb
deleted file mode 100644
index 32510cf43d..0000000000
--- a/spec/controllers/media_proxy_controller_spec.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe MediaProxyController do
- render_views
-
- before do
- stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
- end
-
- describe '#show' do
- it 'redirects when attached to a status' do
- status = Fabricate(:status)
- media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
- get :show, params: { id: media_attachment.id }
-
- expect(response).to have_http_status(302)
- end
-
- it 'responds with missing when there is not an attached status' do
- media_attachment = Fabricate(:media_attachment, status: nil, remote_url: 'http://example.com/attachment.png')
- get :show, params: { id: media_attachment.id }
-
- expect(response).to have_http_status(404)
- end
-
- it 'raises when id cant be found' do
- get :show, params: { id: 'missing' }
-
- expect(response).to have_http_status(404)
- end
-
- it 'raises when not permitted to view' do
- status = Fabricate(:status, visibility: :direct)
- media_attachment = Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png')
- get :show, params: { id: media_attachment.id }
-
- expect(response).to have_http_status(404)
- end
- end
-end
diff --git a/spec/controllers/settings/exports/blocked_accounts_controller_spec.rb b/spec/controllers/settings/exports/blocked_accounts_controller_spec.rb
deleted file mode 100644
index 459b278d64..0000000000
--- a/spec/controllers/settings/exports/blocked_accounts_controller_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Settings::Exports::BlockedAccountsController do
- render_views
-
- describe 'GET #index' do
- it 'returns a csv of the blocking accounts' do
- user = Fabricate(:user)
- user.account.block!(Fabricate(:account, username: 'username', domain: 'domain'))
-
- sign_in user, scope: :user
- get :index, format: :csv
-
- expect(response.body).to eq "username@domain\n"
- end
- end
-end
diff --git a/spec/controllers/settings/exports/blocked_domains_controller_spec.rb b/spec/controllers/settings/exports/blocked_domains_controller_spec.rb
deleted file mode 100644
index ac72fd9dd7..0000000000
--- a/spec/controllers/settings/exports/blocked_domains_controller_spec.rb
+++ /dev/null
@@ -1,20 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Settings::Exports::BlockedDomainsController do
- render_views
-
- describe 'GET #index' do
- it 'returns a csv of the domains' do
- account = Fabricate(:account, domain: 'example.com')
- user = Fabricate(:user, account: account)
- Fabricate(:account_domain_block, domain: 'example.com', account: account)
-
- sign_in user, scope: :user
- get :index, format: :csv
-
- expect(response.body).to eq "example.com\n"
- end
- end
-end
diff --git a/spec/controllers/settings/exports/bookmarks_controller_spec.rb b/spec/controllers/settings/exports/bookmarks_controller_spec.rb
deleted file mode 100644
index 9982eff165..0000000000
--- a/spec/controllers/settings/exports/bookmarks_controller_spec.rb
+++ /dev/null
@@ -1,24 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Settings::Exports::BookmarksController do
- render_views
-
- let(:user) { Fabricate(:user) }
- let(:account) { Fabricate(:account, domain: 'foo.bar') }
- let(:status) { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') }
-
- describe 'GET #index' do
- before do
- user.account.bookmarks.create!(status: status)
- end
-
- it 'returns a csv of the bookmarked toots' do
- sign_in user, scope: :user
- get :index, format: :csv
-
- expect(response.body).to eq "https://foo.bar/statuses/1312\n"
- end
- end
-end
diff --git a/spec/controllers/settings/exports/following_accounts_controller_spec.rb b/spec/controllers/settings/exports/following_accounts_controller_spec.rb
deleted file mode 100644
index 72b0b94e13..0000000000
--- a/spec/controllers/settings/exports/following_accounts_controller_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Settings::Exports::FollowingAccountsController do
- render_views
-
- describe 'GET #index' do
- it 'returns a csv of the following accounts' do
- user = Fabricate(:user)
- user.account.follow!(Fabricate(:account, username: 'username', domain: 'domain'))
-
- sign_in user, scope: :user
- get :index, format: :csv
-
- expect(response.body).to eq "Account address,Show boosts,Notify on new posts,Languages\nusername@domain,true,false,\n"
- end
- end
-end
diff --git a/spec/controllers/settings/exports/lists_controller_spec.rb b/spec/controllers/settings/exports/lists_controller_spec.rb
deleted file mode 100644
index 29623ba499..0000000000
--- a/spec/controllers/settings/exports/lists_controller_spec.rb
+++ /dev/null
@@ -1,21 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Settings::Exports::ListsController do
- render_views
-
- describe 'GET #index' do
- it 'returns a csv of the domains' do
- account = Fabricate(:account)
- user = Fabricate(:user, account: account)
- list = Fabricate(:list, account: account, title: 'The List')
- Fabricate(:list_account, list: list, account: account)
-
- sign_in user, scope: :user
- get :index, format: :csv
-
- expect(response.body).to match 'The List'
- end
- end
-end
diff --git a/spec/controllers/settings/exports/muted_accounts_controller_spec.rb b/spec/controllers/settings/exports/muted_accounts_controller_spec.rb
deleted file mode 100644
index b4170cb160..0000000000
--- a/spec/controllers/settings/exports/muted_accounts_controller_spec.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-require 'rails_helper'
-
-describe Settings::Exports::MutedAccountsController do
- render_views
-
- describe 'GET #index' do
- it 'returns a csv of the muting accounts' do
- user = Fabricate(:user)
- user.account.mute!(Fabricate(:account, username: 'username', domain: 'domain'))
-
- sign_in user, scope: :user
- get :index, format: :csv
-
- expect(response.body).to eq "Account address,Hide notifications\nusername@domain,true\n"
- end
- end
-end
diff --git a/spec/requests/anonymous_cookies_spec.rb b/spec/requests/anonymous_cookies_spec.rb
index 427f54e449..337ed4ec31 100644
--- a/spec/requests/anonymous_cookies_spec.rb
+++ b/spec/requests/anonymous_cookies_spec.rb
@@ -2,7 +2,7 @@
require 'rails_helper'
-context 'when visited anonymously' do
+describe 'Anonymous visits' do
around do |example|
old = ActionController::Base.allow_forgery_protection
ActionController::Base.allow_forgery_protection = true
diff --git a/spec/requests/api/oembed_spec.rb b/spec/requests/api/oembed_spec.rb
new file mode 100644
index 0000000000..b9578b37c8
--- /dev/null
+++ b/spec/requests/api/oembed_spec.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe 'API OEmbed' do
+ describe 'GET /api/oembed' do
+ before { host! Rails.configuration.x.local_domain }
+
+ context 'when status is public' do
+ let(:status) { Fabricate(:status, visibility: :public) }
+
+ it 'returns success with private cache control headers' do
+ get '/api/oembed', params: { url: short_account_status_url(status.account, status) }
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.headers['Cache-Control'])
+ .to include('private, no-store')
+ end
+ end
+
+ context 'when status is not public' do
+ let(:status) { Fabricate(:status, visibility: :direct) }
+
+ it 'returns not found' do
+ get '/api/oembed', params: { url: short_account_status_url(status.account, status) }
+
+ expect(response)
+ .to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/requests/api/web/settings_spec.rb b/spec/requests/api/web/settings_spec.rb
new file mode 100644
index 0000000000..81b8b44953
--- /dev/null
+++ b/spec/requests/api/web/settings_spec.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+RSpec.describe '/api/web/settings' do
+ describe 'PATCH /api/web/settings' do
+ let(:user) { Fabricate :user }
+
+ context 'when signed in' do
+ before { sign_in(user) }
+
+ it 'updates setting and responds with success' do
+ patch '/api/web/settings', params: { data: { 'onboarded' => true } }
+
+ expect(user_web_setting.data)
+ .to include('onboarded' => 'true')
+
+ expect(response)
+ .to have_http_status(200)
+ end
+ end
+
+ context 'when not signed in' do
+ it 'responds with unprocessable and does not modify setting' do
+ patch '/api/web/settings', params: { data: { 'onboarded' => true } }
+
+ expect(user_web_setting)
+ .to be_nil
+
+ expect(response)
+ .to have_http_status(422)
+ end
+ end
+
+ def user_web_setting
+ Web::Setting
+ .where(user: user)
+ .first
+ end
+ end
+end
diff --git a/spec/requests/media_proxy_spec.rb b/spec/requests/media_proxy_spec.rb
new file mode 100644
index 0000000000..0524105d90
--- /dev/null
+++ b/spec/requests/media_proxy_spec.rb
@@ -0,0 +1,67 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Media Proxy' do
+ describe 'GET /media_proxy/:id' do
+ before do
+ integration_session.https! # TODO: Move to global rails_helper for all request specs?
+ host! Rails.configuration.x.local_domain # TODO: Move to global rails_helper for all request specs?
+
+ stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt'))
+ end
+
+ context 'when attached to a status' do
+ let(:status) { Fabricate(:status) }
+ let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') }
+
+ it 'redirects to correct original url' do
+ get "/media_proxy/#{media_attachment.id}"
+
+ expect(response)
+ .to have_http_status(302)
+ .and redirect_to media_attachment.file.url(:original)
+ end
+
+ it 'redirects to small style url' do
+ get "/media_proxy/#{media_attachment.id}/small"
+
+ expect(response)
+ .to have_http_status(302)
+ .and redirect_to media_attachment.file.url(:small)
+ end
+ end
+
+ context 'when there is not an attached status' do
+ let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') }
+
+ it 'responds with missing' do
+ get "/media_proxy/#{media_attachment.id}"
+
+ expect(response)
+ .to have_http_status(404)
+ end
+ end
+
+ context 'when id cannot be found' do
+ it 'responds with missing' do
+ get '/media_proxy/missing'
+
+ expect(response)
+ .to have_http_status(404)
+ end
+ end
+
+ context 'when not permitted to view' do
+ let(:status) { Fabricate(:status, visibility: :direct) }
+ let(:media_attachment) { Fabricate(:media_attachment, status: status, remote_url: 'http://example.com/attachment.png') }
+
+ it 'responds with missing' do
+ get "/media_proxy/#{media_attachment.id}"
+
+ expect(response)
+ .to have_http_status(404)
+ end
+ end
+ end
+end
diff --git a/spec/requests/settings/exports/blocked_accounts_spec.rb b/spec/requests/settings/exports/blocked_accounts_spec.rb
new file mode 100644
index 0000000000..f335ba18c0
--- /dev/null
+++ b/spec/requests/settings/exports/blocked_accounts_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Settings / Exports / Blocked Accounts' do
+ describe 'GET /settings/exports/blocks' do
+ context 'with a signed in user who has blocked accounts' do
+ let(:user) { Fabricate :user }
+
+ before do
+ Fabricate(
+ :block,
+ account: user.account,
+ target_account: Fabricate(:account, username: 'username', domain: 'domain')
+ )
+ sign_in user
+ end
+
+ it 'returns a CSV with the blocking accounts' do
+ get '/settings/exports/blocks.csv'
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.content_type)
+ .to eq('text/csv')
+ expect(response.body)
+ .to eq(<<~CSV)
+ username@domain
+ CSV
+ end
+ end
+
+ describe 'when signed out' do
+ it 'returns unauthorized' do
+ get '/settings/exports/blocks.csv'
+
+ expect(response)
+ .to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/spec/requests/settings/exports/blocked_domains_spec.rb b/spec/requests/settings/exports/blocked_domains_spec.rb
new file mode 100644
index 0000000000..762907585f
--- /dev/null
+++ b/spec/requests/settings/exports/blocked_domains_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Settings / Exports / Blocked Domains' do
+ describe 'GET /settings/exports/domain_blocks' do
+ context 'with a signed in user who has blocked domains' do
+ let(:account) { Fabricate :account, domain: 'example.com' }
+ let(:user) { Fabricate :user, account: account }
+
+ before do
+ Fabricate(:account_domain_block, domain: 'example.com', account: account)
+ sign_in user
+ end
+
+ it 'returns a CSV with the domains' do
+ get '/settings/exports/domain_blocks.csv'
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.content_type)
+ .to eq('text/csv')
+ expect(response.body)
+ .to eq(<<~CSV)
+ example.com
+ CSV
+ end
+ end
+
+ describe 'when signed out' do
+ it 'returns unauthorized' do
+ get '/settings/exports/domain_blocks.csv'
+
+ expect(response)
+ .to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/spec/requests/settings/exports/bookmarks_spec.rb b/spec/requests/settings/exports/bookmarks_spec.rb
new file mode 100644
index 0000000000..f200e70383
--- /dev/null
+++ b/spec/requests/settings/exports/bookmarks_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Settings / Exports / Bookmarks' do
+ describe 'GET /settings/exports/bookmarks' do
+ context 'with a signed in user who has bookmarks' do
+ let(:account) { Fabricate(:account, domain: 'foo.bar') }
+ let(:status) { Fabricate(:status, account: account, uri: 'https://foo.bar/statuses/1312') }
+ let(:user) { Fabricate(:user) }
+
+ before do
+ Fabricate(
+ :bookmark,
+ account: user.account,
+ status: status
+ )
+ sign_in user
+ end
+
+ it 'returns a CSV with the bookmarked statuses' do
+ get '/settings/exports/bookmarks.csv'
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.content_type)
+ .to eq('text/csv')
+ expect(response.body)
+ .to eq(<<~CSV)
+ https://foo.bar/statuses/1312
+ CSV
+ end
+ end
+
+ describe 'when signed out' do
+ it 'returns unauthorized' do
+ get '/settings/exports/bookmarks.csv'
+
+ expect(response)
+ .to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/spec/requests/settings/exports/following_accounts_spec.rb b/spec/requests/settings/exports/following_accounts_spec.rb
new file mode 100644
index 0000000000..268b72c412
--- /dev/null
+++ b/spec/requests/settings/exports/following_accounts_spec.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Settings / Exports / Following Accounts' do
+ describe 'GET /settings/exports/follows' do
+ context 'with a signed in user who is following accounts' do
+ let(:user) { Fabricate :user }
+
+ before do
+ Fabricate(
+ :follow,
+ account: user.account,
+ target_account: Fabricate(:account, username: 'username', domain: 'domain'),
+ languages: ['en']
+ )
+ sign_in user
+ end
+
+ it 'returns a CSV with the accounts' do
+ get '/settings/exports/follows.csv'
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.content_type)
+ .to eq('text/csv')
+ expect(response.body)
+ .to eq(<<~CSV)
+ Account address,Show boosts,Notify on new posts,Languages
+ username@domain,true,false,en
+ CSV
+ end
+ end
+
+ describe 'when signed out' do
+ it 'returns unauthorized' do
+ get '/settings/exports/follows.csv'
+
+ expect(response)
+ .to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/spec/requests/settings/exports/lists_spec.rb b/spec/requests/settings/exports/lists_spec.rb
new file mode 100644
index 0000000000..b868f8dfda
--- /dev/null
+++ b/spec/requests/settings/exports/lists_spec.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Settings / Exports / Lists' do
+ describe 'GET /settings/exports/lists' do
+ context 'with a signed in user who has lists' do
+ let(:account) { Fabricate(:account, username: 'test', domain: 'example.com') }
+ let(:list) { Fabricate :list, account: account, title: 'The List' }
+ let(:user) { Fabricate(:user, account: account) }
+
+ before do
+ Fabricate(:list_account, list: list, account: account)
+ sign_in user
+ end
+
+ it 'returns a CSV with the list' do
+ get '/settings/exports/lists.csv'
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.content_type)
+ .to eq('text/csv')
+ expect(response.body)
+ .to eq(<<~CSV)
+ The List,test@example.com
+ CSV
+ end
+ end
+
+ describe 'when signed out' do
+ it 'returns unauthorized' do
+ get '/settings/exports/lists.csv'
+
+ expect(response)
+ .to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/spec/requests/settings/exports/muted_accounts_spec.rb b/spec/requests/settings/exports/muted_accounts_spec.rb
new file mode 100644
index 0000000000..efdb0d8221
--- /dev/null
+++ b/spec/requests/settings/exports/muted_accounts_spec.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+
+describe 'Settings / Exports / Muted Accounts' do
+ describe 'GET /settings/exports/mutes' do
+ context 'with a signed in user who has muted accounts' do
+ let(:user) { Fabricate :user }
+
+ before do
+ Fabricate(
+ :mute,
+ account: user.account,
+ target_account: Fabricate(:account, username: 'username', domain: 'domain')
+ )
+ sign_in user
+ end
+
+ it 'returns a CSV with the muted accounts' do
+ get '/settings/exports/mutes.csv'
+
+ expect(response)
+ .to have_http_status(200)
+ expect(response.content_type)
+ .to eq('text/csv')
+ expect(response.body)
+ .to eq(<<~CSV)
+ Account address,Hide notifications
+ username@domain,true
+ CSV
+ end
+ end
+
+ describe 'when signed out' do
+ it 'returns unauthorized' do
+ get '/settings/exports/mutes.csv'
+
+ expect(response)
+ .to have_http_status(401)
+ end
+ end
+ end
+end
diff --git a/streaming/database.js b/streaming/database.js
new file mode 100644
index 0000000000..9f1d742143
--- /dev/null
+++ b/streaming/database.js
@@ -0,0 +1,128 @@
+import pg from 'pg';
+import pgConnectionString from 'pg-connection-string';
+
+import { parseIntFromEnvValue } from './utils.js';
+
+/**
+ * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
+ * @param {string} environment
+ * @returns {pg.PoolConfig} the configuration for the PostgreSQL connection
+ */
+export function configFromEnv(env, environment) {
+ /** @type {Record
} */
+ const pgConfigs = {
+ development: {
+ user: env.DB_USER || pg.defaults.user,
+ password: env.DB_PASS || pg.defaults.password,
+ database: env.DB_NAME || 'mastodon_development',
+ host: env.DB_HOST || pg.defaults.host,
+ port: parseIntFromEnvValue(env.DB_PORT, pg.defaults.port ?? 5432, 'DB_PORT')
+ },
+
+ production: {
+ user: env.DB_USER || 'mastodon',
+ password: env.DB_PASS || '',
+ database: env.DB_NAME || 'mastodon_production',
+ host: env.DB_HOST || 'localhost',
+ port: parseIntFromEnvValue(env.DB_PORT, 5432, 'DB_PORT')
+ },
+ };
+
+ /**
+ * @type {pg.PoolConfig}
+ */
+ let baseConfig = {};
+
+ if (env.DATABASE_URL) {
+ const parsedUrl = pgConnectionString.parse(env.DATABASE_URL);
+
+ // The result of dbUrlToConfig from pg-connection-string is not type
+ // compatible with pg.PoolConfig, since parts of the connection URL may be
+ // `null` when pg.PoolConfig expects `undefined`, as such we have to
+ // manually create the baseConfig object from the properties of the
+ // parsedUrl.
+ //
+ // For more information see:
+ // https://github.com/brianc/node-postgres/issues/2280
+ //
+ // FIXME: clean up once brianc/node-postgres#3128 lands
+ if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
+ if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
+ if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
+ if (typeof parsedUrl.port === 'string') {
+ const parsedPort = parseInt(parsedUrl.port, 10);
+ if (isNaN(parsedPort)) {
+ throw new Error('Invalid port specified in DATABASE_URL environment variable');
+ }
+ baseConfig.port = parsedPort;
+ }
+ if (typeof parsedUrl.database === 'string') baseConfig.database = parsedUrl.database;
+ if (typeof parsedUrl.options === 'string') baseConfig.options = parsedUrl.options;
+
+ // The pg-connection-string type definition isn't correct, as parsedUrl.ssl
+ // can absolutely be an Object, this is to work around these incorrect
+ // types, including the casting of parsedUrl.ssl to Record
+ if (typeof parsedUrl.ssl === 'boolean') {
+ baseConfig.ssl = parsedUrl.ssl;
+ } else if (typeof parsedUrl.ssl === 'object' && !Array.isArray(parsedUrl.ssl) && parsedUrl.ssl !== null) {
+ /** @type {Record} */
+ const sslOptions = parsedUrl.ssl;
+ baseConfig.ssl = {};
+
+ baseConfig.ssl.cert = sslOptions.cert;
+ baseConfig.ssl.key = sslOptions.key;
+ baseConfig.ssl.ca = sslOptions.ca;
+ baseConfig.ssl.rejectUnauthorized = sslOptions.rejectUnauthorized;
+ }
+
+ // Support overriding the database password in the connection URL
+ if (!baseConfig.password && env.DB_PASS) {
+ baseConfig.password = env.DB_PASS;
+ }
+ } else if (Object.hasOwn(pgConfigs, environment)) {
+ baseConfig = pgConfigs[environment];
+
+ if (env.DB_SSLMODE) {
+ switch(env.DB_SSLMODE) {
+ case 'disable':
+ case '':
+ baseConfig.ssl = false;
+ break;
+ case 'no-verify':
+ baseConfig.ssl = { rejectUnauthorized: false };
+ break;
+ default:
+ baseConfig.ssl = {};
+ break;
+ }
+ }
+ } else {
+ throw new Error('Unable to resolve postgresql database configuration.');
+ }
+
+ return {
+ ...baseConfig,
+ max: parseIntFromEnvValue(env.DB_POOL, 10, 'DB_POOL'),
+ connectionTimeoutMillis: 15000,
+ // Deliberately set application_name to an empty string to prevent excessive
+ // CPU usage with PG Bouncer. See:
+ // - https://github.com/mastodon/mastodon/pull/23958
+ // - https://github.com/pgbouncer/pgbouncer/issues/349
+ application_name: '',
+ };
+}
+
+let pool;
+/**
+ *
+ * @param {pg.PoolConfig} config
+ * @returns {pg.Pool}
+ */
+export function getPool(config) {
+ if (pool) {
+ return pool;
+ }
+
+ pool = new pg.Pool(config);
+ return pool;
+}
diff --git a/streaming/index.js b/streaming/index.js
index 6d8fdcc61c..75a298a2c9 100644
--- a/streaming/index.js
+++ b/streaming/index.js
@@ -8,15 +8,14 @@ import url from 'node:url';
import cors from 'cors';
import dotenv from 'dotenv';
import express from 'express';
-import { Redis } from 'ioredis';
import { JSDOM } from 'jsdom';
-import pg from 'pg';
-import pgConnectionString from 'pg-connection-string';
import { WebSocketServer } from 'ws';
+import * as Database from './database.js';
import { AuthenticationError, RequestError, extractStatusAndMessage as extractErrorStatusAndMessage } from './errors.js';
import { logger, httpLogger, initializeLogLevel, attachWebsocketHttpLogger, createWebsocketLogger } from './logging.js';
import { setupMetrics } from './metrics.js';
+import * as Redis from './redis.js';
import { isTruthy, normalizeHashtag, firstParam } from './utils.js';
const environment = process.env.NODE_ENV || 'development';
@@ -48,23 +47,6 @@ initializeLogLevel(process.env, environment);
* @property {string} deviceId
*/
-/**
- * @param {RedisConfiguration} config
- * @returns {Promise}
- */
-const createRedisClient = async ({ redisParams, redisUrl }) => {
- let client;
-
- if (typeof redisUrl === 'string') {
- client = new Redis(redisUrl, redisParams);
- } else {
- client = new Redis(redisParams);
- }
-
- client.on('error', (err) => logger.error({ err }, 'Redis Client Error!'));
-
- return client;
-};
/**
* Attempts to safely parse a string as JSON, used when both receiving a message
@@ -97,177 +79,6 @@ const parseJSON = (json, req) => {
}
};
-/**
- * Takes an environment variable that should be an integer, attempts to parse
- * it falling back to a default if not set, and handles errors parsing.
- * @param {string|undefined} value
- * @param {number} defaultValue
- * @param {string} variableName
- * @returns {number}
- */
-const parseIntFromEnv = (value, defaultValue, variableName) => {
- if (typeof value === 'string' && value.length > 0) {
- const parsedValue = parseInt(value, 10);
- if (isNaN(parsedValue)) {
- throw new Error(`Invalid ${variableName} environment variable: ${value}`);
- }
- return parsedValue;
- } else {
- return defaultValue;
- }
-};
-
-/**
- * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
- * @returns {pg.PoolConfig} the configuration for the PostgreSQL connection
- */
-const pgConfigFromEnv = (env) => {
- /** @type {Record} */
- const pgConfigs = {
- development: {
- user: env.DB_USER || pg.defaults.user,
- password: env.DB_PASS || pg.defaults.password,
- database: env.DB_NAME || 'mastodon_development',
- host: env.DB_HOST || pg.defaults.host,
- port: parseIntFromEnv(env.DB_PORT, pg.defaults.port ?? 5432, 'DB_PORT')
- },
-
- production: {
- user: env.DB_USER || 'mastodon',
- password: env.DB_PASS || '',
- database: env.DB_NAME || 'mastodon_production',
- host: env.DB_HOST || 'localhost',
- port: parseIntFromEnv(env.DB_PORT, 5432, 'DB_PORT')
- },
- };
-
- /**
- * @type {pg.PoolConfig}
- */
- let baseConfig = {};
-
- if (env.DATABASE_URL) {
- const parsedUrl = pgConnectionString.parse(env.DATABASE_URL);
-
- // The result of dbUrlToConfig from pg-connection-string is not type
- // compatible with pg.PoolConfig, since parts of the connection URL may be
- // `null` when pg.PoolConfig expects `undefined`, as such we have to
- // manually create the baseConfig object from the properties of the
- // parsedUrl.
- //
- // For more information see:
- // https://github.com/brianc/node-postgres/issues/2280
- //
- // FIXME: clean up once brianc/node-postgres#3128 lands
- if (typeof parsedUrl.password === 'string') baseConfig.password = parsedUrl.password;
- if (typeof parsedUrl.host === 'string') baseConfig.host = parsedUrl.host;
- if (typeof parsedUrl.user === 'string') baseConfig.user = parsedUrl.user;
- if (typeof parsedUrl.port === 'string') {
- const parsedPort = parseInt(parsedUrl.port, 10);
- if (isNaN(parsedPort)) {
- throw new Error('Invalid port specified in DATABASE_URL environment variable');
- }
- baseConfig.port = parsedPort;
- }
- if (typeof parsedUrl.database === 'string') baseConfig.database = parsedUrl.database;
- if (typeof parsedUrl.options === 'string') baseConfig.options = parsedUrl.options;
-
- // The pg-connection-string type definition isn't correct, as parsedUrl.ssl
- // can absolutely be an Object, this is to work around these incorrect
- // types, including the casting of parsedUrl.ssl to Record
- if (typeof parsedUrl.ssl === 'boolean') {
- baseConfig.ssl = parsedUrl.ssl;
- } else if (typeof parsedUrl.ssl === 'object' && !Array.isArray(parsedUrl.ssl) && parsedUrl.ssl !== null) {
- /** @type {Record} */
- const sslOptions = parsedUrl.ssl;
- baseConfig.ssl = {};
-
- baseConfig.ssl.cert = sslOptions.cert;
- baseConfig.ssl.key = sslOptions.key;
- baseConfig.ssl.ca = sslOptions.ca;
- baseConfig.ssl.rejectUnauthorized = sslOptions.rejectUnauthorized;
- }
-
- // Support overriding the database password in the connection URL
- if (!baseConfig.password && env.DB_PASS) {
- baseConfig.password = env.DB_PASS;
- }
- } else if (Object.hasOwn(pgConfigs, environment)) {
- baseConfig = pgConfigs[environment];
-
- if (env.DB_SSLMODE) {
- switch(env.DB_SSLMODE) {
- case 'disable':
- case '':
- baseConfig.ssl = false;
- break;
- case 'no-verify':
- baseConfig.ssl = { rejectUnauthorized: false };
- break;
- default:
- baseConfig.ssl = {};
- break;
- }
- }
- } else {
- throw new Error('Unable to resolve postgresql database configuration.');
- }
-
- return {
- ...baseConfig,
- max: parseIntFromEnv(env.DB_POOL, 10, 'DB_POOL'),
- connectionTimeoutMillis: 15000,
- // Deliberately set application_name to an empty string to prevent excessive
- // CPU usage with PG Bouncer. See:
- // - https://github.com/mastodon/mastodon/pull/23958
- // - https://github.com/pgbouncer/pgbouncer/issues/349
- application_name: '',
- };
-};
-
-/**
- * @typedef RedisConfiguration
- * @property {import('ioredis').RedisOptions} redisParams
- * @property {string} redisPrefix
- * @property {string|undefined} redisUrl
- */
-
-/**
- * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
- * @returns {RedisConfiguration} configuration for the Redis connection
- */
-const redisConfigFromEnv = (env) => {
- // ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*,
- // which means we can't use it. But this is something that should be looked into.
- const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : '';
-
- let redisPort = parseIntFromEnv(env.REDIS_PORT, 6379, 'REDIS_PORT');
- let redisDatabase = parseIntFromEnv(env.REDIS_DB, 0, 'REDIS_DB');
-
- /** @type {import('ioredis').RedisOptions} */
- const redisParams = {
- host: env.REDIS_HOST || '127.0.0.1',
- port: redisPort,
- // Force support for both IPv6 and IPv4, by default ioredis sets this to 4,
- // only allowing IPv4 connections:
- // https://github.com/redis/ioredis/issues/1576
- family: 0,
- db: redisDatabase,
- password: env.REDIS_PASSWORD || undefined,
- };
-
- // redisParams.path takes precedence over host and port.
- if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) {
- redisParams.path = env.REDIS_URL.slice(7);
- }
-
- return {
- redisParams,
- redisPrefix,
- redisUrl: typeof env.REDIS_URL === 'string' ? env.REDIS_URL : undefined,
- };
-};
-
const PUBLIC_CHANNELS = [
'public',
'public:media',
@@ -291,10 +102,12 @@ const CHANNEL_NAMES = [
];
const startServer = async () => {
- const pgPool = new pg.Pool(pgConfigFromEnv(process.env));
+ const pgPool = Database.getPool(Database.configFromEnv(process.env, environment));
const metrics = setupMetrics(CHANNEL_NAMES, pgPool);
+ const redisConfig = Redis.configFromEnv(process.env);
+ const redisClient = Redis.createClient(redisConfig, logger);
const server = http.createServer();
const wss = new WebSocketServer({ noServer: true });
@@ -386,9 +199,7 @@ const startServer = async () => {
*/
const subs = {};
- const redisConfig = redisConfigFromEnv(process.env);
- const redisSubscribeClient = await createRedisClient(redisConfig);
- const redisClient = await createRedisClient(redisConfig);
+ const redisSubscribeClient = Redis.createClient(redisConfig, logger);
const { redisPrefix } = redisConfig;
// When checking metrics in the browser, the favicon is requested this
diff --git a/streaming/redis.js b/streaming/redis.js
new file mode 100644
index 0000000000..208d6ae078
--- /dev/null
+++ b/streaming/redis.js
@@ -0,0 +1,65 @@
+import { Redis } from 'ioredis';
+
+import { parseIntFromEnvValue } from './utils.js';
+
+/**
+ * @typedef RedisConfiguration
+ * @property {import('ioredis').RedisOptions} redisParams
+ * @property {string} redisPrefix
+ * @property {string|undefined} redisUrl
+ */
+
+/**
+ * @param {NodeJS.ProcessEnv} env the `process.env` value to read configuration from
+ * @returns {RedisConfiguration} configuration for the Redis connection
+ */
+export function configFromEnv(env) {
+ // ioredis *can* transparently add prefixes for us, but it doesn't *in some cases*,
+ // which means we can't use it. But this is something that should be looked into.
+ const redisPrefix = env.REDIS_NAMESPACE ? `${env.REDIS_NAMESPACE}:` : '';
+
+ let redisPort = parseIntFromEnvValue(env.REDIS_PORT, 6379, 'REDIS_PORT');
+ let redisDatabase = parseIntFromEnvValue(env.REDIS_DB, 0, 'REDIS_DB');
+
+ /** @type {import('ioredis').RedisOptions} */
+ const redisParams = {
+ host: env.REDIS_HOST || '127.0.0.1',
+ port: redisPort,
+ // Force support for both IPv6 and IPv4, by default ioredis sets this to 4,
+ // only allowing IPv4 connections:
+ // https://github.com/redis/ioredis/issues/1576
+ family: 0,
+ db: redisDatabase,
+ password: env.REDIS_PASSWORD || undefined,
+ };
+
+ // redisParams.path takes precedence over host and port.
+ if (env.REDIS_URL && env.REDIS_URL.startsWith('unix://')) {
+ redisParams.path = env.REDIS_URL.slice(7);
+ }
+
+ return {
+ redisParams,
+ redisPrefix,
+ redisUrl: typeof env.REDIS_URL === 'string' ? env.REDIS_URL : undefined,
+ };
+}
+
+/**
+ * @param {RedisConfiguration} config
+ * @param {import('pino').Logger} logger
+ * @returns {Redis}
+ */
+export function createClient({ redisParams, redisUrl }, logger) {
+ let client;
+
+ if (typeof redisUrl === 'string') {
+ client = new Redis(redisUrl, redisParams);
+ } else {
+ client = new Redis(redisParams);
+ }
+
+ client.on('error', (err) => logger.error({ err }, 'Redis Client Error!'));
+
+ return client;
+}
diff --git a/streaming/utils.js b/streaming/utils.js
index 4610bf660d..47c63dd4ca 100644
--- a/streaming/utils.js
+++ b/streaming/utils.js
@@ -59,3 +59,23 @@ export function firstParam(arrayOrString) {
return arrayOrString;
}
}
+
+/**
+ * Takes an environment variable that should be an integer, attempts to parse
+ * it falling back to a default if not set, and handles errors parsing.
+ * @param {string|undefined} value
+ * @param {number} defaultValue
+ * @param {string} variableName
+ * @returns {number}
+ */
+export function parseIntFromEnvValue(value, defaultValue, variableName) {
+ if (typeof value === 'string' && value.length > 0) {
+ const parsedValue = parseInt(value, 10);
+ if (isNaN(parsedValue)) {
+ throw new Error(`Invalid ${variableName} environment variable: ${value}`);
+ }
+ return parsedValue;
+ } else {
+ return defaultValue;
+ }
+}