From 7c752f088c15702c24b7526806f96e86f1344838 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marcin=20miko=C5=82ajczak?= Date: Fri, 27 Oct 2023 21:18:30 +0200 Subject: [PATCH] instance v1 to v2 convesion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: marcin mikołajczak --- src/actions/instance.ts | 47 ++++++-- src/api/hooks/streaming/useTimelineStream.ts | 2 +- .../components/registration-mode-picker.tsx | 6 +- .../components/registration-form.tsx | 2 +- src/features/compose/components/upload.tsx | 2 +- .../components/site-banner.tsx | 2 +- src/reducers/instance.ts | 24 ++-- src/schemas/instance.ts | 104 ++++++++++++++++-- src/stream.ts | 2 +- src/utils/features.ts | 14 ++- 10 files changed, 165 insertions(+), 40 deletions(-) diff --git a/src/actions/instance.ts b/src/actions/instance.ts index 6ea62ca961..b949dcb652 100644 --- a/src/actions/instance.ts +++ b/src/actions/instance.ts @@ -1,10 +1,11 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import get from 'lodash/get'; +import { gte } from 'semver'; import KVStore from 'soapbox/storage/kv-store'; import { RootState } from 'soapbox/store'; import { getAuthUserUrl, getMeUrl } from 'soapbox/utils/auth'; -import { parseVersion } from 'soapbox/utils/features'; +import { MASTODON, parseVersion, PLEROMA, REBASED } from 'soapbox/utils/features'; import api from '../api'; @@ -22,25 +23,50 @@ export const getHost = (state: RootState) => { export const rememberInstance = createAsyncThunk( 'instance/remember', async(host: string) => { - return await KVStore.getItemOrError(`instance:${host}`); + const instance = await KVStore.getItemOrError(`instance:${host}`); + + return { instance, host }; }, ); +const supportsInstanceV2 = (instance: Record): boolean => { + const v = parseVersion(get(instance, 'version')); + return (v.software === MASTODON && gte(v.compatVersion, '4.0.0')) || + (v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.5.54')); +}; + /** We may need to fetch nodeinfo on Pleroma < 2.1 */ const needsNodeinfo = (instance: Record): boolean => { const v = parseVersion(get(instance, 'version')); - return v.software === 'Pleroma' && !get(instance, ['pleroma', 'metadata']); + return v.software === PLEROMA && !get(instance, ['pleroma', 'metadata']); }; -export const fetchInstance = createAsyncThunk( +export const fetchInstance = createAsyncThunk<{ instance: Record; host?: string | null }, string | null | undefined, { state: RootState }>( 'instance/fetch', - async(_arg, { dispatch, getState, rejectWithValue }) => { + async(host, { dispatch, getState, rejectWithValue }) => { try { const { data: instance } = await api(getState).get('/api/v1/instance'); + + if (supportsInstanceV2(instance)) { + return dispatch(fetchInstanceV2(host)) as any as { instance: Record; host?: string | null }; + } + if (needsNodeinfo(instance)) { dispatch(fetchNodeinfo()); } - return instance; + return { instance, host }; + } catch (e) { + return rejectWithValue(e); + } + }, +); + +export const fetchInstanceV2 = createAsyncThunk<{ instance: Record; host?: string | null }, string | null | undefined, { state: RootState }>( + 'instance/fetch', + async(host, { getState, rejectWithValue }) => { + try { + const { data: instance } = await api(getState).get('/api/v2/instance'); + return { instance, host }; } catch (e) { return rejectWithValue(e); } @@ -52,10 +78,11 @@ export const loadInstance = createAsyncThunk( 'instance/load', async(_arg, { dispatch, getState }) => { const host = getHost(getState()); - await Promise.all([ - dispatch(rememberInstance(host || '')), - dispatch(fetchInstance()), - ]); + const rememberedInstance = await dispatch(rememberInstance(host || '')); + + if (rememberedInstance.payload && supportsInstanceV2((rememberedInstance.payload as any).instance)) { + await dispatch(fetchInstanceV2(host)); + } else dispatch(fetchInstance(host)); }, ); diff --git a/src/api/hooks/streaming/useTimelineStream.ts b/src/api/hooks/streaming/useTimelineStream.ts index 37ee40f1e7..22e9d0cf43 100644 --- a/src/api/hooks/streaming/useTimelineStream.ts +++ b/src/api/hooks/streaming/useTimelineStream.ts @@ -14,7 +14,7 @@ function useTimelineStream(...args: Parameters) { const stream = useRef<(() => void) | null>(null); const accessToken = useAppSelector(getAccessToken); - const streamingUrl = instance.urls?.streaming_api; + const streamingUrl = instance.configuration.urls.streaming; const connect = () => { if (enabled && streamingUrl && !stream.current) { diff --git a/src/features/admin/components/registration-mode-picker.tsx b/src/features/admin/components/registration-mode-picker.tsx index 51d18ab43d..744809a320 100644 --- a/src/features/admin/components/registration-mode-picker.tsx +++ b/src/features/admin/components/registration-mode-picker.tsx @@ -27,9 +27,9 @@ const generateConfig = (mode: RegistrationMode) => { }]; }; -const modeFromInstance = (instance: Instance): RegistrationMode => { - if (instance.approval_required && instance.registrations) return 'approval'; - return instance.registrations ? 'open' : 'closed'; +const modeFromInstance = ({ registrations }: Instance): RegistrationMode => { + if (registrations.approval_required && registrations.enabled) return 'approval'; + return registrations.enabled ? 'open' : 'closed'; }; /** Allows changing the registration mode of the instance, eg "open", "closed", "approval" */ diff --git a/src/features/auth-login/components/registration-form.tsx b/src/features/auth-login/components/registration-form.tsx index 1b19b0b37f..5d39eb4029 100644 --- a/src/features/auth-login/components/registration-form.tsx +++ b/src/features/auth-login/components/registration-form.tsx @@ -47,7 +47,7 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { const locale = settings.get('locale'); const needsConfirmation = instance.pleroma.metadata.account_activation_required; - const needsApproval = instance.approval_required; + const needsApproval = instance.registrations.approval_required; const supportsEmailList = features.emailList; const supportsAccountLookup = features.accountLookup; const birthdayRequired = instance.pleroma.metadata.birthday_required; diff --git a/src/features/compose/components/upload.tsx b/src/features/compose/components/upload.tsx index 4cb1635535..a7f89d4876 100644 --- a/src/features/compose/components/upload.tsx +++ b/src/features/compose/components/upload.tsx @@ -12,7 +12,7 @@ interface IUploadCompose { const UploadCompose: React.FC = ({ composeId, id, onSubmit }) => { const dispatch = useAppDispatch(); - const { description_limit: descriptionLimit } = useInstance(); + const { pleroma: { metadata: { description_limit: descriptionLimit } } } = useInstance(); const media = useCompose(composeId).media_attachments.find(item => item.id === id)!; diff --git a/src/features/landing-timeline/components/site-banner.tsx b/src/features/landing-timeline/components/site-banner.tsx index 453447c9a0..194faecc64 100644 --- a/src/features/landing-timeline/components/site-banner.tsx +++ b/src/features/landing-timeline/components/site-banner.tsx @@ -9,7 +9,7 @@ import { LogoText } from './logo-text'; const SiteBanner: React.FC = () => { const instance = useInstance(); - const description = instance.short_description || instance.description; + const description = instance.description; return ( diff --git a/src/reducers/instance.ts b/src/reducers/instance.ts index 2ccb89325b..1f98e097dd 100644 --- a/src/reducers/instance.ts +++ b/src/reducers/instance.ts @@ -4,20 +4,24 @@ import { Map as ImmutableMap, List as ImmutableList, fromJS } from 'immutable'; import { ADMIN_CONFIG_UPDATE_REQUEST, ADMIN_CONFIG_UPDATE_SUCCESS } from 'soapbox/actions/admin'; import { PLEROMA_PRELOAD_IMPORT } from 'soapbox/actions/preload'; import { type Instance, instanceSchema } from 'soapbox/schemas'; +import { instanceV1ToV2 } from 'soapbox/schemas/instance'; import KVStore from 'soapbox/storage/kv-store'; import { ConfigDB } from 'soapbox/utils/config-db'; import { rememberInstance, fetchInstance, + fetchInstanceV2, } from '../actions/instance'; import type { AnyAction } from 'redux'; +import type { APIEntity } from 'soapbox/types/entities'; const initialState: Instance = instanceSchema.parse({}); -const importInstance = (_state: typeof initialState, instance: unknown) => { - return instanceSchema.parse(instance); +const importInstance = (_state: typeof initialState, instance: APIEntity) => { + if (typeof instance.domain === 'string') return instanceSchema.parse(instance); + return instanceV1ToV2.parse(instance); }; const preloadImport = (state: typeof initialState, action: Record, path: string) => { @@ -45,8 +49,10 @@ const importConfigs = (state: typeof initialState, configs: ImmutableList) const registrationsOpen = getConfigValue(value, ':registrations_open') as boolean | undefined; const approvalRequired = getConfigValue(value, ':account_approval_required') as boolean | undefined; - draft.registrations = registrationsOpen ?? draft.registrations; - draft.approval_required = approvalRequired ?? draft.approval_required; + draft.registrations = { + enabled: registrationsOpen ?? draft.registrations.enabled, + approval_required: approvalRequired ?? draft.registrations.approval_required, + }; } if (simplePolicy) { @@ -76,9 +82,7 @@ const getHost = (instance: { uri: string }) => { } }; -const persistInstance = (instance: { uri: string }) => { - const host = getHost(instance); - +const persistInstance = (instance: { uri: string }, host: string | null = getHost(instance)) => { if (host) { KVStore.setItem(`instance:${host}`, instance).catch(console.error); } @@ -97,11 +101,13 @@ export default function instance(state = initialState, action: AnyAction) { case PLEROMA_PRELOAD_IMPORT: return preloadImport(state, action, '/api/v1/instance'); case rememberInstance.fulfilled.type: - return importInstance(state, action.payload); + return importInstance(state, action.payload.instance); case fetchInstance.fulfilled.type: + case fetchInstanceV2.fulfilled.type: persistInstance(action.payload); - return importInstance(state, action.payload); + return importInstance(state, action.payload.instance); case fetchInstance.rejected.type: + case fetchInstanceV2.rejected.type: return handleInstanceFetchFail(state, action.error); case ADMIN_CONFIG_UPDATE_REQUEST: case ADMIN_CONFIG_UPDATE_SUCCESS: diff --git a/src/schemas/instance.ts b/src/schemas/instance.ts index 4e55a7456f..e1c1ef0a8c 100644 --- a/src/schemas/instance.ts +++ b/src/schemas/instance.ts @@ -53,6 +53,17 @@ const configurationSchema = coerceObject({ max_characters: z.number().optional().catch(undefined), max_media_attachments: z.number().optional().catch(undefined), }), + translation: coerceObject({ + enabled: z.boolean().catch(false), + }), + urls: coerceObject({ + streaming: z.string().url().optional().catch(undefined), + }), +}); + +const contactSchema = coerceObject({ + contact_account: accountSchema.optional().catch(undefined), + email: z.string().email().catch(''), }); const nostrSchema = coerceObject({ @@ -65,6 +76,7 @@ const pleromaSchema = coerceObject({ account_activation_required: z.boolean().catch(false), birthday_min_age: z.number().catch(0), birthday_required: z.boolean().catch(false), + description_limit: z.number().catch(1500), features: z.string().array().catch([]), federation: coerceObject({ enabled: z.boolean().catch(true), // Assume true unless explicitly false @@ -112,14 +124,20 @@ const pleromaPollLimitsSchema = coerceObject({ min_expiration: z.number().optional().catch(undefined), }); +const registrations = coerceObject({ + approval_required: z.boolean().catch(false), + enabled: z.boolean().catch(false), + message: z.string().optional().catch(undefined), +}); + const statsSchema = coerceObject({ domain_count: z.number().catch(0), status_count: z.number().catch(0), user_count: z.number().catch(0), }); -const urlsSchema = coerceObject({ - streaming_api: z.string().url().optional().catch(undefined), +const thumbnailSchema = coerceObject({ + url: z.string().catch(''), }); const usageSchema = coerceObject({ @@ -129,12 +147,10 @@ const usageSchema = coerceObject({ }); const instanceSchema = coerceObject({ - approval_required: z.boolean().catch(false), configuration: configurationSchema, - contact_account: accountSchema.optional().catch(undefined), + contact: contactSchema, description: z.string().catch(''), - description_limit: z.number().catch(1500), - email: z.string().email().catch(''), + domain: z.string().catch(''), feature_quote: z.boolean().catch(false), fedibird_capabilities: z.array(z.string()).catch([]), languages: z.string().array().catch([]), @@ -143,13 +159,11 @@ const instanceSchema = coerceObject({ nostr: nostrSchema.optional().catch(undefined), pleroma: pleromaSchema, poll_limits: pleromaPollLimitsSchema, - registrations: z.boolean().catch(false), + registrations: registrations, rules: filteredArray(ruleSchema), - short_description: z.string().catch(''), stats: statsSchema, - thumbnail: z.string().catch(''), + thumbnail: thumbnailSchema, title: z.string().catch(''), - urls: urlsSchema, usage: usageSchema, version: z.string().catch(''), }).transform(({ max_media_attachments, max_toot_chars, poll_limits, ...instance }) => { @@ -182,6 +196,74 @@ const instanceSchema = coerceObject({ }; }); +const instanceV1ToV2 = coerceObject({ + approval_required: z.boolean().catch(false), + configuration: configurationSchema, + contact_account: accountSchema.optional().catch(undefined), + description: z.string().catch(''), + description_limit: z.number().catch(1500), + email: z.string().email().catch(''), + feature_quote: z.boolean().catch(false), + fedibird_capabilities: z.array(z.string()).catch([]), + languages: z.string().array().catch([]), + max_media_attachments: z.number().optional().catch(undefined), + max_toot_chars: z.number().optional().catch(undefined), + nostr: nostrSchema.optional().catch(undefined), + pleroma: pleromaSchema, + poll_limits: pleromaPollLimitsSchema, + registrations: z.boolean().catch(false), + rules: filteredArray(ruleSchema), + short_description: z.string().catch(''), + stats: statsSchema, + thumbnail: z.string().catch(''), + title: z.string().catch(''), + urls: coerceObject({ + streaming_api: z.string().url().optional().catch(undefined), + }), + usage: usageSchema, + version: z.string().catch(''), +}).transform(({ + approval_required, + configuration, + contact_account, + description, + description_limit, + email, + pleroma, + registrations, + short_description, + thumbnail, + urls, + ...instance +}) => { + return instanceSchema.parse({ + ...instance, + configuration: { + ...configuration, + urls: { + streaming: urls.streaming_api, + }, + }, + contact: { + account: contact_account, + email: email, + }, + description: short_description || description, + pleroma: { + ...pleroma, + metadata: { + ...pleroma.metadata, + description_limit, + }, + }, + registrations: { + approval_required: approval_required, + enabled: registrations, + }, + thumbnail: { url: thumbnail }, + }); +}); + type Instance = z.infer; -export { instanceSchema, Instance }; +export { instanceSchema, instanceV1ToV2, Instance }; diff --git a/src/stream.ts b/src/stream.ts index bcbcd2b02e..599704d126 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -20,7 +20,7 @@ export function connectStream( callbacks: (dispatch: AppDispatch, getState: () => RootState) => ConnectStreamCallbacks, ) { return (dispatch: AppDispatch, getState: () => RootState) => { - const streamingAPIBaseURL = getState().instance.urls.streaming_api; + const streamingAPIBaseURL = getState().instance.configuration.urls.streaming; const accessToken = getAccessToken(getState()); const { onConnect, onDisconnect, onReceive } = callbacks(dispatch, getState); diff --git a/src/utils/features.ts b/src/utils/features.ts index b7dadd51ba..842e6490f3 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -620,6 +620,16 @@ const getInstanceFeatures = (instance: Instance) => { */ importData: v.software === PLEROMA && gte(v.version, '2.2.0'), + /** + * Mastodon server information API v2. + * @see GET /api/v2/instance + * @see {@link https://docs.joinmastodon.org/methods/instance/#v2} + */ + instanceV2: any([ + v.software === MASTODON && gte(v.compatVersion, '4.0.0'), + v.software === PLEROMA && v.build === REBASED && gte(v.version, '2.5.54'), + ]), + /** * Can create, view, and manage lists. * @see {@link https://docs.joinmastodon.org/methods/lists/} @@ -926,7 +936,7 @@ const getInstanceFeatures = (instance: Instance) => { * Can translate statuses. * @see POST /api/v1/statuses/:id/translate */ - translations: features.includes('translation'), + translations: features.includes('translation') || instance.configuration.translation.enabled, /** * Trending statuses. @@ -994,7 +1004,7 @@ export const parseVersion = (version: string): Backend => { build: semver.build[0], compatVersion: compat.version, software: match[2] || MASTODON, - version: semver.version, + version: semver.version.split('-')[0], }; } else { // If we can't parse the version, this is a new and exotic backend.