feat(api): Add profile fields with emojis and Markdown to users

This commit is contained in:
Jesse Wierzbinski 2024-04-24 18:37:55 -10:00
parent 6373b8ae78
commit cde106a5db
No known key found for this signature in database
9 changed files with 2045 additions and 183 deletions

View file

@ -0,0 +1 @@
ALTER TABLE "Users" ADD COLUMN "fields" jsonb DEFAULT '[]' NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -1,146 +1,153 @@
{ {
"version": "5", "version": "5",
"dialect": "pg", "dialect": "pg",
"entries": [ "entries": [
{ {
"idx": 0, "idx": 0,
"version": "5", "version": "5",
"when": 1712805159664, "when": 1712805159664,
"tag": "0000_illegal_living_lightning", "tag": "0000_illegal_living_lightning",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 1, "idx": 1,
"version": "5", "version": "5",
"when": 1713055774123, "when": 1713055774123,
"tag": "0001_salty_night_thrasher", "tag": "0001_salty_night_thrasher",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 2, "idx": 2,
"version": "5", "version": "5",
"when": 1713056370431, "when": 1713056370431,
"tag": "0002_stiff_ares", "tag": "0002_stiff_ares",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 3, "idx": 3,
"version": "5", "version": "5",
"when": 1713056528340, "when": 1713056528340,
"tag": "0003_spicy_arachne", "tag": "0003_spicy_arachne",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 4, "idx": 4,
"version": "5", "version": "5",
"when": 1713056712218, "when": 1713056712218,
"tag": "0004_burly_lockjaw", "tag": "0004_burly_lockjaw",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 5, "idx": 5,
"version": "5", "version": "5",
"when": 1713056917973, "when": 1713056917973,
"tag": "0005_sleepy_puma", "tag": "0005_sleepy_puma",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 6, "idx": 6,
"version": "5", "version": "5",
"when": 1713057159867, "when": 1713057159867,
"tag": "0006_messy_network", "tag": "0006_messy_network",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 7, "idx": 7,
"version": "5", "version": "5",
"when": 1713227918208, "when": 1713227918208,
"tag": "0007_naive_sleeper", "tag": "0007_naive_sleeper",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 8, "idx": 8,
"version": "5", "version": "5",
"when": 1713246700119, "when": 1713246700119,
"tag": "0008_flawless_brother_voodoo", "tag": "0008_flawless_brother_voodoo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 9, "idx": 9,
"version": "5", "version": "5",
"when": 1713327832438, "when": 1713327832438,
"tag": "0009_easy_slyde", "tag": "0009_easy_slyde",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 10, "idx": 10,
"version": "5", "version": "5",
"when": 1713327880929, "when": 1713327880929,
"tag": "0010_daffy_frightful_four", "tag": "0010_daffy_frightful_four",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 11, "idx": 11,
"version": "5", "version": "5",
"when": 1713333611707, "when": 1713333611707,
"tag": "0011_special_the_fury", "tag": "0011_special_the_fury",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 12, "idx": 12,
"version": "5", "version": "5",
"when": 1713336108114, "when": 1713336108114,
"tag": "0012_certain_thor_girl", "tag": "0012_certain_thor_girl",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 13, "idx": 13,
"version": "5", "version": "5",
"when": 1713336611301, "when": 1713336611301,
"tag": "0013_wandering_celestials", "tag": "0013_wandering_celestials",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 14, "idx": 14,
"version": "5", "version": "5",
"when": 1713389937821, "when": 1713389937821,
"tag": "0014_wonderful_sandman", "tag": "0014_wonderful_sandman",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 15, "idx": 15,
"version": "5", "version": "5",
"when": 1713399438164, "when": 1713399438164,
"tag": "0015_easy_mojo", "tag": "0015_easy_mojo",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 16, "idx": 16,
"version": "5", "version": "5",
"when": 1713413369623, "when": 1713413369623,
"tag": "0016_keen_mindworm", "tag": "0016_keen_mindworm",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 17, "idx": 17,
"version": "5", "version": "5",
"when": 1713417089150, "when": 1713417089150,
"tag": "0017_dusty_black_knight", "tag": "0017_dusty_black_knight",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 18, "idx": 18,
"version": "5", "version": "5",
"when": 1713418575392, "when": 1713418575392,
"tag": "0018_rapid_hairball", "tag": "0018_rapid_hairball",
"breakpoints": true "breakpoints": true
}, },
{ {
"idx": 19, "idx": 19,
"version": "5", "version": "5",
"when": 1713421706451, "when": 1713421706451,
"tag": "0019_mushy_lorna_dane", "tag": "0019_mushy_lorna_dane",
"breakpoints": true "breakpoints": true
} },
] {
} "idx": 20,
"version": "5",
"when": 1714017186457,
"tag": "0020_giant_the_stranger",
"breakpoints": true
}
]
}

View file

@ -12,6 +12,7 @@ import {
uuid, uuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import type { Source as APISource } from "~types/mastodon/source"; import type { Source as APISource } from "~types/mastodon/source";
import type * as Lysand from "lysand-types";
export const Emojis = pgTable("Emojis", { export const Emojis = pgTable("Emojis", {
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(), id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
@ -362,6 +363,12 @@ export const Users = pgTable(
email: text("email"), email: text("email"),
note: text("note").default("").notNull(), note: text("note").default("").notNull(),
isAdmin: boolean("is_admin").default(false).notNull(), isAdmin: boolean("is_admin").default(false).notNull(),
fields: jsonb("fields").notNull().default("[]").$type<
{
key: Lysand.ContentFormat;
value: Lysand.ContentFormat;
}[]
>(),
endpoints: jsonb("endpoints").$type<Partial<{ endpoints: jsonb("endpoints").$type<Partial<{
dislikes: string; dislikes: string;
featured: string; featured: string;

View file

@ -206,6 +206,7 @@ export class User {
inbox: data.inbox, inbox: data.inbox,
outbox: data.outbox, outbox: data.outbox,
}, },
fields: data.fields ?? [],
updatedAt: new Date(data.created_at).toISOString(), updatedAt: new Date(data.created_at).toISOString(),
instanceId: instance.id, instanceId: instance.id,
avatar: data.avatar avatar: data.avatar
@ -312,6 +313,7 @@ export class User {
header: data.header ?? config.defaults.avatar, header: data.header ?? config.defaults.avatar,
isAdmin: data.admin ?? false, isAdmin: data.admin ?? false,
publicKey: keys.public_key, publicKey: keys.public_key,
fields: [],
privateKey: keys.private_key, privateKey: keys.private_key,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
source: { source: {
@ -373,8 +375,10 @@ export class User {
following_count: user.followingCount, following_count: user.followingCount,
statuses_count: user.statusCount, statuses_count: user.statusCount,
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)), emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
// TODO: Add fields fields: user.fields.map((field) => ({
fields: [], name: htmlToText(getBestContentType(field.key).content),
value: getBestContentType(field.value).content,
})),
bot: user.isBot, bot: user.isBot,
source: isOwnAccount ? user.source : undefined, source: isOwnAccount ? user.source : undefined,
// TODO: Add static avatar and header // TODO: Add static avatar and header
@ -450,24 +454,7 @@ export class User {
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined, avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined,
header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined, header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined,
display_name: user.displayName, display_name: user.displayName,
fields: user.source.fields.map((field) => ({ fields: user.fields,
key: {
"text/html": {
content: field.name,
},
"text/plain": {
content: htmlToText(field.name),
},
},
value: {
"text/html": {
content: field.value,
},
"text/plain": {
content: htmlToText(field.value),
},
},
})),
public_key: { public_key: {
actor: new URL( actor: new URL(
`/users/${user.id}`, `/users/${user.id}`,

View file

@ -190,6 +190,7 @@ export const createServer = (
headers: { headers: {
// Include for SSR // Include for SSR
"X-Forwarded-Host": `${config.http.bind}:${config.http.bind_port}`, "X-Forwarded-Host": `${config.http.bind}:${config.http.bind_port}`,
"Accept-Encoding": "identity",
}, },
}).catch(async (e) => { }).catch(async (e) => {
await logger.logError( await logger.logError(

View file

@ -9,7 +9,7 @@ import type { MediaBackend } from "media-manager";
import { LocalMediaBackend, S3MediaBackend } from "media-manager"; import { LocalMediaBackend, S3MediaBackend } from "media-manager";
import { z } from "zod"; import { z } from "zod";
import { getUrl } from "~database/entities/Attachment"; import { getUrl } from "~database/entities/Attachment";
import { parseEmojis } from "~database/entities/Emoji"; import { parseEmojis, type EmojiWithInstance } from "~database/entities/Emoji";
import { contentToHtml } from "~database/entities/Status"; import { contentToHtml } from "~database/entities/Status";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { EmojiToUser, Users } from "~drizzle/schema"; import { EmojiToUser, Users } from "~drizzle/schema";
@ -40,12 +40,25 @@ export const schema = z.object({
locked: z.boolean().optional(), locked: z.boolean().optional(),
bot: z.boolean().optional(), bot: z.boolean().optional(),
discoverable: z.boolean().optional(), discoverable: z.boolean().optional(),
"source[privacy]": z source: z
.enum(["public", "unlisted", "private", "direct"]) .object({
privacy: z
.enum(["public", "unlisted", "private", "direct"])
.optional(),
sensitive: z.boolean().optional(),
language: z
.enum(ISO6391.getAllCodes() as [string, ...string[]])
.optional(),
})
.optional(), .optional(),
"source[sensitive]": z.boolean().optional(), fields_attributes: z
"source[language]": z .array(
.enum(ISO6391.getAllCodes() as [string, ...string[]]) z.object({
name: z.string().max(config.validation.max_field_name_size),
value: z.string().max(config.validation.max_field_value_size),
}),
)
.max(config.validation.max_field_count)
.optional(), .optional(),
}); });
@ -66,9 +79,8 @@ export default apiRoute<typeof meta, typeof schema>(
locked, locked,
bot, bot,
discoverable, discoverable,
"source[privacy]": source_privacy, source,
"source[sensitive]": source_sensitive, fields_attributes,
"source[language]": source_language,
} = extraData.parsedRequest; } = extraData.parsedRequest;
const sanitizedNote = await sanitizeHtml(note ?? ""); const sanitizedNote = await sanitizeHtml(note ?? "");
@ -133,16 +145,16 @@ export default apiRoute<typeof meta, typeof schema>(
}); });
} }
if (source_privacy && self.source) { if (source?.privacy) {
self.source.privacy = source_privacy; self.source.privacy = source.privacy;
} }
if (source_sensitive && self.source) { if (source?.sensitive) {
self.source.sensitive = source_sensitive; self.source.sensitive = source.sensitive;
} }
if (source_language && self.source) { if (source?.language) {
self.source.language = source_language; self.source.language = source.language;
} }
if (avatar) { if (avatar) {
@ -185,11 +197,57 @@ export default apiRoute<typeof meta, typeof schema>(
self.isDiscoverable = discoverable; self.isDiscoverable = discoverable;
} }
const fieldEmojis: EmojiWithInstance[] = [];
if (fields_attributes) {
self.fields = [];
self.source.fields = [];
for (const field of fields_attributes) {
// Can be Markdown or plaintext, also has emojis
const parsedName = await contentToHtml({
"text/markdown": {
content: field.name,
},
});
const parsedValue = await contentToHtml({
"text/markdown": {
content: field.value,
},
});
// Parse emojis
const nameEmojis = await parseEmojis(parsedName);
const valueEmojis = await parseEmojis(parsedValue);
fieldEmojis.push(...nameEmojis, ...valueEmojis);
// Replace fields
self.fields.push({
key: {
"text/html": {
content: parsedName,
},
},
value: {
"text/html": {
content: parsedValue,
},
},
});
self.source.fields.push({
name: field.name,
value: field.value,
});
}
}
// Parse emojis // Parse emojis
const displaynameEmojis = await parseEmojis(sanitizedDisplayName); const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
const noteEmojis = await parseEmojis(sanitizedNote); const noteEmojis = await parseEmojis(sanitizedNote);
self.emojis = [...displaynameEmojis, ...noteEmojis]; self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis];
// Deduplicate emojis // Deduplicate emojis
self.emojis = self.emojis.filter( self.emojis = self.emojis.filter(
@ -204,6 +262,7 @@ export default apiRoute<typeof meta, typeof schema>(
note: self.note, note: self.note,
avatar: self.avatar, avatar: self.avatar,
header: self.header, header: self.header,
fields: self.fields,
isLocked: self.isLocked, isLocked: self.isLocked,
isBot: self.isBot, isBot: self.isBot,
isDiscoverable: self.isDiscoverable, isDiscoverable: self.isDiscoverable,

View file

@ -114,7 +114,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
urls: { urls: {
streaming_api: "", streaming_api: "",
}, },
version: `4.3.0+glitch (compatible; Mastodon ${version}})`, version: "4.3.0",
pleroma: { pleroma: {
metadata: { metadata: {
account_activation_required: false, account_activation_required: false,

View file

@ -52,7 +52,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
return jsonResponse({ return jsonResponse({
domain: new URL(config.http.base_url).hostname, domain: new URL(config.http.base_url).hostname,
title: config.instance.name, title: config.instance.name,
version: `4.3.0+glitch (compatible; Mastodon ${version}})`, version: "4.3.0",
source_url: "https://github.com/lysand-org/lysand", source_url: "https://github.com/lysand-org/lysand",
description: config.instance.description, description: config.instance.description,
usage: { usage: {