mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Implement duration controls on mutes
This commit is contained in:
parent
9d1d56bd08
commit
c9a1581932
|
|
@ -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] 🔥 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 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] 🔒 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
|
### CLI
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,9 @@ describe("/api/v1/accounts/:id/mute", () => {
|
||||||
test("should mute user", async () => {
|
test("should mute user", async () => {
|
||||||
await using client = await generateClient(users[0]);
|
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(ok).toBe(true);
|
||||||
expect(raw.status).toBe(200);
|
|
||||||
|
|
||||||
expect(data.muting).toBe(true);
|
expect(data.muting).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
@ -43,11 +42,31 @@ describe("/api/v1/accounts/:id/mute", () => {
|
||||||
test("should return 200 if user already muted", async () => {
|
test("should return 200 if user already muted", async () => {
|
||||||
await using client = await generateClient(users[0]);
|
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(ok).toBe(true);
|
||||||
expect(raw.status).toBe(200);
|
|
||||||
|
|
||||||
expect(data.muting).toBe(true);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { describeRoute } from "hono-openapi";
|
||||||
import { resolver, validator } from "hono-openapi/zod";
|
import { resolver, validator } from "hono-openapi/zod";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ApiError } from "~/classes/errors/api-error";
|
import { ApiError } from "~/classes/errors/api-error";
|
||||||
|
import { RelationshipJobType } from "~/classes/queues/relationships";
|
||||||
|
import { relationshipQueue } from "~/classes/queues/relationships";
|
||||||
|
|
||||||
export default apiRoute((app) =>
|
export default apiRoute((app) =>
|
||||||
app.post(
|
app.post(
|
||||||
|
|
@ -56,15 +58,14 @@ export default apiRoute((app) =>
|
||||||
.default(0)
|
.default(0)
|
||||||
.openapi({
|
.openapi({
|
||||||
description:
|
description:
|
||||||
"How long the mute should last, in seconds.",
|
"How long the mute should last, in seconds. 0 means indefinite.",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
handleZodError,
|
handleZodError,
|
||||||
),
|
),
|
||||||
async (context) => {
|
async (context) => {
|
||||||
const { user } = context.get("auth");
|
const { user } = context.get("auth");
|
||||||
// TODO: Add duration support
|
const { notifications, duration } = context.req.valid("json");
|
||||||
const { notifications } = context.req.valid("json");
|
|
||||||
const otherUser = context.get("user");
|
const otherUser = context.get("user");
|
||||||
|
|
||||||
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
const foundRelationship = await Relationship.fromOwnerAndSubject(
|
||||||
|
|
@ -72,12 +73,24 @@ export default apiRoute((app) =>
|
||||||
otherUser,
|
otherUser,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: Implement duration
|
|
||||||
await foundRelationship.update({
|
await foundRelationship.update({
|
||||||
muting: true,
|
muting: true,
|
||||||
mutingNotifications: notifications,
|
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);
|
return context.json(foundRelationship.toApi(), 200);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
import { User } from "@versia/kit/db";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
|
import { Worker } from "bullmq";
|
||||||
|
import chalk from "chalk";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
import type { KnownEntity } from "~/types/api";
|
import type { KnownEntity } from "~/types/api";
|
||||||
import { connection } from "~/utils/redis.ts";
|
import { connection } from "~/utils/redis.ts";
|
||||||
|
|
||||||
|
|
@ -18,3 +22,54 @@ export const deliveryQueue = new Queue<DeliveryJobData, void, DeliveryJobType>(
|
||||||
connection,
|
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 { Queue } from "bullmq";
|
||||||
|
import { Worker } from "bullmq";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
import { connection } from "~/utils/redis.ts";
|
import { connection } from "~/utils/redis.ts";
|
||||||
|
|
||||||
export enum FetchJobType {
|
export enum FetchJobType {
|
||||||
|
|
@ -15,3 +20,53 @@ export type FetchJobData = {
|
||||||
export const fetchQueue = new Queue<FetchJobData, void, FetchJobType>("fetch", {
|
export const fetchQueue = new Queue<FetchJobData, void, FetchJobType>("fetch", {
|
||||||
connection,
|
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 type { Entity } from "@versia/federation/types";
|
||||||
|
import { Instance, User } from "@versia/kit/db";
|
||||||
import { Queue } from "bullmq";
|
import { Queue } from "bullmq";
|
||||||
|
import { Worker } from "bullmq";
|
||||||
import type { SocketAddress } from "bun";
|
import type { SocketAddress } from "bun";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
import { connection } from "~/utils/redis.ts";
|
import { connection } from "~/utils/redis.ts";
|
||||||
|
import { InboxProcessor } from "../inbox/processor.ts";
|
||||||
|
|
||||||
export enum InboxJobType {
|
export enum InboxJobType {
|
||||||
ProcessEntity = "processEntity",
|
ProcessEntity = "processEntity",
|
||||||
|
|
@ -29,3 +34,169 @@ export const inboxQueue = new Queue<InboxJobData, Response, InboxJobType>(
|
||||||
connection,
|
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 { Queue } from "bullmq";
|
||||||
|
import { Worker } from "bullmq";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
import { connection } from "~/utils/redis.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 {
|
export enum MediaJobType {
|
||||||
ConvertMedia = "convertMedia",
|
ConvertMedia = "convertMedia",
|
||||||
|
|
@ -14,3 +19,108 @@ export type MediaJobData = {
|
||||||
export const mediaQueue = new Queue<MediaJobData, void, MediaJobType>("media", {
|
export const mediaQueue = new Queue<MediaJobData, void, MediaJobType>("media", {
|
||||||
connection,
|
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 { Queue } from "bullmq";
|
||||||
|
import { Worker } from "bullmq";
|
||||||
|
import { sendNotification } from "web-push";
|
||||||
|
import { config } from "~/config.ts";
|
||||||
import { connection } from "~/utils/redis.ts";
|
import { connection } from "~/utils/redis.ts";
|
||||||
|
|
||||||
export enum PushJobType {
|
export enum PushJobType {
|
||||||
|
|
@ -16,3 +21,135 @@ export type PushJobData = {
|
||||||
export const pushQueue = new Queue<PushJobData, void, PushJobType>("push", {
|
export const pushQueue = new Queue<PushJobData, void, PushJobType>("push", {
|
||||||
connection,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { sentry } from "@/sentry";
|
import { sentry } from "@/sentry";
|
||||||
import { getLogger } from "@logtape/logtape";
|
import { getLogger } from "@logtape/logtape";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { getDeliveryWorker } from "~/classes/workers/delivery";
|
import { getDeliveryWorker } from "~/classes/queues/delivery";
|
||||||
import { getFetchWorker } from "~/classes/workers/fetch";
|
import { getFetchWorker } from "~/classes/queues/fetch";
|
||||||
import { getInboxWorker } from "~/classes/workers/inbox";
|
import { getInboxWorker } from "~/classes/queues/inbox";
|
||||||
import { getMediaWorker } from "~/classes/workers/media";
|
import { getMediaWorker } from "~/classes/queues/media";
|
||||||
import { getPushWorker } from "~/classes/workers/push";
|
import { getPushWorker } from "~/classes/queues/push";
|
||||||
|
import { getRelationshipWorker } from "~/classes/queues/relationships";
|
||||||
|
|
||||||
process.on("SIGINT", () => {
|
process.on("SIGINT", () => {
|
||||||
process.exit();
|
process.exit();
|
||||||
|
|
@ -36,4 +37,8 @@ serverLogger.info`Starting Media Worker...`;
|
||||||
getMediaWorker();
|
getMediaWorker();
|
||||||
serverLogger.info`${chalk.green("✔")} Media Worker started`;
|
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`;
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,6 @@ import { deleteOldTestUsers } from "./utils.ts";
|
||||||
|
|
||||||
await setupDatabase();
|
await setupDatabase();
|
||||||
await deleteOldTestUsers();
|
await deleteOldTestUsers();
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
await import("~/entrypoints/worker/index.ts");
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { fetchQueue } from "~/classes/queues/fetch";
|
||||||
import { inboxQueue } from "~/classes/queues/inbox";
|
import { inboxQueue } from "~/classes/queues/inbox";
|
||||||
import { mediaQueue } from "~/classes/queues/media";
|
import { mediaQueue } from "~/classes/queues/media";
|
||||||
import { pushQueue } from "~/classes/queues/push";
|
import { pushQueue } from "~/classes/queues/push";
|
||||||
|
import { relationshipQueue } from "~/classes/queues/relationships";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
import pkg from "~/package.json";
|
import pkg from "~/package.json";
|
||||||
import type { HonoEnv } from "~/types/api";
|
import type { HonoEnv } from "~/types/api";
|
||||||
|
|
@ -22,6 +23,7 @@ export const applyToHono = (app: Hono<HonoEnv>): void => {
|
||||||
new BullMQAdapter(fetchQueue),
|
new BullMQAdapter(fetchQueue),
|
||||||
new BullMQAdapter(pushQueue),
|
new BullMQAdapter(pushQueue),
|
||||||
new BullMQAdapter(mediaQueue),
|
new BullMQAdapter(mediaQueue),
|
||||||
|
new BullMQAdapter(relationshipQueue),
|
||||||
],
|
],
|
||||||
serverAdapter,
|
serverAdapter,
|
||||||
options: {
|
options: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue