2023-10-19 21:53:59 +02:00
|
|
|
import { ConfigType, getConfig } from "@config";
|
2023-09-12 22:48:10 +02:00
|
|
|
import { APIAccount } from "~types/entities/account";
|
2023-11-04 04:34:31 +01:00
|
|
|
import { User as LysandUser } from "~types/lysand/Object";
|
|
|
|
|
import { htmlToText } from "html-to-text";
|
2023-11-11 03:36:06 +01:00
|
|
|
import {
|
|
|
|
|
Emoji,
|
|
|
|
|
Instance,
|
|
|
|
|
Like,
|
|
|
|
|
Relationship,
|
|
|
|
|
Status,
|
|
|
|
|
User,
|
|
|
|
|
} from "@prisma/client";
|
|
|
|
|
import { client } from "~database/datasource";
|
|
|
|
|
import { addEmojiIfNotExists, emojiToAPI, emojiToLysand } from "./Emoji";
|
|
|
|
|
import { addInstanceIfNotExists } from "./Instance";
|
|
|
|
|
import { APISource } from "~types/entities/source";
|
2023-09-11 05:54:14 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
export interface AuthData {
|
|
|
|
|
user: UserWithRelations | null;
|
|
|
|
|
token: string;
|
|
|
|
|
}
|
2023-10-23 07:39:42 +02:00
|
|
|
|
2023-09-12 22:48:10 +02:00
|
|
|
/**
|
2023-09-28 20:19:21 +02:00
|
|
|
* Represents a user in the database.
|
2023-09-12 22:48:10 +02:00
|
|
|
* Stores local and remote users
|
|
|
|
|
*/
|
2023-11-04 04:34:31 +01:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
export const userRelations = {
|
|
|
|
|
emojis: true,
|
|
|
|
|
instance: true,
|
|
|
|
|
likes: true,
|
|
|
|
|
relationships: true,
|
|
|
|
|
relationshipSubjects: true,
|
|
|
|
|
pinnedNotes: true,
|
2023-11-12 02:37:14 +01:00
|
|
|
_count: {
|
2023-11-11 03:36:06 +01:00
|
|
|
select: {
|
2023-11-12 02:37:14 +01:00
|
|
|
statuses: true,
|
|
|
|
|
likes: true,
|
2023-11-11 03:36:06 +01:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type UserWithRelations = User & {
|
|
|
|
|
emojis: Emoji[];
|
|
|
|
|
instance: Instance | null;
|
|
|
|
|
likes: Like[];
|
|
|
|
|
relationships: Relationship[];
|
|
|
|
|
relationshipSubjects: Relationship[];
|
|
|
|
|
pinnedNotes: Status[];
|
2023-11-12 02:37:14 +01:00
|
|
|
_count: {
|
|
|
|
|
statuses: number;
|
|
|
|
|
likes: number;
|
2023-11-11 03:36:06 +01:00
|
|
|
};
|
|
|
|
|
};
|
2023-10-08 22:20:42 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Get the user's avatar in raw URL format
|
|
|
|
|
* @param config The config to use
|
|
|
|
|
* @returns The raw URL for the user's avatar
|
|
|
|
|
*/
|
|
|
|
|
export const getAvatarUrl = (user: User, config: ConfigType) => {
|
|
|
|
|
if (!user.avatar) return config.defaults.avatar;
|
|
|
|
|
if (config.media.backend === "local") {
|
|
|
|
|
return `${config.http.base_url}/media/${user.avatar}`;
|
|
|
|
|
} else if (config.media.backend === "s3") {
|
|
|
|
|
return `${config.s3.public_url}/${user.avatar}`;
|
2023-09-18 22:29:56 +02:00
|
|
|
}
|
2023-11-11 03:36:06 +01:00
|
|
|
return "";
|
|
|
|
|
};
|
2023-09-18 22:29:56 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Get the user's header in raw URL format
|
|
|
|
|
* @param config The config to use
|
|
|
|
|
* @returns The raw URL for the user's header
|
|
|
|
|
*/
|
|
|
|
|
export const getHeaderUrl = (user: User, config: ConfigType) => {
|
|
|
|
|
if (!user.header) return config.defaults.header;
|
|
|
|
|
if (config.media.backend === "local") {
|
|
|
|
|
return `${config.http.base_url}/media/${user.header}`;
|
|
|
|
|
} else if (config.media.backend === "s3") {
|
|
|
|
|
return `${config.s3.public_url}/${user.header}`;
|
2023-09-18 22:29:56 +02:00
|
|
|
}
|
2023-11-11 03:36:06 +01:00
|
|
|
return "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getFromRequest = async (req: Request): Promise<AuthData> => {
|
|
|
|
|
// Check auth token
|
|
|
|
|
const token = req.headers.get("Authorization")?.split(" ")[1] || "";
|
|
|
|
|
|
|
|
|
|
return { user: await retrieveUserFromToken(token), token };
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const fetchRemoteUser = async (uri: string) => {
|
|
|
|
|
// Check if user not already in database
|
|
|
|
|
const foundUser = await client.user.findUnique({
|
|
|
|
|
where: {
|
|
|
|
|
uri,
|
|
|
|
|
},
|
|
|
|
|
include: userRelations,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (foundUser) return foundUser;
|
|
|
|
|
|
|
|
|
|
const response = await fetch(uri, {
|
|
|
|
|
method: "GET",
|
|
|
|
|
headers: {
|
|
|
|
|
"Content-Type": "application/json",
|
|
|
|
|
Accept: "application/json",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const data = (await response.json()) as Partial<LysandUser>;
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
!(
|
|
|
|
|
data.id &&
|
|
|
|
|
data.username &&
|
|
|
|
|
data.uri &&
|
|
|
|
|
data.created_at &&
|
|
|
|
|
data.disliked &&
|
|
|
|
|
data.featured &&
|
|
|
|
|
data.liked &&
|
|
|
|
|
data.followers &&
|
|
|
|
|
data.following &&
|
|
|
|
|
data.inbox &&
|
|
|
|
|
data.outbox &&
|
|
|
|
|
data.public_key
|
|
|
|
|
)
|
|
|
|
|
) {
|
|
|
|
|
throw new Error("Invalid user data");
|
2023-11-05 00:59:55 +01:00
|
|
|
}
|
|
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
// Parse emojis and add them to database
|
|
|
|
|
const userEmojis =
|
|
|
|
|
data.extensions?.["org.lysand:custom_emojis"]?.emojis ?? [];
|
|
|
|
|
|
|
|
|
|
const user = await client.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
username: data.username,
|
|
|
|
|
uri: data.uri,
|
|
|
|
|
createdAt: new Date(data.created_at),
|
|
|
|
|
endpoints: {
|
|
|
|
|
disliked: data.disliked,
|
|
|
|
|
featured: data.featured,
|
|
|
|
|
liked: data.liked,
|
|
|
|
|
followers: data.followers,
|
|
|
|
|
following: data.following,
|
|
|
|
|
inbox: data.inbox,
|
|
|
|
|
outbox: data.outbox,
|
2023-09-27 00:33:43 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
avatar: (data.avatar && data.avatar[0].content) || "",
|
|
|
|
|
header: (data.header && data.header[0].content) || "",
|
|
|
|
|
displayName: data.display_name ?? "",
|
|
|
|
|
note: data.bio?.[0].content ?? "",
|
|
|
|
|
publicKey: data.public_key.public_key,
|
|
|
|
|
source: {
|
|
|
|
|
language: null,
|
|
|
|
|
note: "",
|
|
|
|
|
privacy: "public",
|
|
|
|
|
sensitive: false,
|
|
|
|
|
fields: [],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2023-09-27 00:33:43 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
const emojis = [];
|
2023-09-27 00:33:43 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
for (const emoji of userEmojis) {
|
|
|
|
|
emojis.push(await addEmojiIfNotExists(emoji));
|
2023-09-27 00:33:43 +02:00
|
|
|
}
|
|
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
const uriData = new URL(data.uri);
|
|
|
|
|
|
|
|
|
|
return await client.user.update({
|
|
|
|
|
where: {
|
|
|
|
|
id: user.id,
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
emojis: {
|
|
|
|
|
connect: emojis.map(emoji => ({
|
|
|
|
|
id: emoji.id,
|
|
|
|
|
})),
|
2023-09-22 05:18:05 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
instanceId: (await addInstanceIfNotExists(uriData.origin)).id,
|
|
|
|
|
},
|
|
|
|
|
include: userRelations,
|
|
|
|
|
});
|
|
|
|
|
};
|
2023-09-22 05:18:05 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Fetches the list of followers associated with the actor and updates the user's followers
|
|
|
|
|
*/
|
|
|
|
|
export const fetchFollowers = () => {
|
|
|
|
|
//
|
|
|
|
|
};
|
2023-09-22 05:18:05 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Creates a new LOCAL user.
|
|
|
|
|
* @param data The data for the new user.
|
|
|
|
|
* @returns The newly created user.
|
|
|
|
|
*/
|
|
|
|
|
export const createNewLocalUser = async (data: {
|
|
|
|
|
username: string;
|
|
|
|
|
display_name?: string;
|
|
|
|
|
password: string;
|
|
|
|
|
email: string;
|
|
|
|
|
bio?: string;
|
|
|
|
|
avatar?: string;
|
|
|
|
|
header?: string;
|
|
|
|
|
}) => {
|
|
|
|
|
const config = getConfig();
|
|
|
|
|
|
|
|
|
|
const keys = await generateUserKeys();
|
|
|
|
|
|
|
|
|
|
const user = await client.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
username: data.username,
|
|
|
|
|
displayName: data.display_name ?? data.username,
|
|
|
|
|
password: await Bun.password.hash(data.password),
|
|
|
|
|
email: data.email,
|
|
|
|
|
note: data.bio ?? "",
|
|
|
|
|
avatar: data.avatar ?? config.defaults.avatar,
|
|
|
|
|
header: data.header ?? config.defaults.avatar,
|
|
|
|
|
uri: "",
|
|
|
|
|
publicKey: keys.public_key,
|
|
|
|
|
privateKey: keys.private_key,
|
|
|
|
|
source: {
|
|
|
|
|
language: null,
|
|
|
|
|
note: "",
|
|
|
|
|
privacy: "public",
|
|
|
|
|
sensitive: false,
|
|
|
|
|
fields: [],
|
2023-09-22 03:09:14 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return await client.user.update({
|
|
|
|
|
where: {
|
|
|
|
|
id: user.id,
|
|
|
|
|
},
|
|
|
|
|
data: {
|
|
|
|
|
uri: `${config.http.base_url}/users/${user.id}`,
|
2023-11-12 07:39:59 +01:00
|
|
|
endpoints: {
|
|
|
|
|
disliked: `${config.http.base_url}/users/${user.id}/disliked`,
|
|
|
|
|
featured: `${config.http.base_url}/users/${user.id}/featured`,
|
|
|
|
|
liked: `${config.http.base_url}/users/${user.id}/liked`,
|
|
|
|
|
followers: `${config.http.base_url}/users/${user.id}/followers`,
|
|
|
|
|
following: `${config.http.base_url}/users/${user.id}/following`,
|
|
|
|
|
inbox: `${config.http.base_url}/users/${user.id}/inbox`,
|
|
|
|
|
outbox: `${config.http.base_url}/users/${user.id}/outbox`,
|
|
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
},
|
|
|
|
|
include: userRelations,
|
|
|
|
|
});
|
|
|
|
|
};
|
2023-09-22 03:09:14 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Parses mentions from a list of URIs
|
|
|
|
|
*/
|
|
|
|
|
export const parseMentionsUris = async (mentions: string[]) => {
|
|
|
|
|
return await client.user.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
uri: {
|
|
|
|
|
in: mentions,
|
2023-09-27 01:08:05 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
},
|
|
|
|
|
include: userRelations,
|
|
|
|
|
});
|
|
|
|
|
};
|
2023-09-22 03:09:14 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Retrieves a user from a token.
|
|
|
|
|
* @param access_token The access token to retrieve the user from.
|
|
|
|
|
* @returns The user associated with the given access token.
|
|
|
|
|
*/
|
|
|
|
|
export const retrieveUserFromToken = async (access_token: string) => {
|
|
|
|
|
if (!access_token) return null;
|
|
|
|
|
|
|
|
|
|
const token = await client.token.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
access_token,
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
user: {
|
|
|
|
|
include: userRelations,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
2023-09-22 05:18:05 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
if (!token) return null;
|
2023-09-27 01:08:05 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
return token.user;
|
|
|
|
|
};
|
2023-09-22 05:18:05 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Gets the relationship to another user.
|
|
|
|
|
* @param other The other user to get the relationship to.
|
|
|
|
|
* @returns The relationship to the other user.
|
|
|
|
|
*/
|
|
|
|
|
export const getRelationshipToOtherUser = async (
|
|
|
|
|
user: UserWithRelations,
|
|
|
|
|
other: User
|
|
|
|
|
) => {
|
|
|
|
|
return await client.relationship.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
ownerId: user.id,
|
|
|
|
|
subjectId: other.id,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
2023-09-22 03:09:14 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Generates keys for the user.
|
|
|
|
|
*/
|
|
|
|
|
export const generateUserKeys = async () => {
|
|
|
|
|
const keys = (await crypto.subtle.generateKey("Ed25519", true, [
|
|
|
|
|
"sign",
|
|
|
|
|
"verify",
|
|
|
|
|
])) as CryptoKeyPair;
|
|
|
|
|
|
|
|
|
|
const privateKey = btoa(
|
|
|
|
|
String.fromCharCode.apply(null, [
|
|
|
|
|
...new Uint8Array(
|
|
|
|
|
// jesus help me what do these letters mean
|
|
|
|
|
await crypto.subtle.exportKey("pkcs8", keys.privateKey)
|
|
|
|
|
),
|
|
|
|
|
])
|
|
|
|
|
);
|
|
|
|
|
const publicKey = btoa(
|
|
|
|
|
String.fromCharCode(
|
|
|
|
|
...new Uint8Array(
|
|
|
|
|
// why is exporting a key so hard
|
|
|
|
|
await crypto.subtle.exportKey("spki", keys.publicKey)
|
2023-09-18 07:38:08 +02:00
|
|
|
)
|
2023-11-11 03:36:06 +01:00
|
|
|
)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Add header, footer and newlines later on
|
|
|
|
|
// These keys are base64 encrypted
|
|
|
|
|
return {
|
|
|
|
|
private_key: privateKey,
|
|
|
|
|
public_key: publicKey,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// eslint-disable-next-line @typescript-eslint/require-await
|
|
|
|
|
export const userToAPI = async (
|
|
|
|
|
user: UserWithRelations,
|
|
|
|
|
isOwnAccount = false
|
|
|
|
|
): Promise<APIAccount> => {
|
|
|
|
|
const config = getConfig();
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
username: user.username,
|
|
|
|
|
display_name: user.displayName,
|
|
|
|
|
note: user.note,
|
|
|
|
|
url: user.uri,
|
|
|
|
|
avatar: getAvatarUrl(user, config),
|
|
|
|
|
header: getHeaderUrl(user, config),
|
2023-11-12 07:39:59 +01:00
|
|
|
locked: user.isLocked,
|
2023-11-11 03:36:06 +01:00
|
|
|
created_at: new Date(user.createdAt).toISOString(),
|
|
|
|
|
followers_count: user.relationshipSubjects.filter(r => r.following)
|
|
|
|
|
.length,
|
|
|
|
|
following_count: user.relationships.filter(r => r.following).length,
|
2023-11-12 02:37:14 +01:00
|
|
|
statuses_count: user._count.statuses,
|
2023-11-11 03:36:06 +01:00
|
|
|
emojis: await Promise.all(user.emojis.map(emoji => emojiToAPI(emoji))),
|
|
|
|
|
// TODO: Add fields
|
|
|
|
|
fields: [],
|
2023-11-12 07:39:59 +01:00
|
|
|
bot: user.isBot,
|
2023-11-11 03:36:06 +01:00
|
|
|
source:
|
|
|
|
|
isOwnAccount && user.source
|
|
|
|
|
? (user.source as any as APISource)
|
|
|
|
|
: undefined,
|
|
|
|
|
// TODO: Add static avatar and header
|
|
|
|
|
avatar_static: "",
|
|
|
|
|
header_static: "",
|
|
|
|
|
acct:
|
|
|
|
|
user.instance === null
|
|
|
|
|
? `${user.username}`
|
|
|
|
|
: `${user.username}@${user.instance.base_url}`,
|
|
|
|
|
// TODO: Add these fields
|
|
|
|
|
limited: false,
|
|
|
|
|
moved: null,
|
|
|
|
|
noindex: false,
|
|
|
|
|
suspended: false,
|
|
|
|
|
discoverable: undefined,
|
|
|
|
|
mute_expires_at: undefined,
|
|
|
|
|
group: false,
|
|
|
|
|
role: undefined,
|
|
|
|
|
};
|
|
|
|
|
};
|
2023-09-18 07:38:08 +02:00
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
/**
|
|
|
|
|
* Should only return local users
|
|
|
|
|
*/
|
|
|
|
|
export const userToLysand = (user: UserWithRelations): LysandUser => {
|
|
|
|
|
if (user.instanceId !== null) {
|
|
|
|
|
throw new Error("Cannot convert remote user to Lysand format");
|
2023-09-18 07:38:08 +02:00
|
|
|
}
|
|
|
|
|
|
2023-11-11 03:36:06 +01:00
|
|
|
return {
|
|
|
|
|
id: user.id,
|
|
|
|
|
type: "User",
|
|
|
|
|
uri: user.uri,
|
|
|
|
|
bio: [
|
|
|
|
|
{
|
|
|
|
|
content: user.note,
|
|
|
|
|
content_type: "text/html",
|
2023-10-08 22:20:42 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
{
|
|
|
|
|
content: htmlToText(user.note),
|
|
|
|
|
content_type: "text/plain",
|
2023-10-08 22:20:42 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
],
|
|
|
|
|
created_at: new Date(user.createdAt).toISOString(),
|
|
|
|
|
disliked: `${user.uri}/disliked`,
|
|
|
|
|
featured: `${user.uri}/featured`,
|
|
|
|
|
liked: `${user.uri}/liked`,
|
|
|
|
|
followers: `${user.uri}/followers`,
|
|
|
|
|
following: `${user.uri}/following`,
|
|
|
|
|
inbox: `${user.uri}/inbox`,
|
|
|
|
|
outbox: `${user.uri}/outbox`,
|
|
|
|
|
indexable: false,
|
|
|
|
|
username: user.username,
|
|
|
|
|
avatar: [
|
|
|
|
|
{
|
|
|
|
|
content: getAvatarUrl(user, getConfig()) || "",
|
|
|
|
|
content_type: `image/${user.avatar.split(".")[1]}`,
|
2023-10-23 07:39:42 +02:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
],
|
|
|
|
|
header: [
|
|
|
|
|
{
|
|
|
|
|
content: getHeaderUrl(user, getConfig()) || "",
|
|
|
|
|
content_type: `image/${user.header.split(".")[1]}`,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
display_name: user.displayName,
|
|
|
|
|
fields: (user.source as any as APISource).fields.map(field => ({
|
|
|
|
|
key: [
|
2023-11-04 04:34:31 +01:00
|
|
|
{
|
2023-11-11 03:36:06 +01:00
|
|
|
content: field.name,
|
2023-11-04 04:34:31 +01:00
|
|
|
content_type: "text/html",
|
|
|
|
|
},
|
|
|
|
|
{
|
2023-11-11 03:36:06 +01:00
|
|
|
content: htmlToText(field.name),
|
2023-11-04 04:34:31 +01:00
|
|
|
content_type: "text/plain",
|
|
|
|
|
},
|
|
|
|
|
],
|
2023-11-11 03:36:06 +01:00
|
|
|
value: [
|
2023-11-04 04:34:31 +01:00
|
|
|
{
|
2023-11-11 03:36:06 +01:00
|
|
|
content: field.value,
|
|
|
|
|
content_type: "text/html",
|
2023-11-04 04:34:31 +01:00
|
|
|
},
|
|
|
|
|
{
|
2023-11-11 03:36:06 +01:00
|
|
|
content: htmlToText(field.value),
|
|
|
|
|
content_type: "text/plain",
|
2023-11-04 04:34:31 +01:00
|
|
|
},
|
|
|
|
|
],
|
2023-11-11 03:36:06 +01:00
|
|
|
})),
|
|
|
|
|
public_key: {
|
|
|
|
|
actor: `${getConfig().http.base_url}/users/${user.id}`,
|
|
|
|
|
public_key: user.publicKey,
|
|
|
|
|
},
|
|
|
|
|
extensions: {
|
|
|
|
|
"org.lysand:custom_emojis": {
|
|
|
|
|
emojis: user.emojis.map(emoji => emojiToLysand(emoji)),
|
2023-11-04 04:34:31 +01:00
|
|
|
},
|
2023-11-11 03:36:06 +01:00
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
};
|