mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
feat(api): ✨ Implement duration controls on mutes
This commit is contained in:
parent
9d1d56bd08
commit
c9a1581932
17 changed files with 652 additions and 572 deletions
|
|
@ -1,4 +1,8 @@
|
|||
import { User } from "@versia/kit/db";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import chalk from "chalk";
|
||||
import { config } from "~/config.ts";
|
||||
import type { KnownEntity } from "~/types/api";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
|
||||
|
|
@ -18,3 +22,54 @@ export const deliveryQueue = new Queue<DeliveryJobData, void, DeliveryJobType>(
|
|||
connection,
|
||||
},
|
||||
);
|
||||
|
||||
export const getDeliveryWorker = (): Worker<
|
||||
DeliveryJobData,
|
||||
void,
|
||||
DeliveryJobType
|
||||
> =>
|
||||
new Worker<DeliveryJobData, void, DeliveryJobType>(
|
||||
deliveryQueue.name,
|
||||
async (job) => {
|
||||
switch (job.name) {
|
||||
case DeliveryJobType.FederateEntity: {
|
||||
const { entity, recipientId, senderId } = job.data;
|
||||
|
||||
const sender = await User.fromId(senderId);
|
||||
|
||||
if (!sender) {
|
||||
throw new Error(
|
||||
`Could not resolve sender ID ${chalk.gray(senderId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const recipient = await User.fromId(recipientId);
|
||||
|
||||
if (!recipient) {
|
||||
throw new Error(
|
||||
`Could not resolve recipient ID ${chalk.gray(recipientId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
await job.log(
|
||||
`Federating entity [${entity.id}] from @${sender.getAcct()} to @${recipient.getAcct()}`,
|
||||
);
|
||||
|
||||
await sender.federateToUser(entity, recipient);
|
||||
|
||||
await job.log(
|
||||
`✔ Finished federating entity [${entity.id}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
removeOnComplete: {
|
||||
age: config.queues.delivery?.remove_after_complete_seconds,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: config.queues.delivery?.remove_after_failure_seconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { Instance } from "@versia/kit/db";
|
||||
import { Instances } from "@versia/kit/tables";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { config } from "~/config.ts";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
|
||||
export enum FetchJobType {
|
||||
|
|
@ -15,3 +20,53 @@ export type FetchJobData = {
|
|||
export const fetchQueue = new Queue<FetchJobData, void, FetchJobType>("fetch", {
|
||||
connection,
|
||||
});
|
||||
|
||||
export const getFetchWorker = (): Worker<FetchJobData, void, FetchJobType> =>
|
||||
new Worker<FetchJobData, void, FetchJobType>(
|
||||
fetchQueue.name,
|
||||
async (job) => {
|
||||
switch (job.name) {
|
||||
case FetchJobType.Instance: {
|
||||
const { uri } = job.data;
|
||||
|
||||
await job.log(`Fetching instance metadata from [${uri}]`);
|
||||
|
||||
// Check if exists
|
||||
const host = new URL(uri).host;
|
||||
|
||||
const existingInstance = await Instance.fromSql(
|
||||
eq(Instances.baseUrl, host),
|
||||
);
|
||||
|
||||
if (existingInstance) {
|
||||
await job.log(
|
||||
"Instance is known, refetching remote data.",
|
||||
);
|
||||
|
||||
await existingInstance.updateFromRemote();
|
||||
|
||||
await job.log(
|
||||
`Instance [${uri}] successfully refetched`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await Instance.resolve(new URL(uri));
|
||||
|
||||
await job.log(
|
||||
`✔ Finished fetching instance metadata from [${uri}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
removeOnComplete: {
|
||||
age: config.queues.fetch?.remove_after_complete_seconds,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: config.queues.fetch?.remove_after_failure_seconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
import { getLogger } from "@logtape/logtape";
|
||||
import type { Entity } from "@versia/federation/types";
|
||||
import { Instance, User } from "@versia/kit/db";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import type { SocketAddress } from "bun";
|
||||
import { config } from "~/config.ts";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
import { InboxProcessor } from "../inbox/processor.ts";
|
||||
|
||||
export enum InboxJobType {
|
||||
ProcessEntity = "processEntity",
|
||||
|
|
@ -29,3 +34,169 @@ export const inboxQueue = new Queue<InboxJobData, Response, InboxJobType>(
|
|||
connection,
|
||||
},
|
||||
);
|
||||
|
||||
export const getInboxWorker = (): Worker<InboxJobData, void, InboxJobType> =>
|
||||
new Worker<InboxJobData, void, InboxJobType>(
|
||||
inboxQueue.name,
|
||||
async (job) => {
|
||||
switch (job.name) {
|
||||
case InboxJobType.ProcessEntity: {
|
||||
const { data, headers, request, ip } = job.data;
|
||||
|
||||
await job.log(`Processing entity [${data.id}]`);
|
||||
|
||||
if (headers.authorization) {
|
||||
const processor = new InboxProcessor(
|
||||
{
|
||||
...request,
|
||||
url: new URL(request.url),
|
||||
},
|
||||
data,
|
||||
null,
|
||||
{
|
||||
authorization: headers.authorization,
|
||||
},
|
||||
getLogger(["federation", "inbox"]),
|
||||
ip,
|
||||
);
|
||||
|
||||
await job.log(
|
||||
`Entity [${data.id}] is potentially from a bridge`,
|
||||
);
|
||||
|
||||
const output = await processor.process();
|
||||
|
||||
if (output instanceof Response) {
|
||||
// Error occurred
|
||||
const error = await output.json();
|
||||
await job.log(`Error during processing: ${error}`);
|
||||
|
||||
await job.log(
|
||||
`Failed processing entity [${data.id}]`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await job.log(
|
||||
`✔ Finished processing entity [${data.id}]`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
"versia-signature": signature,
|
||||
"versia-signed-at": signedAt,
|
||||
"versia-signed-by": signedBy,
|
||||
} = headers as {
|
||||
"versia-signature": string;
|
||||
"versia-signed-at": number;
|
||||
"versia-signed-by": string;
|
||||
};
|
||||
|
||||
const sender = await User.resolve(new URL(signedBy));
|
||||
|
||||
if (!(sender || signedBy.startsWith("instance "))) {
|
||||
await job.log(
|
||||
`Could not resolve sender URI [${signedBy}]`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (sender?.isLocal()) {
|
||||
throw new Error(
|
||||
"Cannot process federation requests from local users",
|
||||
);
|
||||
}
|
||||
|
||||
const remoteInstance = sender
|
||||
? await Instance.fromUser(sender)
|
||||
: await Instance.resolveFromHost(
|
||||
signedBy.split(" ")[1],
|
||||
);
|
||||
|
||||
if (!remoteInstance) {
|
||||
await job.log("Could not resolve the remote instance.");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await job.log(
|
||||
`Entity [${data.id}] is from remote instance [${remoteInstance.data.baseUrl}]`,
|
||||
);
|
||||
|
||||
if (!remoteInstance.data.publicKey?.key) {
|
||||
throw new Error(
|
||||
`Instance ${remoteInstance.data.baseUrl} has no public key stored in database`,
|
||||
);
|
||||
}
|
||||
|
||||
const processor = new InboxProcessor(
|
||||
{
|
||||
...request,
|
||||
url: new URL(request.url),
|
||||
},
|
||||
data,
|
||||
{
|
||||
instance: remoteInstance,
|
||||
key:
|
||||
sender?.data.publicKey ??
|
||||
remoteInstance.data.publicKey.key,
|
||||
},
|
||||
{
|
||||
signature,
|
||||
signedAt: new Date(signedAt * 1000),
|
||||
authorization: undefined,
|
||||
},
|
||||
getLogger(["federation", "inbox"]),
|
||||
ip,
|
||||
);
|
||||
|
||||
const output = await processor.process();
|
||||
|
||||
if (output instanceof Response) {
|
||||
// Error occurred
|
||||
const error = await output.json();
|
||||
await job.log(`Error during processing: ${error}`);
|
||||
|
||||
await job.log(`Failed processing entity [${data.id}]`);
|
||||
|
||||
await job.log(
|
||||
`Sending error message to instance [${remoteInstance.data.baseUrl}]`,
|
||||
);
|
||||
|
||||
await remoteInstance.sendMessage(
|
||||
`Failed processing entity [${data.uri}] delivered to inbox. Returned error:\n\n${JSON.stringify(
|
||||
error,
|
||||
null,
|
||||
4,
|
||||
)}`,
|
||||
);
|
||||
|
||||
await job.log("Message sent");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await job.log(`Finished processing entity [${data.id}]`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unknown job type: ${job.name}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
removeOnComplete: {
|
||||
age: config.queues.inbox?.remove_after_complete_seconds,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: config.queues.inbox?.remove_after_failure_seconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import { Media } from "@versia/kit/db";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import { config } from "~/config.ts";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
import { calculateBlurhash } from "../media/preprocessors/blurhash.ts";
|
||||
import { convertImage } from "../media/preprocessors/image-conversion.ts";
|
||||
|
||||
export enum MediaJobType {
|
||||
ConvertMedia = "convertMedia",
|
||||
|
|
@ -14,3 +19,108 @@ export type MediaJobData = {
|
|||
export const mediaQueue = new Queue<MediaJobData, void, MediaJobType>("media", {
|
||||
connection,
|
||||
});
|
||||
|
||||
export const getMediaWorker = (): Worker<MediaJobData, void, MediaJobType> =>
|
||||
new Worker<MediaJobData, void, MediaJobType>(
|
||||
mediaQueue.name,
|
||||
async (job) => {
|
||||
switch (job.name) {
|
||||
case MediaJobType.ConvertMedia: {
|
||||
const { attachmentId, filename } = job.data;
|
||||
|
||||
await job.log(`Fetching attachment ID [${attachmentId}]`);
|
||||
|
||||
const attachment = await Media.fromId(attachmentId);
|
||||
|
||||
if (!attachment) {
|
||||
throw new Error(
|
||||
`Attachment not found: [${attachmentId}]`,
|
||||
);
|
||||
}
|
||||
|
||||
await job.log(`Processing attachment [${attachmentId}]`);
|
||||
await job.log(
|
||||
`Fetching file from [${attachment.getUrl()}]`,
|
||||
);
|
||||
|
||||
// Download the file and process it.
|
||||
const blob = await (
|
||||
await fetch(attachment.getUrl())
|
||||
).blob();
|
||||
|
||||
const file = new File([blob], filename);
|
||||
|
||||
await job.log(`Converting attachment [${attachmentId}]`);
|
||||
|
||||
const processedFile = await convertImage(
|
||||
file,
|
||||
config.media.conversion.convert_to,
|
||||
{
|
||||
convertVectors:
|
||||
config.media.conversion.convert_vectors,
|
||||
},
|
||||
);
|
||||
|
||||
await job.log(`Uploading attachment [${attachmentId}]`);
|
||||
|
||||
await attachment.updateFromFile(processedFile);
|
||||
|
||||
await job.log(
|
||||
`✔ Finished processing attachment [${attachmentId}]`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
case MediaJobType.CalculateMetadata: {
|
||||
// Calculate blurhash
|
||||
const { attachmentId } = job.data;
|
||||
|
||||
await job.log(`Fetching attachment ID [${attachmentId}]`);
|
||||
|
||||
const attachment = await Media.fromId(attachmentId);
|
||||
|
||||
if (!attachment) {
|
||||
throw new Error(
|
||||
`Attachment not found: [${attachmentId}]`,
|
||||
);
|
||||
}
|
||||
|
||||
await job.log(`Processing attachment [${attachmentId}]`);
|
||||
await job.log(
|
||||
`Fetching file from [${attachment.getUrl()}]`,
|
||||
);
|
||||
|
||||
// Download the file and process it.
|
||||
const blob = await (
|
||||
await fetch(attachment.getUrl())
|
||||
).blob();
|
||||
|
||||
// Filename is not important for blurhash
|
||||
const file = new File([blob], "");
|
||||
|
||||
await job.log(`Generating blurhash for [${attachmentId}]`);
|
||||
|
||||
const blurhash = await calculateBlurhash(file);
|
||||
|
||||
await attachment.update({
|
||||
blurhash,
|
||||
});
|
||||
|
||||
await job.log(
|
||||
`✔ Finished processing attachment [${attachmentId}]`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
removeOnComplete: {
|
||||
age: config.queues.media?.remove_after_complete_seconds,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: config.queues.media?.remove_after_failure_seconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { htmlToText } from "@/content_types.ts";
|
||||
import { Note, PushSubscription, Token, User } from "@versia/kit/db";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import { sendNotification } from "web-push";
|
||||
import { config } from "~/config.ts";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
|
||||
export enum PushJobType {
|
||||
|
|
@ -16,3 +21,135 @@ export type PushJobData = {
|
|||
export const pushQueue = new Queue<PushJobData, void, PushJobType>("push", {
|
||||
connection,
|
||||
});
|
||||
|
||||
export const getPushWorker = (): Worker<PushJobData, void, PushJobType> =>
|
||||
new Worker<PushJobData, void, PushJobType>(
|
||||
pushQueue.name,
|
||||
async (job) => {
|
||||
const {
|
||||
data: { psId, relatedUserId, type, noteId, notificationId },
|
||||
} = job;
|
||||
|
||||
if (!config.notifications.push) {
|
||||
await job.log("Push notifications are disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
await job.log(
|
||||
`Sending push notification for note [${notificationId}]`,
|
||||
);
|
||||
|
||||
const ps = await PushSubscription.fromId(psId);
|
||||
|
||||
if (!ps) {
|
||||
throw new Error(
|
||||
`Could not resolve push subscription ID ${psId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = await Token.fromId(ps.data.tokenId);
|
||||
|
||||
if (!token) {
|
||||
throw new Error(
|
||||
`Could not resolve token ID ${ps.data.tokenId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const relatedUser = await User.fromId(relatedUserId);
|
||||
|
||||
if (!relatedUser) {
|
||||
throw new Error(
|
||||
`Could not resolve related user ID ${relatedUserId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const note = noteId ? await Note.fromId(noteId) : null;
|
||||
|
||||
const truncate = (str: string, len: number): string => {
|
||||
if (str.length <= len) {
|
||||
return str;
|
||||
}
|
||||
|
||||
return `${str.slice(0, len)}...`;
|
||||
};
|
||||
|
||||
const name = truncate(
|
||||
relatedUser.data.displayName || relatedUser.data.username,
|
||||
50,
|
||||
);
|
||||
|
||||
let title = name;
|
||||
|
||||
switch (type) {
|
||||
case "mention":
|
||||
title = `${name} mentioned you`;
|
||||
break;
|
||||
case "reply":
|
||||
title = `${name} replied to you`;
|
||||
break;
|
||||
case "favourite":
|
||||
title = `${name} liked your note`;
|
||||
break;
|
||||
case "reblog":
|
||||
title = `${name} reblogged your note`;
|
||||
break;
|
||||
case "follow":
|
||||
title = `${name} followed you`;
|
||||
break;
|
||||
case "follow_request":
|
||||
title = `${name} requested to follow you`;
|
||||
break;
|
||||
case "poll":
|
||||
title = "Poll ended";
|
||||
break;
|
||||
}
|
||||
|
||||
const body = note
|
||||
? htmlToText(note.data.spoilerText || note.data.content)
|
||||
: htmlToText(relatedUser.data.note);
|
||||
|
||||
await sendNotification(
|
||||
{
|
||||
endpoint: ps.data.endpoint,
|
||||
keys: {
|
||||
auth: ps.data.authSecret,
|
||||
p256dh: ps.data.publicKey,
|
||||
},
|
||||
},
|
||||
JSON.stringify({
|
||||
access_token: token.data.accessToken,
|
||||
// FIXME
|
||||
preferred_locale: "en-US",
|
||||
notification_id: notificationId,
|
||||
notification_type: type,
|
||||
icon: relatedUser.getAvatarUrl(),
|
||||
title,
|
||||
body: truncate(body, 140),
|
||||
}),
|
||||
{
|
||||
vapidDetails: {
|
||||
subject:
|
||||
config.notifications.push.subject ||
|
||||
config.http.base_url.origin,
|
||||
privateKey:
|
||||
config.notifications.push.vapid_keys.private,
|
||||
publicKey: config.notifications.push.vapid_keys.public,
|
||||
},
|
||||
contentEncoding: "aesgcm",
|
||||
},
|
||||
);
|
||||
|
||||
await job.log(
|
||||
`✔ Finished delivering push notification for note [${notificationId}]`,
|
||||
);
|
||||
},
|
||||
{
|
||||
connection,
|
||||
removeOnComplete: {
|
||||
age: config.queues.push?.remove_after_complete_seconds,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: config.queues.push?.remove_after_failure_seconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
|
|
|||
67
classes/queues/relationships.ts
Normal file
67
classes/queues/relationships.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { Relationship, User } from "@versia/kit/db";
|
||||
import { Queue } from "bullmq";
|
||||
import { Worker } from "bullmq";
|
||||
import { config } from "~/config.ts";
|
||||
import { connection } from "~/utils/redis.ts";
|
||||
|
||||
export enum RelationshipJobType {
|
||||
Unmute = "unmute",
|
||||
}
|
||||
|
||||
export type RelationshipJobData = {
|
||||
ownerId: string;
|
||||
subjectId: string;
|
||||
};
|
||||
|
||||
export const relationshipQueue = new Queue<
|
||||
RelationshipJobData,
|
||||
void,
|
||||
RelationshipJobType
|
||||
>("relationships", {
|
||||
connection,
|
||||
});
|
||||
|
||||
export const getRelationshipWorker = (): Worker<
|
||||
RelationshipJobData,
|
||||
void,
|
||||
RelationshipJobType
|
||||
> =>
|
||||
new Worker<RelationshipJobData, void, RelationshipJobType>(
|
||||
relationshipQueue.name,
|
||||
async (job) => {
|
||||
switch (job.name) {
|
||||
case RelationshipJobType.Unmute: {
|
||||
const { ownerId, subjectId } = job.data;
|
||||
|
||||
const owner = await User.fromId(ownerId);
|
||||
const subject = await User.fromId(subjectId);
|
||||
|
||||
if (!(owner && subject)) {
|
||||
await job.log("Users not found");
|
||||
return;
|
||||
}
|
||||
|
||||
const foundRelationship =
|
||||
await Relationship.fromOwnerAndSubject(owner, subject);
|
||||
|
||||
if (foundRelationship.data.muting) {
|
||||
await foundRelationship.update({
|
||||
muting: false,
|
||||
mutingNotifications: false,
|
||||
});
|
||||
}
|
||||
|
||||
await job.log(`✔ Finished unmuting [${subjectId}]`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
connection,
|
||||
removeOnComplete: {
|
||||
age: config.queues.fetch?.remove_after_complete_seconds,
|
||||
},
|
||||
removeOnFail: {
|
||||
age: config.queues.fetch?.remove_after_failure_seconds,
|
||||
},
|
||||
},
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue