mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Add profile fields with emojis and Markdown to users
This commit is contained in:
parent
6373b8ae78
commit
cde106a5db
1
drizzle/0020_giant_the_stranger.sql
Normal file
1
drizzle/0020_giant_the_stranger.sql
Normal file
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE "Users" ADD COLUMN "fields" jsonb DEFAULT '[]' NOT NULL;
|
||||
1800
drizzle/meta/0020_snapshot.json
Normal file
1800
drizzle/meta/0020_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -141,6 +141,13 @@
|
|||
"when": 1713421706451,
|
||||
"tag": "0019_mushy_lorna_dane",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "5",
|
||||
"when": 1714017186457,
|
||||
"tag": "0020_giant_the_stranger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
uuid,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import type { Source as APISource } from "~types/mastodon/source";
|
||||
import type * as Lysand from "lysand-types";
|
||||
|
||||
export const Emojis = pgTable("Emojis", {
|
||||
id: uuid("id").default(sql`uuid_generate_v7()`).primaryKey().notNull(),
|
||||
|
|
@ -362,6 +363,12 @@ export const Users = pgTable(
|
|||
email: text("email"),
|
||||
note: text("note").default("").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<{
|
||||
dislikes: string;
|
||||
featured: string;
|
||||
|
|
|
|||
|
|
@ -206,6 +206,7 @@ export class User {
|
|||
inbox: data.inbox,
|
||||
outbox: data.outbox,
|
||||
},
|
||||
fields: data.fields ?? [],
|
||||
updatedAt: new Date(data.created_at).toISOString(),
|
||||
instanceId: instance.id,
|
||||
avatar: data.avatar
|
||||
|
|
@ -312,6 +313,7 @@ export class User {
|
|||
header: data.header ?? config.defaults.avatar,
|
||||
isAdmin: data.admin ?? false,
|
||||
publicKey: keys.public_key,
|
||||
fields: [],
|
||||
privateKey: keys.private_key,
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: {
|
||||
|
|
@ -373,8 +375,10 @@ export class User {
|
|||
following_count: user.followingCount,
|
||||
statuses_count: user.statusCount,
|
||||
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
|
||||
// TODO: Add fields
|
||||
fields: [],
|
||||
fields: user.fields.map((field) => ({
|
||||
name: htmlToText(getBestContentType(field.key).content),
|
||||
value: getBestContentType(field.value).content,
|
||||
})),
|
||||
bot: user.isBot,
|
||||
source: isOwnAccount ? user.source : undefined,
|
||||
// TODO: Add static avatar and header
|
||||
|
|
@ -450,24 +454,7 @@ export class User {
|
|||
avatar: urlToContentFormat(this.getAvatarUrl(config)) ?? undefined,
|
||||
header: urlToContentFormat(this.getHeaderUrl(config)) ?? undefined,
|
||||
display_name: user.displayName,
|
||||
fields: user.source.fields.map((field) => ({
|
||||
key: {
|
||||
"text/html": {
|
||||
content: field.name,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(field.name),
|
||||
},
|
||||
},
|
||||
value: {
|
||||
"text/html": {
|
||||
content: field.value,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(field.value),
|
||||
},
|
||||
},
|
||||
})),
|
||||
fields: user.fields,
|
||||
public_key: {
|
||||
actor: new URL(
|
||||
`/users/${user.id}`,
|
||||
|
|
|
|||
|
|
@ -190,6 +190,7 @@ export const createServer = (
|
|||
headers: {
|
||||
// Include for SSR
|
||||
"X-Forwarded-Host": `${config.http.bind}:${config.http.bind_port}`,
|
||||
"Accept-Encoding": "identity",
|
||||
},
|
||||
}).catch(async (e) => {
|
||||
await logger.logError(
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import type { MediaBackend } from "media-manager";
|
|||
import { LocalMediaBackend, S3MediaBackend } from "media-manager";
|
||||
import { z } from "zod";
|
||||
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 { db } from "~drizzle/db";
|
||||
import { EmojiToUser, Users } from "~drizzle/schema";
|
||||
|
|
@ -40,13 +40,26 @@ export const schema = z.object({
|
|||
locked: z.boolean().optional(),
|
||||
bot: z.boolean().optional(),
|
||||
discoverable: z.boolean().optional(),
|
||||
"source[privacy]": z
|
||||
source: z
|
||||
.object({
|
||||
privacy: z
|
||||
.enum(["public", "unlisted", "private", "direct"])
|
||||
.optional(),
|
||||
"source[sensitive]": z.boolean().optional(),
|
||||
"source[language]": z
|
||||
sensitive: z.boolean().optional(),
|
||||
language: z
|
||||
.enum(ISO6391.getAllCodes() as [string, ...string[]])
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
fields_attributes: z
|
||||
.array(
|
||||
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(),
|
||||
});
|
||||
|
||||
export default apiRoute<typeof meta, typeof schema>(
|
||||
|
|
@ -66,9 +79,8 @@ export default apiRoute<typeof meta, typeof schema>(
|
|||
locked,
|
||||
bot,
|
||||
discoverable,
|
||||
"source[privacy]": source_privacy,
|
||||
"source[sensitive]": source_sensitive,
|
||||
"source[language]": source_language,
|
||||
source,
|
||||
fields_attributes,
|
||||
} = extraData.parsedRequest;
|
||||
|
||||
const sanitizedNote = await sanitizeHtml(note ?? "");
|
||||
|
|
@ -133,16 +145,16 @@ export default apiRoute<typeof meta, typeof schema>(
|
|||
});
|
||||
}
|
||||
|
||||
if (source_privacy && self.source) {
|
||||
self.source.privacy = source_privacy;
|
||||
if (source?.privacy) {
|
||||
self.source.privacy = source.privacy;
|
||||
}
|
||||
|
||||
if (source_sensitive && self.source) {
|
||||
self.source.sensitive = source_sensitive;
|
||||
if (source?.sensitive) {
|
||||
self.source.sensitive = source.sensitive;
|
||||
}
|
||||
|
||||
if (source_language && self.source) {
|
||||
self.source.language = source_language;
|
||||
if (source?.language) {
|
||||
self.source.language = source.language;
|
||||
}
|
||||
|
||||
if (avatar) {
|
||||
|
|
@ -185,11 +197,57 @@ export default apiRoute<typeof meta, typeof schema>(
|
|||
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
|
||||
const displaynameEmojis = await parseEmojis(sanitizedDisplayName);
|
||||
const noteEmojis = await parseEmojis(sanitizedNote);
|
||||
|
||||
self.emojis = [...displaynameEmojis, ...noteEmojis];
|
||||
self.emojis = [...displaynameEmojis, ...noteEmojis, ...fieldEmojis];
|
||||
|
||||
// Deduplicate emojis
|
||||
self.emojis = self.emojis.filter(
|
||||
|
|
@ -204,6 +262,7 @@ export default apiRoute<typeof meta, typeof schema>(
|
|||
note: self.note,
|
||||
avatar: self.avatar,
|
||||
header: self.header,
|
||||
fields: self.fields,
|
||||
isLocked: self.isLocked,
|
||||
isBot: self.isBot,
|
||||
isDiscoverable: self.isDiscoverable,
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
|||
urls: {
|
||||
streaming_api: "",
|
||||
},
|
||||
version: `4.3.0+glitch (compatible; Mastodon ${version}})`,
|
||||
version: "4.3.0",
|
||||
pleroma: {
|
||||
metadata: {
|
||||
account_activation_required: false,
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
|
|||
return jsonResponse({
|
||||
domain: new URL(config.http.base_url).hostname,
|
||||
title: config.instance.name,
|
||||
version: `4.3.0+glitch (compatible; Mastodon ${version}})`,
|
||||
version: "4.3.0",
|
||||
source_url: "https://github.com/lysand-org/lysand",
|
||||
description: config.instance.description,
|
||||
usage: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue