feat(api): Implement duration controls on mutes

This commit is contained in:
Jesse Wierzbinski 2025-03-30 20:54:47 +02:00
parent 9d1d56bd08
commit c9a1581932
No known key found for this signature in database
17 changed files with 652 additions and 572 deletions

View file

@ -25,6 +25,7 @@ Please see [Database Changes](#database-changes) and [New Configuration](#new-co
- [x] 🔥 Removed nonstandard `/api/v1/accounts/id` endpoint (the same functionality was already possible with other endpoints).
- [x] ✨️ Implemented rate limiting support for API endpoints.
- [x] 🔒 Implemented `is_indexable` and `is_hiding_collections` fields to the [**Accounts API**](https://docs.joinmastodon.org/methods/accounts/#update_credentials).
- [x] ✨️ Muting other users now lets you specify a duration, after which the mute will be automatically removed.
### CLI

View file

@ -32,10 +32,9 @@ describe("/api/v1/accounts/:id/mute", () => {
test("should mute user", async () => {
await using client = await generateClient(users[0]);
const { data, ok, raw } = await client.muteAccount(users[1].id);
const { data, ok } = await client.muteAccount(users[1].id);
expect(ok).toBe(true);
expect(raw.status).toBe(200);
expect(data.muting).toBe(true);
});
@ -43,11 +42,31 @@ describe("/api/v1/accounts/:id/mute", () => {
test("should return 200 if user already muted", async () => {
await using client = await generateClient(users[0]);
const { data, ok, raw } = await client.muteAccount(users[1].id);
const { data, ok } = await client.muteAccount(users[1].id);
expect(ok).toBe(true);
expect(raw.status).toBe(200);
expect(data.muting).toBe(true);
});
test("should unmute user after duration", async () => {
await using client = await generateClient(users[0]);
const { data, ok } = await client.muteAccount(users[1].id, {
duration: 1,
});
expect(ok).toBe(true);
expect(data.muting).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
const { data: data2, ok: ok2 } = await client.getRelationship(
users[1].id,
);
expect(ok2).toBe(true);
expect(data2.muting).toBe(false);
});
});

View file

@ -6,6 +6,8 @@ import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod";
import { ApiError } from "~/classes/errors/api-error";
import { RelationshipJobType } from "~/classes/queues/relationships";
import { relationshipQueue } from "~/classes/queues/relationships";
export default apiRoute((app) =>
app.post(
@ -56,15 +58,14 @@ export default apiRoute((app) =>
.default(0)
.openapi({
description:
"How long the mute should last, in seconds.",
"How long the mute should last, in seconds. 0 means indefinite.",
}),
}),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
// TODO: Add duration support
const { notifications } = context.req.valid("json");
const { notifications, duration } = context.req.valid("json");
const otherUser = context.get("user");
const foundRelationship = await Relationship.fromOwnerAndSubject(
@ -72,12 +73,24 @@ export default apiRoute((app) =>
otherUser,
);
// TODO: Implement duration
await foundRelationship.update({
muting: true,
mutingNotifications: notifications,
});
if (duration > 0) {
await relationshipQueue.add(
RelationshipJobType.Unmute,
{
ownerId: user.id,
subjectId: otherUser.id,
},
{
delay: duration * 1000,
},
);
}
return context.json(foundRelationship.toApi(), 200);
},
),

View file

@ -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,
},
},
);

View file

@ -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,
},
},
);

View file

@ -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,
},
},
);

View file

@ -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,
},
},
);

View file

@ -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,
},
},
);

View 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,
},
},
);

View file

@ -1,61 +0,0 @@
import { User } from "@versia/kit/db";
import { Worker } from "bullmq";
import chalk from "chalk";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import {
type DeliveryJobData,
DeliveryJobType,
deliveryQueue,
} from "../queues/delivery.ts";
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,
},
},
);

View file

@ -1,61 +0,0 @@
import { Instance } from "@versia/kit/db";
import { Instances } from "@versia/kit/tables";
import { Worker } from "bullmq";
import { eq } from "drizzle-orm";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import {
type FetchJobData,
FetchJobType,
fetchQueue,
} from "../queues/fetch.ts";
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,
},
},
);

View file

@ -1,177 +0,0 @@
import { getLogger } from "@logtape/logtape";
import { Instance, User } from "@versia/kit/db";
import { Worker } from "bullmq";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import { InboxProcessor } from "../inbox/processor.ts";
import {
type InboxJobData,
InboxJobType,
inboxQueue,
} from "../queues/inbox.ts";
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,
},
},
);

View file

@ -1,116 +0,0 @@
import { Media } from "@versia/kit/db";
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";
import {
type MediaJobData,
MediaJobType,
mediaQueue,
} from "../queues/media.ts";
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,
},
},
);

View file

@ -1,143 +0,0 @@
import { htmlToText } from "@/content_types.ts";
import { Note, PushSubscription, Token, User } from "@versia/kit/db";
import { Worker } from "bullmq";
import { sendNotification } from "web-push";
import { config } from "~/config.ts";
import { connection } from "~/utils/redis.ts";
import {
type PushJobData,
type PushJobType,
pushQueue,
} from "../queues/push.ts";
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,
},
},
);

View file

@ -1,11 +1,12 @@
import { sentry } from "@/sentry";
import { getLogger } from "@logtape/logtape";
import chalk from "chalk";
import { getDeliveryWorker } from "~/classes/workers/delivery";
import { getFetchWorker } from "~/classes/workers/fetch";
import { getInboxWorker } from "~/classes/workers/inbox";
import { getMediaWorker } from "~/classes/workers/media";
import { getPushWorker } from "~/classes/workers/push";
import { getDeliveryWorker } from "~/classes/queues/delivery";
import { getFetchWorker } from "~/classes/queues/fetch";
import { getInboxWorker } from "~/classes/queues/inbox";
import { getMediaWorker } from "~/classes/queues/media";
import { getPushWorker } from "~/classes/queues/push";
import { getRelationshipWorker } from "~/classes/queues/relationships";
process.on("SIGINT", () => {
process.exit();
@ -36,4 +37,8 @@ serverLogger.info`Starting Media Worker...`;
getMediaWorker();
serverLogger.info`${chalk.green("✔")} Media Worker started`;
serverLogger.info`${chalk.green("✔✔✔✔✔")} All workers started`;
serverLogger.info`Starting Relationship Worker...`;
getRelationshipWorker();
serverLogger.info`${chalk.green("✔")} Relationship Worker started`;
serverLogger.info`${chalk.green("✔✔✔✔✔✔")} All workers started`;

View file

@ -3,3 +3,6 @@ import { deleteOldTestUsers } from "./utils.ts";
await setupDatabase();
await deleteOldTestUsers();
// Start workers
await import("~/entrypoints/worker/index.ts");

View file

@ -8,6 +8,7 @@ import { fetchQueue } from "~/classes/queues/fetch";
import { inboxQueue } from "~/classes/queues/inbox";
import { mediaQueue } from "~/classes/queues/media";
import { pushQueue } from "~/classes/queues/push";
import { relationshipQueue } from "~/classes/queues/relationships";
import { config } from "~/config.ts";
import pkg from "~/package.json";
import type { HonoEnv } from "~/types/api";
@ -22,6 +23,7 @@ export const applyToHono = (app: Hono<HonoEnv>): void => {
new BullMQAdapter(fetchQueue),
new BullMQAdapter(pushQueue),
new BullMQAdapter(mediaQueue),
new BullMQAdapter(relationshipQueue),
],
serverAdapter,
options: {