mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
refactor(api): 🎨 Move User methods into their own class similar to Note
This commit is contained in:
parent
abc8f1ae16
commit
9d70778abd
81 changed files with 2055 additions and 1603 deletions
|
|
@ -5,6 +5,7 @@ import {
|
|||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
isNotNull,
|
||||
} from "drizzle-orm";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import type * as Lysand from "lysand-types";
|
||||
|
|
@ -30,17 +31,7 @@ import {
|
|||
contentToHtml,
|
||||
findFirstNote,
|
||||
findManyNotes,
|
||||
getStatusUri,
|
||||
} from "~database/entities/Status";
|
||||
import {
|
||||
type User,
|
||||
type UserWithRelations,
|
||||
type UserWithRelationsAndRelationships,
|
||||
findManyUsers,
|
||||
getUserUri,
|
||||
userToAPI,
|
||||
userToMention,
|
||||
} from "~database/entities/User";
|
||||
import { db } from "~drizzle/db";
|
||||
import {
|
||||
Attachments,
|
||||
|
|
@ -48,13 +39,12 @@ import {
|
|||
NoteToMentions,
|
||||
Notes,
|
||||
Notifications,
|
||||
UserToPinnedNotes,
|
||||
Users,
|
||||
UsersRelations,
|
||||
} from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import type { Attachment as APIAttachment } from "~types/mastodon/attachment";
|
||||
import type { Status as APIStatus } from "~types/mastodon/status";
|
||||
import { User } from "./user";
|
||||
|
||||
/**
|
||||
* Gives helpers to fetch notes from database in a nice format
|
||||
|
|
@ -101,36 +91,44 @@ export class Note {
|
|||
return found.map((s) => new Note(s));
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.status.id;
|
||||
}
|
||||
|
||||
async getUsersToFederateTo() {
|
||||
// Mentioned users
|
||||
const mentionedUsers =
|
||||
this.getStatus().mentions.length > 0
|
||||
? await findManyUsers({
|
||||
where: (user, { and, isNotNull, inArray }) =>
|
||||
and(
|
||||
isNotNull(user.instanceId),
|
||||
inArray(
|
||||
user.id,
|
||||
this.getStatus().mentions.map(
|
||||
(mention) => mention.id,
|
||||
),
|
||||
? await User.manyFromSql(
|
||||
and(
|
||||
isNotNull(Users.instanceId),
|
||||
inArray(
|
||||
Users.id,
|
||||
this.getStatus().mentions.map(
|
||||
(mention) => mention.id,
|
||||
),
|
||||
),
|
||||
})
|
||||
),
|
||||
)
|
||||
: [];
|
||||
|
||||
const usersThatCanSeePost = await findManyUsers({
|
||||
where: (user, { isNotNull }) => isNotNull(user.instanceId),
|
||||
with: {
|
||||
relationships: {
|
||||
where: (relationship, { eq, and }) =>
|
||||
and(
|
||||
eq(relationship.subjectId, Users.id),
|
||||
eq(relationship.following, true),
|
||||
),
|
||||
const usersThatCanSeePost = await User.manyFromSql(
|
||||
isNotNull(Users.instanceId),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
with: {
|
||||
relationships: {
|
||||
where: (relationship, { eq, and }) =>
|
||||
and(
|
||||
eq(relationship.subjectId, Users.id),
|
||||
eq(relationship.following, true),
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
);
|
||||
|
||||
const fusedUsers = [...mentionedUsers, ...usersThatCanSeePost];
|
||||
|
||||
|
|
@ -159,39 +157,13 @@ export class Note {
|
|||
}
|
||||
|
||||
getAuthor() {
|
||||
return this.status.author;
|
||||
return new User(this.status.author);
|
||||
}
|
||||
|
||||
async getReplyChildren() {
|
||||
return await Note.manyFromSql(eq(Notes.replyId, this.status.id));
|
||||
}
|
||||
|
||||
async pin(pinner: User) {
|
||||
return (
|
||||
await db
|
||||
.insert(UserToPinnedNotes)
|
||||
.values({
|
||||
noteId: this.status.id,
|
||||
userId: pinner.id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
|
||||
async unpin(unpinner: User) {
|
||||
return (
|
||||
await db
|
||||
.delete(UserToPinnedNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(NoteToMentions.noteId, this.status.id),
|
||||
eq(NoteToMentions.userId, unpinner.id),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
|
||||
static async insert(values: InferInsertModel<typeof Notes>) {
|
||||
return (await db.insert(Notes).values(values).returning())[0];
|
||||
}
|
||||
|
|
@ -204,7 +176,7 @@ export class Note {
|
|||
spoiler_text: string,
|
||||
emojis: EmojiWithInstance[],
|
||||
uri?: string,
|
||||
mentions?: UserWithRelations[],
|
||||
mentions?: User[],
|
||||
/** List of IDs of database Attachment objects */
|
||||
media_attachments?: string[],
|
||||
replyId?: string,
|
||||
|
|
@ -216,7 +188,7 @@ export class Note {
|
|||
// Parse emojis and fuse with existing emojis
|
||||
let foundEmojis = emojis;
|
||||
|
||||
if (author.instanceId === null) {
|
||||
if (author.isLocal()) {
|
||||
const parsedEmojis = await parseEmojis(htmlContent);
|
||||
// Fuse and deduplicate
|
||||
foundEmojis = [...emojis, ...parsedEmojis].filter(
|
||||
|
|
@ -277,7 +249,7 @@ export class Note {
|
|||
|
||||
// Send notifications for mentioned local users
|
||||
for (const mention of mentions ?? []) {
|
||||
if (mention.instanceId === null) {
|
||||
if (mention.isLocal()) {
|
||||
await db.insert(Notifications).values({
|
||||
accountId: author.id,
|
||||
notifiedId: mention.id,
|
||||
|
|
@ -296,7 +268,7 @@ export class Note {
|
|||
is_sensitive?: boolean,
|
||||
spoiler_text?: string,
|
||||
emojis: EmojiWithInstance[] = [],
|
||||
mentions: UserWithRelations[] = [],
|
||||
mentions: User[] = [],
|
||||
/** List of IDs of database Attachment objects */
|
||||
media_attachments: string[] = [],
|
||||
) {
|
||||
|
|
@ -307,7 +279,7 @@ export class Note {
|
|||
// Parse emojis and fuse with existing emojis
|
||||
let foundEmojis = emojis;
|
||||
|
||||
if (this.getAuthor().instanceId === null && htmlContent) {
|
||||
if (this.getAuthor().isLocal() && htmlContent) {
|
||||
const parsedEmojis = await parseEmojis(htmlContent);
|
||||
// Fuse and deduplicate
|
||||
foundEmojis = [...emojis, ...parsedEmojis].filter(
|
||||
|
|
@ -401,7 +373,7 @@ export class Note {
|
|||
* @param user The user to check.
|
||||
* @returns Whether this status is viewable by the user.
|
||||
*/
|
||||
async isViewableByUser(user: UserWithRelations | null) {
|
||||
async isViewableByUser(user: User | null) {
|
||||
if (this.getAuthor().id === user?.id) return true;
|
||||
if (this.getStatus().visibility === "public") return true;
|
||||
if (this.getStatus().visibility === "unlisted") return true;
|
||||
|
|
@ -423,7 +395,7 @@ export class Note {
|
|||
);
|
||||
}
|
||||
|
||||
async toAPI(userFetching?: UserWithRelations | null): Promise<APIStatus> {
|
||||
async toAPI(userFetching?: User | null): Promise<APIStatus> {
|
||||
const data = this.getStatus();
|
||||
const wasPinnedByUser = userFetching
|
||||
? !!(await db.query.UserToPinnedNotes.findFirst({
|
||||
|
|
@ -480,7 +452,7 @@ export class Note {
|
|||
id: data.id,
|
||||
in_reply_to_id: data.replyId || null,
|
||||
in_reply_to_account_id: data.reply?.authorId || null,
|
||||
account: userToAPI(data.author),
|
||||
account: this.getAuthor().toAPI(userFetching?.id === data.authorId),
|
||||
created_at: new Date(data.createdAt).toISOString(),
|
||||
application: data.application
|
||||
? applicationToAPI(data.application)
|
||||
|
|
@ -495,7 +467,16 @@ export class Note {
|
|||
media_attachments: (data.attachments ?? []).map(
|
||||
(a) => attachmentToAPI(a) as APIAttachment,
|
||||
),
|
||||
mentions: data.mentions.map((mention) => userToMention(mention)),
|
||||
mentions: data.mentions.map((mention) => ({
|
||||
id: mention.id,
|
||||
acct: User.getAcct(
|
||||
mention.instanceId === null,
|
||||
mention.username,
|
||||
mention.instance?.baseUrl,
|
||||
),
|
||||
url: User.getUri(mention.id, mention.uri, config.http.base_url),
|
||||
username: mention.username,
|
||||
})),
|
||||
language: null,
|
||||
muted: wasMutedByUser,
|
||||
pinned: wasPinnedByUser,
|
||||
|
|
@ -531,8 +512,13 @@ export class Note {
|
|||
return localObjectURI(this.getStatus().id);
|
||||
}
|
||||
|
||||
static getURI(id?: string | null) {
|
||||
if (!id) return null;
|
||||
return localObjectURI(id);
|
||||
}
|
||||
|
||||
getMastoURI() {
|
||||
return `/@${this.getAuthor().username}/${this.getStatus().id}`;
|
||||
return `/@${this.getAuthor().getUser().username}/${this.id}`;
|
||||
}
|
||||
|
||||
toLysand(): Lysand.Note {
|
||||
|
|
@ -541,7 +527,7 @@ export class Note {
|
|||
type: "Note",
|
||||
created_at: new Date(status.createdAt).toISOString(),
|
||||
id: status.id,
|
||||
author: getUserUri(status.author),
|
||||
author: this.getAuthor().getUri(),
|
||||
uri: this.getURI(),
|
||||
content: {
|
||||
"text/html": {
|
||||
|
|
@ -556,8 +542,8 @@ export class Note {
|
|||
),
|
||||
is_sensitive: status.sensitive,
|
||||
mentions: status.mentions.map((mention) => mention.uri || ""),
|
||||
quotes: getStatusUri(status.quote) ?? undefined,
|
||||
replies_to: getStatusUri(status.reply) ?? undefined,
|
||||
quotes: Note.getURI(status.quotingId) ?? undefined,
|
||||
replies_to: Note.getURI(status.replyId) ?? undefined,
|
||||
subject: status.spoilerText,
|
||||
visibility: status.visibility as Lysand.Visibility,
|
||||
extensions: {
|
||||
|
|
@ -572,7 +558,7 @@ export class Note {
|
|||
/**
|
||||
* Return all the ancestors of this post,
|
||||
*/
|
||||
async getAncestors(fetcher: UserWithRelationsAndRelationships | null) {
|
||||
async getAncestors(fetcher: User | null) {
|
||||
const ancestors: Note[] = [];
|
||||
|
||||
let currentStatus: Note = this;
|
||||
|
|
@ -599,10 +585,7 @@ export class Note {
|
|||
* Return all the descendants of this post (recursive)
|
||||
* Temporary implementation, will be replaced with a recursive SQL query when I get to it
|
||||
*/
|
||||
async getDescendants(
|
||||
fetcher: UserWithRelationsAndRelationships | null,
|
||||
depth = 0,
|
||||
) {
|
||||
async getDescendants(fetcher: User | null, depth = 0) {
|
||||
const descendants: Note[] = [];
|
||||
for (const child of await this.getReplyChildren()) {
|
||||
descendants.push(child);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { type SQL, gt } from "drizzle-orm";
|
||||
import { Notes } from "~drizzle/schema";
|
||||
import { Notes, Users } from "~drizzle/schema";
|
||||
import { config } from "~packages/config-manager";
|
||||
import { Note } from "./note";
|
||||
import { User } from "./user";
|
||||
|
||||
enum TimelineType {
|
||||
NOTE = "Note",
|
||||
USER = "User",
|
||||
}
|
||||
|
||||
export class Timeline {
|
||||
|
|
@ -15,7 +17,23 @@ export class Timeline {
|
|||
limit: number,
|
||||
url: string,
|
||||
) {
|
||||
return new Timeline(TimelineType.NOTE).fetchTimeline(sql, limit, url);
|
||||
return new Timeline(TimelineType.NOTE).fetchTimeline<Note>(
|
||||
sql,
|
||||
limit,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
static async getUserTimeline(
|
||||
sql: SQL<unknown> | undefined,
|
||||
limit: number,
|
||||
url: string,
|
||||
) {
|
||||
return new Timeline(TimelineType.USER).fetchTimeline<User>(
|
||||
sql,
|
||||
limit,
|
||||
url,
|
||||
);
|
||||
}
|
||||
|
||||
private async fetchTimeline<T>(
|
||||
|
|
@ -23,13 +41,15 @@ export class Timeline {
|
|||
limit: number,
|
||||
url: string,
|
||||
) {
|
||||
const objects: Note[] = [];
|
||||
const notes: Note[] = [];
|
||||
const users: User[] = [];
|
||||
|
||||
switch (this.type) {
|
||||
case TimelineType.NOTE:
|
||||
objects.push(
|
||||
...(await Note.manyFromSql(sql, undefined, limit)),
|
||||
);
|
||||
notes.push(...(await Note.manyFromSql(sql, undefined, limit)));
|
||||
break;
|
||||
case TimelineType.USER:
|
||||
users.push(...(await User.manyFromSql(sql, undefined, limit)));
|
||||
break;
|
||||
}
|
||||
|
||||
|
|
@ -39,26 +59,26 @@ export class Timeline {
|
|||
config.http.base_url,
|
||||
).toString();
|
||||
|
||||
if (objects.length > 0) {
|
||||
if (notes.length > 0) {
|
||||
switch (this.type) {
|
||||
case TimelineType.NOTE: {
|
||||
const objectBefore = await Note.fromSql(
|
||||
gt(Notes.id, objects[0].getStatus().id),
|
||||
gt(Notes.id, notes[0].getStatus().id),
|
||||
);
|
||||
|
||||
if (objectBefore) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
|
||||
objects[0].getStatus().id
|
||||
notes[0].getStatus().id
|
||||
}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (objects.length >= (limit ?? 20)) {
|
||||
if (notes.length >= (limit ?? 20)) {
|
||||
const objectAfter = await Note.fromSql(
|
||||
gt(
|
||||
Notes.id,
|
||||
objects[objects.length - 1].getStatus().id,
|
||||
notes[notes.length - 1].getStatus().id,
|
||||
),
|
||||
);
|
||||
|
||||
|
|
@ -67,7 +87,37 @@ export class Timeline {
|
|||
`<${urlWithoutQuery}?limit=${
|
||||
limit ?? 20
|
||||
}&max_id=${
|
||||
objects[objects.length - 1].getStatus().id
|
||||
notes[notes.length - 1].getStatus().id
|
||||
}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case TimelineType.USER: {
|
||||
const objectBefore = await User.fromSql(
|
||||
gt(Users.id, users[0].id),
|
||||
);
|
||||
|
||||
if (objectBefore) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${limit ?? 20}&min_id=${
|
||||
users[0].id
|
||||
}>; rel="prev"`,
|
||||
);
|
||||
}
|
||||
|
||||
if (users.length >= (limit ?? 20)) {
|
||||
const objectAfter = await User.fromSql(
|
||||
gt(Users.id, users[users.length - 1].id),
|
||||
);
|
||||
|
||||
if (objectAfter) {
|
||||
linkHeader.push(
|
||||
`<${urlWithoutQuery}?limit=${
|
||||
limit ?? 20
|
||||
}&max_id=${
|
||||
users[users.length - 1].id
|
||||
}>; rel="next"`,
|
||||
);
|
||||
}
|
||||
|
|
@ -77,9 +127,17 @@ export class Timeline {
|
|||
}
|
||||
}
|
||||
|
||||
return {
|
||||
link: linkHeader.join(", "),
|
||||
objects,
|
||||
};
|
||||
switch (this.type) {
|
||||
case TimelineType.NOTE:
|
||||
return {
|
||||
link: linkHeader.join(", "),
|
||||
objects: notes as T[],
|
||||
};
|
||||
case TimelineType.USER:
|
||||
return {
|
||||
link: linkHeader.join(", "),
|
||||
objects: users as T[],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
421
packages/database-interface/user.ts
Normal file
421
packages/database-interface/user.ts
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
import { idValidator } from "@api";
|
||||
import { getBestContentType, urlToContentFormat } from "@content_types";
|
||||
import { addUserToMeilisearch } from "@meilisearch";
|
||||
import { type SQL, and, desc, eq, inArray } from "drizzle-orm";
|
||||
import { htmlToText } from "html-to-text";
|
||||
import type * as Lysand from "lysand-types";
|
||||
import {
|
||||
emojiToAPI,
|
||||
emojiToLysand,
|
||||
fetchEmoji,
|
||||
} from "~database/entities/Emoji";
|
||||
import { addInstanceIfNotExists } from "~database/entities/Instance";
|
||||
import {
|
||||
type UserWithRelations,
|
||||
findFirstUser,
|
||||
findManyUsers,
|
||||
} from "~database/entities/User";
|
||||
import { db } from "~drizzle/db";
|
||||
import {
|
||||
EmojiToUser,
|
||||
NoteToMentions,
|
||||
UserToPinnedNotes,
|
||||
Users,
|
||||
} from "~drizzle/schema";
|
||||
import { type Config, config } from "~packages/config-manager";
|
||||
import type { Account as APIAccount } from "~types/mastodon/account";
|
||||
import type { Mention as APIMention } from "~types/mastodon/mention";
|
||||
import type { Note } from "./note";
|
||||
|
||||
/**
|
||||
* Gives helpers to fetch users from database in a nice format
|
||||
*/
|
||||
export class User {
|
||||
constructor(private user: UserWithRelations) {}
|
||||
|
||||
static async fromId(id: string | null): Promise<User | null> {
|
||||
if (!id) return null;
|
||||
|
||||
return await User.fromSql(eq(Users.id, id));
|
||||
}
|
||||
|
||||
static async fromIds(ids: string[]): Promise<User[]> {
|
||||
return await User.manyFromSql(inArray(Users.id, ids));
|
||||
}
|
||||
|
||||
static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Users.id),
|
||||
) {
|
||||
const found = await findFirstUser({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) return null;
|
||||
return new User(found);
|
||||
}
|
||||
|
||||
static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(Users.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.Users.findMany>[0],
|
||||
) {
|
||||
const found = await findManyUsers({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new User(s));
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.user.id;
|
||||
}
|
||||
|
||||
getUser() {
|
||||
return this.user;
|
||||
}
|
||||
|
||||
isLocal() {
|
||||
return this.user.instanceId === null;
|
||||
}
|
||||
|
||||
isRemote() {
|
||||
return !this.isLocal();
|
||||
}
|
||||
|
||||
getUri() {
|
||||
return (
|
||||
this.user.uri ||
|
||||
new URL(`/users/${this.user.id}`, config.http.base_url).toString()
|
||||
);
|
||||
}
|
||||
|
||||
static getUri(id: string, uri: string | null, baseUrl: string) {
|
||||
return uri || new URL(`/users/${id}`, baseUrl).toString();
|
||||
}
|
||||
|
||||
async pin(note: Note) {
|
||||
return (
|
||||
await db
|
||||
.insert(UserToPinnedNotes)
|
||||
.values({
|
||||
noteId: note.id,
|
||||
userId: this.id,
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
|
||||
async unpin(note: Note) {
|
||||
return (
|
||||
await db
|
||||
.delete(UserToPinnedNotes)
|
||||
.where(
|
||||
and(
|
||||
eq(NoteToMentions.noteId, note.id),
|
||||
eq(NoteToMentions.userId, this.id),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
)[0];
|
||||
}
|
||||
|
||||
static async resolve(uri: string): Promise<User | null> {
|
||||
// Check if user not already in database
|
||||
const foundUser = await User.fromSql(eq(Users.uri, uri));
|
||||
|
||||
if (foundUser) return foundUser;
|
||||
|
||||
// Check if URI is of a local user
|
||||
if (uri.startsWith(config.http.base_url)) {
|
||||
const uuid = uri.match(idValidator);
|
||||
|
||||
if (!uuid || !uuid[0]) {
|
||||
throw new Error(
|
||||
`URI ${uri} is of a local user, but it could not be parsed`,
|
||||
);
|
||||
}
|
||||
|
||||
return await User.fromId(uuid[0]);
|
||||
}
|
||||
|
||||
if (!URL.canParse(uri)) {
|
||||
throw new Error(`Invalid URI to parse ${uri}`);
|
||||
}
|
||||
|
||||
const response = await fetch(uri, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
const data = (await response.json()) as Partial<Lysand.User>;
|
||||
|
||||
if (
|
||||
!(
|
||||
data.id &&
|
||||
data.username &&
|
||||
data.uri &&
|
||||
data.created_at &&
|
||||
data.dislikes &&
|
||||
data.featured &&
|
||||
data.likes &&
|
||||
data.followers &&
|
||||
data.following &&
|
||||
data.inbox &&
|
||||
data.outbox &&
|
||||
data.public_key
|
||||
)
|
||||
) {
|
||||
throw new Error("Invalid user data");
|
||||
}
|
||||
|
||||
// Parse emojis and add them to database
|
||||
const userEmojis =
|
||||
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
|
||||
|
||||
const instance = await addInstanceIfNotExists(data.uri);
|
||||
|
||||
const emojis = [];
|
||||
|
||||
for (const emoji of userEmojis) {
|
||||
emojis.push(await fetchEmoji(emoji));
|
||||
}
|
||||
|
||||
const newUser = (
|
||||
await db
|
||||
.insert(Users)
|
||||
.values({
|
||||
username: data.username,
|
||||
uri: data.uri,
|
||||
createdAt: new Date(data.created_at).toISOString(),
|
||||
endpoints: {
|
||||
dislikes: data.dislikes,
|
||||
featured: data.featured,
|
||||
likes: data.likes,
|
||||
followers: data.followers,
|
||||
following: data.following,
|
||||
inbox: data.inbox,
|
||||
outbox: data.outbox,
|
||||
},
|
||||
updatedAt: new Date(data.created_at).toISOString(),
|
||||
instanceId: instance.id,
|
||||
avatar: data.avatar
|
||||
? Object.entries(data.avatar)[0][1].content
|
||||
: "",
|
||||
header: data.header
|
||||
? Object.entries(data.header)[0][1].content
|
||||
: "",
|
||||
displayName: data.display_name ?? "",
|
||||
note: getBestContentType(data.bio).content,
|
||||
publicKey: data.public_key.public_key,
|
||||
source: {
|
||||
language: null,
|
||||
note: "",
|
||||
privacy: "public",
|
||||
sensitive: false,
|
||||
fields: [],
|
||||
},
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
|
||||
// Add emojis to user
|
||||
if (emojis.length > 0) {
|
||||
await db.insert(EmojiToUser).values(
|
||||
emojis.map((emoji) => ({
|
||||
emojiId: emoji.id,
|
||||
userId: newUser.id,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
const finalUser = await User.fromId(newUser.id);
|
||||
|
||||
if (!finalUser) return null;
|
||||
|
||||
// Add to Meilisearch
|
||||
await addUserToMeilisearch(finalUser);
|
||||
|
||||
return finalUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's avatar in raw URL format
|
||||
* @param config The config to use
|
||||
* @returns The raw URL for the user's avatar
|
||||
*/
|
||||
getAvatarUrl(config: Config) {
|
||||
if (!this.user.avatar)
|
||||
return (
|
||||
config.defaults.avatar ||
|
||||
`https://api.dicebear.com/8.x/${config.defaults.placeholder_style}/svg?seed=${this.user.username}`
|
||||
);
|
||||
return this.user.avatar;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's header in raw URL format
|
||||
* @param config The config to use
|
||||
* @returns The raw URL for the user's header
|
||||
*/
|
||||
getHeaderUrl(config: Config) {
|
||||
if (!this.user.header) return config.defaults.header;
|
||||
return this.user.header;
|
||||
}
|
||||
|
||||
getAcct() {
|
||||
return this.isLocal()
|
||||
? this.user.username
|
||||
: `${this.user.username}@${this.user.instance?.baseUrl}`;
|
||||
}
|
||||
|
||||
static getAcct(isLocal: boolean, username: string, baseUrl?: string) {
|
||||
return isLocal ? username : `${username}@${baseUrl}`;
|
||||
}
|
||||
|
||||
toAPI(isOwnAccount = false): APIAccount {
|
||||
const user = this.getUser();
|
||||
return {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
display_name: user.displayName,
|
||||
note: user.note,
|
||||
url:
|
||||
user.uri ||
|
||||
new URL(`/@${user.username}`, config.http.base_url).toString(),
|
||||
avatar: this.getAvatarUrl(config),
|
||||
header: this.getHeaderUrl(config),
|
||||
locked: user.isLocked,
|
||||
created_at: new Date(user.createdAt).toISOString(),
|
||||
followers_count: user.followerCount,
|
||||
following_count: user.followingCount,
|
||||
statuses_count: user.statusCount,
|
||||
emojis: user.emojis.map((emoji) => emojiToAPI(emoji)),
|
||||
// TODO: Add fields
|
||||
fields: [],
|
||||
bot: user.isBot,
|
||||
source: isOwnAccount ? user.source : undefined,
|
||||
// TODO: Add static avatar and header
|
||||
avatar_static: this.getAvatarUrl(config),
|
||||
header_static: this.getHeaderUrl(config),
|
||||
acct: this.getAcct(),
|
||||
// TODO: Add these fields
|
||||
limited: false,
|
||||
moved: null,
|
||||
noindex: false,
|
||||
suspended: false,
|
||||
discoverable: undefined,
|
||||
mute_expires_at: undefined,
|
||||
group: false,
|
||||
// @ts-expect-error Pleroma extension
|
||||
pleroma: {
|
||||
is_admin: user.isAdmin,
|
||||
is_moderator: user.isAdmin,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
toLysand(): Lysand.User {
|
||||
if (this.isRemote()) {
|
||||
throw new Error("Cannot convert remote user to Lysand format");
|
||||
}
|
||||
|
||||
const user = this.getUser();
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
type: "User",
|
||||
uri: this.getUri(),
|
||||
bio: {
|
||||
"text/html": {
|
||||
content: user.note,
|
||||
},
|
||||
"text/plain": {
|
||||
content: htmlToText(user.note),
|
||||
},
|
||||
},
|
||||
created_at: new Date(user.createdAt).toISOString(),
|
||||
dislikes: new URL(
|
||||
`/users/${user.id}/dislikes`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
featured: new URL(
|
||||
`/users/${user.id}/featured`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
likes: new URL(
|
||||
`/users/${user.id}/likes`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
followers: new URL(
|
||||
`/users/${user.id}/followers`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
following: new URL(
|
||||
`/users/${user.id}/following`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
inbox: new URL(
|
||||
`/users/${user.id}/inbox`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
outbox: new URL(
|
||||
`/users/${user.id}/outbox`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
indexable: false,
|
||||
username: user.username,
|
||||
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),
|
||||
},
|
||||
},
|
||||
})),
|
||||
public_key: {
|
||||
actor: new URL(
|
||||
`/users/${user.id}`,
|
||||
config.http.base_url,
|
||||
).toString(),
|
||||
public_key: user.publicKey,
|
||||
},
|
||||
extensions: {
|
||||
"org.lysand:custom_emojis": {
|
||||
emojis: user.emojis.map((emoji) => emojiToLysand(emoji)),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
toMention(): APIMention {
|
||||
return {
|
||||
url: this.getUri(),
|
||||
username: this.getUser().username,
|
||||
acct: this.getAcct(),
|
||||
id: this.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,8 @@ import { join } from "node:path";
|
|||
import { redirect } from "@response";
|
||||
import type { BunFile } from "bun";
|
||||
import { config } from "config-manager";
|
||||
import {
|
||||
type UserWithRelations,
|
||||
retrieveUserFromToken,
|
||||
userToAPI,
|
||||
} from "~database/entities/User";
|
||||
import { retrieveUserFromToken } from "~database/entities/User";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
import type { LogManager, MultiLogManager } from "~packages/log-manager";
|
||||
import { languages } from "./glitch-languages";
|
||||
|
||||
|
|
@ -104,7 +101,7 @@ const handleSignInRequest = async (
|
|||
req: Request,
|
||||
path: string,
|
||||
url: URL,
|
||||
user: UserWithRelations | null,
|
||||
user: User | null,
|
||||
accessToken: string,
|
||||
) => {
|
||||
if (req.method === "POST") {
|
||||
|
|
@ -181,7 +178,7 @@ const returnFile = async (file: BunFile, content?: string) => {
|
|||
const handleDefaultRequest = async (
|
||||
req: Request,
|
||||
path: string,
|
||||
user: UserWithRelations | null,
|
||||
user: User | null,
|
||||
accessToken: string,
|
||||
) => {
|
||||
const file = Bun.file(join(config.frontend.glitch.assets, path));
|
||||
|
|
@ -204,7 +201,7 @@ const handleDefaultRequest = async (
|
|||
const brandingTransforms = async (
|
||||
fileContents: string,
|
||||
accessToken: string,
|
||||
user: UserWithRelations | null,
|
||||
user: User | null,
|
||||
) => {
|
||||
let newFileContents = fileContents;
|
||||
for (const server of config.frontend.glitch.server) {
|
||||
|
|
@ -239,7 +236,7 @@ const brandingTransforms = async (
|
|||
const htmlTransforms = async (
|
||||
fileContents: string,
|
||||
accessToken: string,
|
||||
user: UserWithRelations | null,
|
||||
user: User | null,
|
||||
) => {
|
||||
// Find script id="initial-state" and replace its contents with custom json
|
||||
const rewriter = new HTMLRewriter()
|
||||
|
|
@ -290,7 +287,7 @@ const htmlTransforms = async (
|
|||
},
|
||||
accounts: user
|
||||
? {
|
||||
[user.id]: userToAPI(user, true),
|
||||
[user.id]: user.toAPI(true),
|
||||
}
|
||||
: {},
|
||||
media_attachments: {
|
||||
|
|
|
|||
262
packages/lysand-utils/index.ts
Normal file
262
packages/lysand-utils/index.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import type * as Lysand from "lysand-types";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { schemas } from "./schemas";
|
||||
|
||||
const types = [
|
||||
"Note",
|
||||
"User",
|
||||
"Reaction",
|
||||
"Poll",
|
||||
"Vote",
|
||||
"VoteResult",
|
||||
"Report",
|
||||
"ServerMetadata",
|
||||
"Like",
|
||||
"Dislike",
|
||||
"Follow",
|
||||
"FollowAccept",
|
||||
"FollowReject",
|
||||
"Announce",
|
||||
"Undo",
|
||||
];
|
||||
|
||||
/**
|
||||
* Validates an incoming Lysand object using Zod, and returns the object if it is valid.
|
||||
*/
|
||||
export class EntityValidator {
|
||||
constructor(private entity: Lysand.Entity) {}
|
||||
|
||||
/**
|
||||
* Validates the entity.
|
||||
*/
|
||||
validate<ExpectedType>() {
|
||||
// Check if type is valid
|
||||
if (!this.entity.type) {
|
||||
throw new Error("Entity type is required");
|
||||
}
|
||||
|
||||
const schema = this.matchSchema(this.getType());
|
||||
|
||||
const output = schema.safeParse(this.entity);
|
||||
|
||||
if (!output.success) {
|
||||
throw fromZodError(output.error);
|
||||
}
|
||||
|
||||
return output.data as ExpectedType;
|
||||
}
|
||||
|
||||
getType() {
|
||||
// Check if type is valid, return TypeScript type
|
||||
if (!this.entity.type) {
|
||||
throw new Error("Entity type is required");
|
||||
}
|
||||
|
||||
if (!types.includes(this.entity.type)) {
|
||||
throw new Error(`Unknown entity type: ${this.entity.type}`);
|
||||
}
|
||||
|
||||
return this.entity.type as (typeof types)[number];
|
||||
}
|
||||
|
||||
matchSchema(type: string) {
|
||||
switch (type) {
|
||||
case "Note":
|
||||
return schemas.Note;
|
||||
case "User":
|
||||
return schemas.User;
|
||||
case "Reaction":
|
||||
return schemas.Reaction;
|
||||
case "Poll":
|
||||
return schemas.Poll;
|
||||
case "Vote":
|
||||
return schemas.Vote;
|
||||
case "VoteResult":
|
||||
return schemas.VoteResult;
|
||||
case "Report":
|
||||
return schemas.Report;
|
||||
case "ServerMetadata":
|
||||
return schemas.ServerMetadata;
|
||||
case "Like":
|
||||
return schemas.Like;
|
||||
case "Dislike":
|
||||
return schemas.Dislike;
|
||||
case "Follow":
|
||||
return schemas.Follow;
|
||||
case "FollowAccept":
|
||||
return schemas.FollowAccept;
|
||||
case "FollowReject":
|
||||
return schemas.FollowReject;
|
||||
case "Announce":
|
||||
return schemas.Announce;
|
||||
case "Undo":
|
||||
return schemas.Undo;
|
||||
default:
|
||||
throw new Error(`Unknown entity type: ${type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class SignatureValidator {
|
||||
constructor(
|
||||
private public_key: CryptoKey,
|
||||
private signature: string,
|
||||
private date: string,
|
||||
private method: string,
|
||||
private url: URL,
|
||||
private body: string,
|
||||
) {}
|
||||
|
||||
static async fromStringKey(
|
||||
public_key: string,
|
||||
signature: string,
|
||||
date: string,
|
||||
method: string,
|
||||
url: URL,
|
||||
body: string,
|
||||
) {
|
||||
return new SignatureValidator(
|
||||
await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Buffer.from(public_key, "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["verify"],
|
||||
),
|
||||
signature,
|
||||
date,
|
||||
method,
|
||||
url,
|
||||
body,
|
||||
);
|
||||
}
|
||||
async validate() {
|
||||
const signature = this.signature
|
||||
.split("signature=")[1]
|
||||
.replace(/"/g, "");
|
||||
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(this.body),
|
||||
);
|
||||
|
||||
const expectedSignedString =
|
||||
`(request-target): ${this.method.toLowerCase()} ${
|
||||
this.url.pathname
|
||||
}\n` +
|
||||
`host: ${this.url.host}\n` +
|
||||
`date: ${this.date}\n` +
|
||||
`digest: SHA-256=${Buffer.from(new Uint8Array(digest)).toString(
|
||||
"base64",
|
||||
)}\n`;
|
||||
|
||||
// Check if signed string is valid
|
||||
const isValid = await crypto.subtle.verify(
|
||||
"Ed25519",
|
||||
this.public_key,
|
||||
Buffer.from(signature, "base64"),
|
||||
new TextEncoder().encode(expectedSignedString),
|
||||
);
|
||||
|
||||
return isValid;
|
||||
}
|
||||
}
|
||||
|
||||
export class SignatureConstructor {
|
||||
constructor(
|
||||
private private_key: CryptoKey,
|
||||
private url: URL,
|
||||
private authorUri: URL,
|
||||
) {}
|
||||
|
||||
static async fromStringKey(private_key: string, url: URL, authorUri: URL) {
|
||||
return new SignatureConstructor(
|
||||
await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(private_key, "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
),
|
||||
url,
|
||||
authorUri,
|
||||
);
|
||||
}
|
||||
|
||||
async sign(method: string, body: string) {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(body),
|
||||
);
|
||||
|
||||
const date = new Date();
|
||||
|
||||
const signature = await crypto.subtle.sign(
|
||||
"Ed25519",
|
||||
this.private_key,
|
||||
new TextEncoder().encode(
|
||||
`(request-target): ${method.toLowerCase()} ${
|
||||
this.url.pathname
|
||||
}\n` +
|
||||
`host: ${this.url.host}\n` +
|
||||
`date: ${date.toISOString()}\n` +
|
||||
`digest: SHA-256=${Buffer.from(
|
||||
new Uint8Array(digest),
|
||||
).toString("base64")}\n`,
|
||||
),
|
||||
);
|
||||
|
||||
const signatureBase64 = Buffer.from(new Uint8Array(signature)).toString(
|
||||
"base64",
|
||||
);
|
||||
|
||||
return {
|
||||
date: date.toISOString(),
|
||||
signature: `keyId="${this.authorUri.toString()}",algorithm="ed25519",headers="(request-target) host date digest",signature="${signatureBase64}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extends native fetch with object signing
|
||||
* Make sure to format your JSON in Canonical JSON format!
|
||||
* @param url URL to fetch
|
||||
* @param options Standard Web Fetch API options
|
||||
* @param privateKey Author private key in base64
|
||||
* @param authorUri Author URI
|
||||
* @param baseUrl Base URL of this server
|
||||
* @returns Fetch response
|
||||
*/
|
||||
export const signedFetch = async (
|
||||
url: string | URL,
|
||||
options: RequestInit,
|
||||
privateKey: string,
|
||||
authorUri: string | URL,
|
||||
baseUrl: string | URL,
|
||||
) => {
|
||||
const urlObj = new URL(url);
|
||||
const authorUriObj = new URL(authorUri);
|
||||
|
||||
const signature = await SignatureConstructor.fromStringKey(
|
||||
privateKey,
|
||||
urlObj,
|
||||
authorUriObj,
|
||||
);
|
||||
|
||||
const { date, signature: signatureHeader } = await signature.sign(
|
||||
options.method ?? "GET",
|
||||
options.body?.toString() || "",
|
||||
);
|
||||
|
||||
return fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Date: date,
|
||||
Origin: new URL(baseUrl).origin,
|
||||
Signature: signatureHeader,
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
Accept: "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
6
packages/lysand-utils/package.json
Normal file
6
packages/lysand-utils/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"name": "lysand-utils",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": { "zod": "^3.22.4", "zod-validation-error": "^3.1.0" }
|
||||
}
|
||||
261
packages/lysand-utils/schemas.ts
Normal file
261
packages/lysand-utils/schemas.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const Entity = z.object({
|
||||
id: z.string().uuid(),
|
||||
created_at: z.string(),
|
||||
uri: z.string().url(),
|
||||
type: z.string(),
|
||||
extensions: z.object({
|
||||
"org.lysand:custom_emojis": z.object({
|
||||
emojis: z.array(
|
||||
z.object({
|
||||
shortcode: z.string(),
|
||||
url: z.string(),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const ContentFormat = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
content: z.string(),
|
||||
description: z.string().optional(),
|
||||
size: z.number().optional(),
|
||||
hash: z.record(z.string().optional()).optional(),
|
||||
blurhash: z.string().optional(),
|
||||
fps: z.number().optional(),
|
||||
width: z.number().optional(),
|
||||
height: z.number().optional(),
|
||||
duration: z.number().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
const Visibility = z.enum(["public", "unlisted", "private", "direct"]);
|
||||
|
||||
const Publication = Entity.extend({
|
||||
type: z.union([z.literal("Note"), z.literal("Patch")]),
|
||||
author: z.string().url(),
|
||||
content: ContentFormat.optional(),
|
||||
attachments: z.array(ContentFormat).optional(),
|
||||
replies_to: z.string().url().optional(),
|
||||
quotes: z.string().url().optional(),
|
||||
mentions: z.array(z.string().url()).optional(),
|
||||
subject: z.string().optional(),
|
||||
is_sensitive: z.boolean().optional(),
|
||||
visibility: Visibility,
|
||||
extensions: Entity.shape.extensions.extend({
|
||||
"org.lysand:reactions": z
|
||||
.object({
|
||||
reactions: z.string(),
|
||||
})
|
||||
.optional(),
|
||||
"org.lysand:polls": z
|
||||
.object({
|
||||
poll: z.object({
|
||||
options: z.array(ContentFormat),
|
||||
votes: z.array(z.number()),
|
||||
multiple_choice: z.boolean().optional(),
|
||||
expires_at: z.string(),
|
||||
}),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const Note = Publication.extend({
|
||||
type: z.literal("Note"),
|
||||
});
|
||||
|
||||
const Patch = Publication.extend({
|
||||
type: z.literal("Patch"),
|
||||
patched_id: z.string().uuid(),
|
||||
patched_at: z.string(),
|
||||
});
|
||||
|
||||
const ActorPublicKeyData = z.object({
|
||||
public_key: z.string(),
|
||||
actor: z.string().url(),
|
||||
});
|
||||
|
||||
const VanityExtension = z.object({
|
||||
avatar_overlay: ContentFormat.optional(),
|
||||
avatar_mask: ContentFormat.optional(),
|
||||
background: ContentFormat.optional(),
|
||||
audio: ContentFormat.optional(),
|
||||
pronouns: z.record(
|
||||
z.string(),
|
||||
z.array(
|
||||
z.union([
|
||||
z.object({
|
||||
subject: z.string(),
|
||||
object: z.string(),
|
||||
dependent_possessive: z.string(),
|
||||
independent_possessive: z.string(),
|
||||
reflexive: z.string(),
|
||||
}),
|
||||
z.string(),
|
||||
]),
|
||||
),
|
||||
),
|
||||
birthday: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
activitypub: z.string().optional(),
|
||||
});
|
||||
|
||||
const User = Entity.extend({
|
||||
type: z.literal("User"),
|
||||
display_name: z.string().optional(),
|
||||
username: z.string(),
|
||||
avatar: ContentFormat.optional(),
|
||||
header: ContentFormat.optional(),
|
||||
indexable: z.boolean(),
|
||||
public_key: ActorPublicKeyData,
|
||||
bio: ContentFormat.optional(),
|
||||
fields: z
|
||||
.array(
|
||||
z.object({
|
||||
name: ContentFormat,
|
||||
value: ContentFormat,
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
featured: z.string().url(),
|
||||
followers: z.string().url(),
|
||||
following: z.string().url(),
|
||||
likes: z.string().url(),
|
||||
dislikes: z.string().url(),
|
||||
inbox: z.string().url(),
|
||||
outbox: z.string().url(),
|
||||
extensions: Entity.shape.extensions.extend({
|
||||
"org.lysand:vanity": VanityExtension.optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
const Action = Entity.extend({
|
||||
type: z.union([
|
||||
z.literal("Like"),
|
||||
z.literal("Dislike"),
|
||||
z.literal("Follow"),
|
||||
z.literal("FollowAccept"),
|
||||
z.literal("FollowReject"),
|
||||
z.literal("Announce"),
|
||||
z.literal("Undo"),
|
||||
]),
|
||||
author: z.string().url(),
|
||||
});
|
||||
|
||||
const Like = Action.extend({
|
||||
type: z.literal("Like"),
|
||||
object: z.string().url(),
|
||||
});
|
||||
|
||||
const Undo = Action.extend({
|
||||
type: z.literal("Undo"),
|
||||
object: z.string().url(),
|
||||
});
|
||||
|
||||
const Dislike = Action.extend({
|
||||
type: z.literal("Dislike"),
|
||||
object: z.string().url(),
|
||||
});
|
||||
|
||||
const Follow = Action.extend({
|
||||
type: z.literal("Follow"),
|
||||
followee: z.string().url(),
|
||||
});
|
||||
|
||||
const FollowAccept = Action.extend({
|
||||
type: z.literal("FollowAccept"),
|
||||
follower: z.string().url(),
|
||||
});
|
||||
|
||||
const FollowReject = Action.extend({
|
||||
type: z.literal("FollowReject"),
|
||||
follower: z.string().url(),
|
||||
});
|
||||
|
||||
const Announce = Action.extend({
|
||||
type: z.literal("Announce"),
|
||||
object: z.string().url(),
|
||||
});
|
||||
|
||||
const Extension = Entity.extend({
|
||||
type: z.literal("Extension"),
|
||||
extension_type: z.string(),
|
||||
});
|
||||
|
||||
const Reaction = Extension.extend({
|
||||
extension_type: z.literal("org.lysand:reactions/Reaction"),
|
||||
object: z.string().url(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
const Poll = Extension.extend({
|
||||
extension_type: z.literal("org.lysand:polls/Poll"),
|
||||
options: z.array(ContentFormat),
|
||||
votes: z.array(z.number()),
|
||||
multiple_choice: z.boolean().optional(),
|
||||
expires_at: z.string(),
|
||||
});
|
||||
|
||||
const Vote = Extension.extend({
|
||||
extension_type: z.literal("org.lysand:polls/Vote"),
|
||||
poll: z.string().url(),
|
||||
option: z.number(),
|
||||
});
|
||||
|
||||
const VoteResult = Extension.extend({
|
||||
extension_type: z.literal("org.lysand:polls/VoteResult"),
|
||||
poll: z.string().url(),
|
||||
votes: z.array(z.number()),
|
||||
});
|
||||
|
||||
const Report = Extension.extend({
|
||||
extension_type: z.literal("org.lysand:reports/Report"),
|
||||
objects: z.array(z.string().url()),
|
||||
reason: z.string(),
|
||||
comment: z.string().optional(),
|
||||
});
|
||||
|
||||
const ServerMetadata = Entity.extend({
|
||||
type: z.literal("ServerMetadata"),
|
||||
name: z.string(),
|
||||
version: z.string(),
|
||||
description: z.string().optional(),
|
||||
website: z.string().optional(),
|
||||
moderators: z.array(z.string()).optional(),
|
||||
admins: z.array(z.string()).optional(),
|
||||
logo: ContentFormat.optional(),
|
||||
banner: ContentFormat.optional(),
|
||||
supported_extensions: z.array(z.string()),
|
||||
extensions: z.record(z.string(), z.any()).optional(),
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
Entity,
|
||||
ContentFormat,
|
||||
Visibility,
|
||||
Publication,
|
||||
Note,
|
||||
Patch,
|
||||
ActorPublicKeyData,
|
||||
VanityExtension,
|
||||
User,
|
||||
Action,
|
||||
Like,
|
||||
Undo,
|
||||
Dislike,
|
||||
Follow,
|
||||
FollowAccept,
|
||||
FollowReject,
|
||||
Announce,
|
||||
Extension,
|
||||
Reaction,
|
||||
Poll,
|
||||
Vote,
|
||||
VoteResult,
|
||||
Report,
|
||||
ServerMetadata,
|
||||
};
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import type { APActor, APNote } from "activitypub-types";
|
||||
import { ActivityPubTranslator } from "./protocols/activitypub";
|
||||
|
||||
export enum SupportedProtocols {
|
||||
ACTIVITYPUB = "activitypub",
|
||||
}
|
||||
|
||||
/**
|
||||
* ProtocolTranslator
|
||||
* @summary Translates between federation protocols such as ActivityPub to Lysand and back
|
||||
* @description This class is responsible for translating between federation protocols such as ActivityPub to Lysand and back.
|
||||
* This class is not meant to be instantiated directly, but rather for its children to be used.
|
||||
*/
|
||||
export class ProtocolTranslator {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
static auto(object: any) {
|
||||
const protocol = ProtocolTranslator.recognizeProtocol(object);
|
||||
switch (protocol) {
|
||||
case SupportedProtocols.ACTIVITYPUB:
|
||||
return new ActivityPubTranslator();
|
||||
default:
|
||||
throw new Error("Unknown protocol");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an ActivityPub actor to a Lysand user
|
||||
* @param data Raw JSON-LD data from an ActivityPub actor
|
||||
*/
|
||||
user(data: APActor) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates an ActivityPub note to a Lysand status
|
||||
* @param data Raw JSON-LD data from an ActivityPub note
|
||||
*/
|
||||
status(data: APNote) {
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Automatically recognizes the protocol of a given object
|
||||
*/
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
|
||||
private static recognizeProtocol(object: any) {
|
||||
// Temporary stub
|
||||
return SupportedProtocols.ACTIVITYPUB;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"name": "protocol-translator",
|
||||
"version": "0.0.0",
|
||||
"main": "index.ts",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
"activitypub-types": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { ProtocolTranslator } from "..";
|
||||
|
||||
export class ActivityPubTranslator extends ProtocolTranslator {
|
||||
user() {}
|
||||
}
|
||||
|
|
@ -7,11 +7,8 @@ import { RequestParser } from "request-parser";
|
|||
import type { ZodType, z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import type { Application } from "~database/entities/Application";
|
||||
import {
|
||||
type AuthData,
|
||||
type UserWithRelations,
|
||||
getFromRequest,
|
||||
} from "~database/entities/User";
|
||||
import { type AuthData, getFromRequest } from "~database/entities/User";
|
||||
import type { User } from "~packages/database-interface/user";
|
||||
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
||||
|
|
@ -24,11 +21,11 @@ export type RouteHandler<
|
|||
matchedRoute: MatchedRoute,
|
||||
extraData: {
|
||||
auth: {
|
||||
// If the route doesn't require authentication, set the type to UserWithRelations | null
|
||||
// Otherwise set to UserWithRelations
|
||||
// If the route doesn't require authentication, set the type to User | null
|
||||
// Otherwise set to User
|
||||
user: RouteMeta["auth"]["required"] extends true
|
||||
? UserWithRelations
|
||||
: UserWithRelations | null;
|
||||
? User
|
||||
: User | null;
|
||||
token: RouteMeta["auth"]["required"] extends true
|
||||
? string
|
||||
: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue