Card: normalize with zod

This commit is contained in:
Alex Gleason 2023-05-02 17:47:19 -05:00
parent 0351bda198
commit 741da92084
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
7 changed files with 100 additions and 88 deletions

View file

@ -1,82 +1,25 @@
/**
* Card normalizer:
* Converts API cards into our internal format.
* @see {@link https://docs.joinmastodon.org/entities/card/}
*/
import punycode from 'punycode';
import { cardSchema, type Card } from 'soapbox/schemas/card';
import { Record as ImmutableRecord, Map as ImmutableMap, fromJS } from 'immutable';
import { groupSchema, type Group } from 'soapbox/schemas';
import { mergeDefined } from 'soapbox/utils/normalizers';
// https://docs.joinmastodon.org/entities/card/
export const CardRecord = ImmutableRecord({
author_name: '',
author_url: '',
blurhash: null as string | null,
description: '',
embed_url: '',
group: null as null | Group,
height: 0,
html: '',
image: null as string | null,
provider_name: '',
provider_url: '',
title: '',
type: 'link',
url: '',
width: 0,
});
const IDNA_PREFIX = 'xn--';
const decodeIDNA = (domain: string): string => {
return domain
.split('.')
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
.join('.');
};
const getHostname = (url: string): string => {
const parser = document.createElement('a');
parser.href = url;
return parser.hostname;
};
/** Fall back to Pleroma's OG data */
const normalizePleromaOpengraph = (card: ImmutableMap<string, any>) => {
const opengraph = ImmutableMap({
width: card.getIn(['pleroma', 'opengraph', 'width']),
height: card.getIn(['pleroma', 'opengraph', 'height']),
html: card.getIn(['pleroma', 'opengraph', 'html']),
image: card.getIn(['pleroma', 'opengraph', 'thumbnail_url']),
});
return card.mergeWith(mergeDefined, opengraph);
};
/** Set provider from URL if not found */
const normalizeProviderName = (card: ImmutableMap<string, any>) => {
const providerName = card.get('provider_name') || decodeIDNA(getHostname(card.get('url')));
return card.set('provider_name', providerName);
};
const normalizeGroup = (card: ImmutableMap<string, any>) => {
export const normalizeCard = (card: unknown): Card => {
try {
const group = groupSchema.parse(card.get('group').toJS());
return card.set('group', group);
return cardSchema.parse(card);
} catch (_e) {
return card.set('group', null);
return {
author_name: '',
author_url: '',
blurhash: null,
description: '',
embed_url: '',
group: null,
height: 0,
html: '',
image: null,
provider_name: '',
provider_url: '',
title: '',
type: 'link',
url: '',
width: 0,
};
}
};
export const normalizeCard = (card: Record<string, any>) => {
return CardRecord(
ImmutableMap(fromJS(card)).withMutations(card => {
normalizePleromaOpengraph(card);
normalizeProviderName(card);
normalizeGroup(card);
}),
);
};

View file

@ -4,7 +4,7 @@ export { AdminReportRecord, normalizeAdminReport } from './admin-report';
export { AnnouncementRecord, normalizeAnnouncement } from './announcement';
export { AnnouncementReactionRecord, normalizeAnnouncementReaction } from './announcement-reaction';
export { AttachmentRecord, normalizeAttachment } from './attachment';
export { CardRecord, normalizeCard } from './card';
export { normalizeCard } from './card';
export { ChatRecord, normalizeChat } from './chat';
export { ChatMessageRecord, normalizeChatMessage } from './chat-message';
export { EmojiRecord, normalizeEmoji } from './emoji';

View file

@ -4,12 +4,12 @@ import {
fromJS,
} from 'immutable';
import { CardRecord, normalizeCard } from '../card';
import { normalizeCard } from '../card';
import type { Ad } from 'soapbox/features/ads/providers';
export const AdRecord = ImmutableRecord<Ad>({
card: CardRecord(),
card: normalizeCard({}),
impression: undefined as string | undefined,
expires_at: undefined as string | undefined,
reason: undefined as string | undefined,
@ -18,7 +18,7 @@ export const AdRecord = ImmutableRecord<Ad>({
/** Normalizes an ad from Soapbox Config. */
export const normalizeAd = (ad: Record<string, any>) => {
const map = ImmutableMap<string, any>(fromJS(ad));
const card = normalizeCard(map.get('card'));
const card = normalizeCard(map.get('card').toJS());
const expiresAt = map.get('expires_at') || map.get('expires');
return AdRecord(map.merge({

View file

@ -11,10 +11,10 @@ import {
} from 'immutable';
import { normalizeAttachment } from 'soapbox/normalizers/attachment';
import { normalizeCard } from 'soapbox/normalizers/card';
import { normalizeEmoji } from 'soapbox/normalizers/emoji';
import { normalizeMention } from 'soapbox/normalizers/mention';
import { normalizePoll } from 'soapbox/normalizers/poll';
import { cardSchema } from 'soapbox/schemas/card';
import type { ReducerAccount } from 'soapbox/reducers/accounts';
import type { Account, Attachment, Card, Emoji, Group, Mention, Poll, EmbeddedEntity } from 'soapbox/types/entities';
@ -118,9 +118,10 @@ const normalizeStatusPoll = (status: ImmutableMap<string, any>) => {
// Normalize card
const normalizeStatusCard = (status: ImmutableMap<string, any>) => {
if (status.get('card')) {
return status.update('card', ImmutableMap(), normalizeCard);
} else {
try {
const card = cardSchema.parse(status.get('card').toJS());
return status.set('card', card);
} catch (e) {
return status.set('card', null);
}
};

View file

@ -0,0 +1,69 @@
import punycode from 'punycode';
import { z } from 'zod';
import { groupSchema } from './group';
const IDNA_PREFIX = 'xn--';
/**
* Card (aka link preview).
* https://docs.joinmastodon.org/entities/card/
*/
const cardSchema = z.object({
author_name: z.string().catch(''),
author_url: z.string().url().catch(''),
blurhash: z.string().nullable().catch(null),
description: z.string().catch(''),
embed_url: z.string().url().catch(''),
group: groupSchema.nullable().catch(null), // TruthSocial
height: z.number().catch(0),
html: z.string().catch(''),
image: z.string().nullable().catch(null),
pleroma: z.object({
opengraph: z.object({
width: z.number(),
height: z.number(),
html: z.string(),
thumbnail_url: z.string().url(),
}).optional().catch(undefined),
}).optional().catch(undefined),
provider_name: z.string().catch(''),
provider_url: z.string().url().catch(''),
title: z.string().catch(''),
type: z.enum(['link', 'photo', 'video', 'rich']).catch('link'),
url: z.string().url(),
width: z.number().catch(0),
}).transform((card) => {
if (!card.provider_name) {
card.provider_name = decodeIDNA(new URL(card.url).hostname);
}
if (card.pleroma?.opengraph) {
if (!card.width && !card.height) {
card.width = card.pleroma.opengraph.width;
card.height = card.pleroma.opengraph.height;
}
if (!card.html) {
card.html = card.pleroma.opengraph.html;
}
if (!card.image) {
card.image = card.pleroma.opengraph.thumbnail_url;
}
}
return card;
});
const decodeIDNA = (domain: string): string => {
return domain
.split('.')
.map(part => part.indexOf(IDNA_PREFIX) === 0 ? punycode.decode(part.slice(IDNA_PREFIX.length)) : part)
.join('.');
};
type Card = z.infer<typeof cardSchema>;
export { cardSchema, type Card };

View file

@ -13,6 +13,7 @@ export { relationshipSchema } from './relationship';
* Entity Types
*/
export type { Account } from './account';
export type { Card } from './card';
export type { CustomEmoji } from './custom-emoji';
export type { Group } from './group';
export type { GroupMember } from './group-member';

View file

@ -5,7 +5,6 @@ import {
AnnouncementRecord,
AnnouncementReactionRecord,
AttachmentRecord,
CardRecord,
ChatRecord,
ChatMessageRecord,
EmojiRecord,
@ -37,7 +36,6 @@ type AdminReport = ReturnType<typeof AdminReportRecord>;
type Announcement = ReturnType<typeof AnnouncementRecord>;
type AnnouncementReaction = ReturnType<typeof AnnouncementReactionRecord>;
type Attachment = ReturnType<typeof AttachmentRecord>;
type Card = ReturnType<typeof CardRecord>;
type Chat = ReturnType<typeof ChatRecord>;
type ChatMessage = ReturnType<typeof ChatMessageRecord>;
type Emoji = ReturnType<typeof EmojiRecord>;
@ -82,7 +80,6 @@ export {
Announcement,
AnnouncementReaction,
Attachment,
Card,
Chat,
ChatMessage,
Emoji,
@ -110,6 +107,7 @@ export {
};
export type {
Card,
Group,
GroupMember,
GroupRelationship,