feat(api): Finish push notification delivery

This commit is contained in:
Jesse Wierzbinski 2025-01-02 02:45:40 +01:00
parent d096ab830c
commit d839c274b1
No known key found for this signature in database
12 changed files with 457 additions and 9 deletions

View file

@ -189,6 +189,20 @@ expiration = 300 # 5 minutes
# Leave this empty to generate a new key # Leave this empty to generate a new key
key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg==" key = "YBpAV0KZOeM/MZ4kOb2E9moH9gCUr00Co9V7ncGRJ3wbd/a9tLDKKFdI0BtOcnlpfx0ZBh0+w3WSvsl0TsesTg=="
[notifications]
[notifications.push]
# Whether to enable push notifications
enabled = true
[notifications.push.vapid]
# VAPID keys for push notifications
# Run Versia Server with those values missing to generate new keys
public = "BBanhyj2_xWwbTsWld3T49VcAoKZHrVJTzF1f6Av2JwQY_wUi3CF9vZ0WeEcACRj6EEqQ7N35CkUh5epF7n4P_s"
private = "Eujaz7NsF0rKZOVrAFL7mMpFdl96f591ERsRn81unq0"
# Optional
# subject = "mailto:joe@example.com"
[defaults] [defaults]
# Default visibility for new notes # Default visibility for new notes
visibility = "public" visibility = "public"

View file

@ -19,21 +19,49 @@ Versia Server `0.8.0` is fully backwards compatible with `0.7.0`.
## New Configuration Options ## New Configuration Options
```toml ```toml
[notifications]
[notifications.push]
# Whether to enable push notifications
enabled = true
[notifications.push.vapid]
# VAPID keys for push notifications
# Run Versia Server with those values missing to generate new keys
public = ""
private = ""
# Optional
# subject = "mailto:joe@example.com"
[queues] [queues]
# Control the delivery queue (for outbound federation) # Controls the delivery queue (for outbound federation)
[queues.delivery] [queues.delivery]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_on_complete = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_on_failure = 31536000
# Control the inbox processing queue (for inbound federation) # Controls the inbox processing queue (for inbound federation)
[queues.inbox] [queues.inbox]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_on_complete = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_on_failure = 31536000
# Controls the fetch queue (for remote data refreshes)
[queues.fetch]
# Time in seconds to remove completed jobs
remove_on_complete = 31536000
# Time in seconds to remove failed jobs
remove_on_failure = 31536000
# Controls the push queue (for push notification delivery)
[queues.push]
# Time in seconds to remove completed jobs
remove_on_complete = 31536000
# Time in seconds to remove failed jobs
remove_on_failure = 31536000
[validation] [validation]
max_emoji_size = 1000000 max_emoji_size = 1000000
max_emoji_shortcode_size = 100 max_emoji_shortcode_size = 100

BIN
bun.lockb

Binary file not shown.

View file

@ -3,8 +3,8 @@ import type {
Alerts, Alerts,
PushSubscription as ApiPushSubscription, PushSubscription as ApiPushSubscription,
} from "@versia/client/types"; } from "@versia/client/types";
import { type Token, db } from "@versia/kit/db"; import { type Token, type User, db } from "@versia/kit/db";
import { PushSubscriptions } from "@versia/kit/tables"; import { PushSubscriptions, Users } from "@versia/kit/tables";
import { import {
type InferInsertModel, type InferInsertModel,
type InferSelectModel, type InferSelectModel,
@ -136,6 +136,27 @@ export class PushSubscription extends BaseInterface<
); );
} }
public static async manyFromUser(
user: User,
limit?: number,
offset?: number,
): Promise<PushSubscription[]> {
const found = await db.query.PushSubscriptions.findMany({
where: (): SQL => eq(Users.id, user.id),
limit,
offset,
with: {
token: {
with: {
user: true,
},
},
},
});
return found.map((s) => new PushSubscription(s));
}
public static async fromSql( public static async fromSql(
sql: SQL<unknown> | undefined, sql: SQL<unknown> | undefined,
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id), orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),

View file

@ -21,7 +21,7 @@ import type {
FollowReject as VersiaFollowReject, FollowReject as VersiaFollowReject,
User as VersiaUser, User as VersiaUser,
} from "@versia/federation/types"; } from "@versia/federation/types";
import { Notification, db } from "@versia/kit/db"; import { Notification, PushSubscription, db } from "@versia/kit/db";
import { import {
EmojiToUser, EmojiToUser,
Likes, Likes,
@ -54,6 +54,7 @@ import { searchManager } from "~/classes/search/search-manager";
import { type Config, config } from "~/packages/config-manager"; import { type Config, config } from "~/packages/config-manager";
import type { KnownEntity } from "~/types/api.ts"; import type { KnownEntity } from "~/types/api.ts";
import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts";
import { PushJobType, pushQueue } from "../queues/push.ts";
import { BaseInterface } from "./base.ts"; import { BaseInterface } from "./base.ts";
import { Emoji } from "./emoji.ts"; import { Emoji } from "./emoji.ts";
import { Instance } from "./instance.ts"; import { Instance } from "./instance.ts";
@ -572,12 +573,40 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
relatedUser: User, relatedUser: User,
note?: Note, note?: Note,
): Promise<void> { ): Promise<void> {
await Notification.insert({ const notification = await Notification.insert({
accountId: relatedUser.id, accountId: relatedUser.id,
type, type,
notifiedId: this.id, notifiedId: this.id,
noteId: note?.id ?? null, noteId: note?.id ?? null,
}); });
// Also do push notifications
if (config.notifications.push.enabled) {
await this.notifyPush(notification.id, type, relatedUser, note);
}
}
private async notifyPush(
notificationId: string,
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
relatedUser: User,
note?: Note,
): Promise<void> {
// Fetch all push subscriptions
const ps = await PushSubscription.manyFromUser(this);
pushQueue.addBulk(
ps.map((p) => ({
data: {
psId: p.id,
type,
relatedUserId: relatedUser.id,
noteId: note?.id,
notificationId,
},
name: PushJobType.Notify,
})),
);
} }
public async clearAllNotifications(): Promise<void> { public async clearAllNotifications(): Promise<void> {

18
classes/queues/push.ts Normal file
View file

@ -0,0 +1,18 @@
import { Queue } from "bullmq";
import { connection } from "~/utils/redis.ts";
export enum PushJobType {
Notify = "notify",
}
export type PushJobData = {
psId: string;
type: string;
relatedUserId: string;
noteId?: string;
notificationId: string;
};
export const pushQueue = new Queue<PushJobData, void, PushJobType>("push", {
connection,
});

128
classes/workers/push.ts Normal file
View file

@ -0,0 +1,128 @@
import { Note, PushSubscription, Token, User } from "@versia/kit/db";
import { Worker } from "bullmq";
import { htmlToText } from "html-to-text";
import { sendNotification } from "web-push";
import { config } from "~/packages/config-manager";
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;
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 "like":
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);
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(config),
title,
body: truncate(body, 140),
}),
{
vapidDetails: {
subject:
config.notifications.push.vapid.subject ||
config.http.base_url,
privateKey: config.notifications.push.vapid.private,
publicKey: config.notifications.push.vapid.public,
},
},
);
},
{
connection,
removeOnComplete: {
age: config.queues.push.remove_on_complete,
},
removeOnFail: {
age: config.queues.push.remove_on_failure,
},
},
);

View file

@ -247,6 +247,20 @@ expiration = 300 # 5 minutes
# Leave this empty to generate a new key # Leave this empty to generate a new key
key = "" key = ""
[notifications]
[notifications.push]
# Whether to enable push notifications
enabled = true
[notifications.push.vapid]
# VAPID keys for push notifications
# Run Versia Server with those values missing to generate new keys
public = ""
private = ""
# Optional
# subject = "mailto:joe@example.com"
[defaults] [defaults]
# Default visibility for new notes # Default visibility for new notes
# Can be public, unlisted, private or direct # Can be public, unlisted, private or direct
@ -262,27 +276,34 @@ language = "en"
placeholder_style = "thumbs" placeholder_style = "thumbs"
[queues] [queues]
# Control the delivery queue (for outbound federation) # Controls the delivery queue (for outbound federation)
[queues.delivery] [queues.delivery]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_on_complete = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_on_failure = 31536000
# Control the inbox processing queue (for inbound federation) # Controls the inbox processing queue (for inbound federation)
[queues.inbox] [queues.inbox]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_on_complete = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_on_failure = 31536000
# Control the fetch queue (for remote data refreshes) # Controls the fetch queue (for remote data refreshes)
[queues.fetch] [queues.fetch]
# Time in seconds to remove completed jobs # Time in seconds to remove completed jobs
remove_on_complete = 31536000 remove_on_complete = 31536000
# Time in seconds to remove failed jobs # Time in seconds to remove failed jobs
remove_on_failure = 31536000 remove_on_failure = 31536000
# Controls the push queue (for push notification delivery)
[queues.push]
# Time in seconds to remove completed jobs
remove_on_complete = 31536000
# Time in seconds to remove failed jobs
remove_on_failure = 31536000
[federation] [federation]
# This is a list of domain names, such as "mastodon.social" or "pleroma.site" # This is a list of domain names, such as "mastodon.social" or "pleroma.site"
# These changes will not retroactively apply to existing data before they were changed # These changes will not retroactively apply to existing data before they were changed

View file

@ -3126,6 +3126,49 @@
} }
} }
}, },
"notifications": {
"type": "object",
"properties": {
"push": {
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"vapid": {
"type": "object",
"properties": {
"public": {
"type": "string"
},
"private": {
"type": "string"
},
"subject": {
"type": "string"
}
},
"required": ["public", "private"],
"additionalProperties": false,
"default": {
"public": "",
"private": ""
}
}
},
"additionalProperties": false,
"default": {
"enabled": true,
"vapid": {
"public": "",
"private": ""
}
}
}
},
"additionalProperties": false
},
"defaults": { "defaults": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -3364,6 +3407,24 @@
"remove_on_complete": 31536000, "remove_on_complete": 31536000,
"remove_on_failure": 31536000 "remove_on_failure": 31536000
} }
},
"push": {
"type": "object",
"properties": {
"remove_on_complete": {
"type": "integer",
"default": 31536000
},
"remove_on_failure": {
"type": "integer",
"default": 31536000
}
},
"additionalProperties": false,
"default": {
"remove_on_complete": 31536000,
"remove_on_failure": 31536000
}
} }
}, },
"additionalProperties": false, "additionalProperties": false,
@ -3379,6 +3440,10 @@
"fetch": { "fetch": {
"remove_on_complete": 31536000, "remove_on_complete": 31536000,
"remove_on_failure": 31536000 "remove_on_failure": 31536000
},
"push": {
"remove_on_complete": 31536000,
"remove_on_failure": 31536000
} }
} }
}, },
@ -3482,6 +3547,9 @@
"emojis", "emojis",
"read:emoji", "read:emoji",
"owner:emoji", "owner:emoji",
"read:reaction",
"reactions",
"owner:reaction",
"media", "media",
"owner:media", "owner:media",
"blocks", "blocks",
@ -3501,6 +3569,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"ignore_rate_limits", "ignore_rate_limits",
@ -3522,6 +3591,8 @@
"owner:boost", "owner:boost",
"read:account", "read:account",
"owner:emoji", "owner:emoji",
"read:reaction",
"owner:reaction",
"read:emoji", "read:emoji",
"owner:media", "owner:media",
"owner:block", "owner:block",
@ -3533,6 +3604,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"oauth" "oauth"
@ -3559,6 +3631,9 @@
"emojis", "emojis",
"read:emoji", "read:emoji",
"owner:emoji", "owner:emoji",
"read:reaction",
"reactions",
"owner:reaction",
"media", "media",
"owner:media", "owner:media",
"blocks", "blocks",
@ -3578,6 +3653,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"ignore_rate_limits", "ignore_rate_limits",
@ -3599,6 +3675,8 @@
"owner:boost", "owner:boost",
"read:account", "read:account",
"owner:emoji", "owner:emoji",
"read:reaction",
"owner:reaction",
"read:emoji", "read:emoji",
"owner:media", "owner:media",
"owner:block", "owner:block",
@ -3610,6 +3688,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"oauth" "oauth"
@ -3636,6 +3715,9 @@
"emojis", "emojis",
"read:emoji", "read:emoji",
"owner:emoji", "owner:emoji",
"read:reaction",
"reactions",
"owner:reaction",
"media", "media",
"owner:media", "owner:media",
"blocks", "blocks",
@ -3655,6 +3737,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"ignore_rate_limits", "ignore_rate_limits",
@ -3676,6 +3759,8 @@
"owner:boost", "owner:boost",
"read:account", "read:account",
"owner:emoji", "owner:emoji",
"read:reaction",
"owner:reaction",
"read:emoji", "read:emoji",
"owner:media", "owner:media",
"owner:block", "owner:block",
@ -3687,6 +3772,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"oauth", "oauth",
@ -3695,6 +3781,7 @@
"likes", "likes",
"boosts", "boosts",
"emojis", "emojis",
"reactions",
"media", "media",
"blocks", "blocks",
"filters", "filters",
@ -3725,6 +3812,8 @@
"owner:boost", "owner:boost",
"read:account", "read:account",
"owner:emoji", "owner:emoji",
"read:reaction",
"owner:reaction",
"read:emoji", "read:emoji",
"owner:media", "owner:media",
"owner:block", "owner:block",
@ -3736,6 +3825,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"oauth" "oauth"
@ -3751,6 +3841,8 @@
"owner:boost", "owner:boost",
"read:account", "read:account",
"owner:emoji", "owner:emoji",
"read:reaction",
"owner:reaction",
"read:emoji", "read:emoji",
"owner:media", "owner:media",
"owner:block", "owner:block",
@ -3762,6 +3854,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"oauth" "oauth"
@ -3777,6 +3870,8 @@
"owner:boost", "owner:boost",
"read:account", "read:account",
"owner:emoji", "owner:emoji",
"read:reaction",
"owner:reaction",
"read:emoji", "read:emoji",
"owner:media", "owner:media",
"owner:block", "owner:block",
@ -3788,6 +3883,7 @@
"owner:follow", "owner:follow",
"owner:app", "owner:app",
"search", "search",
"push_notifications",
"public_timelines", "public_timelines",
"private_timelines", "private_timelines",
"oauth", "oauth",
@ -3796,6 +3892,7 @@
"likes", "likes",
"boosts", "boosts",
"emojis", "emojis",
"reactions",
"media", "media",
"blocks", "blocks",
"filters", "filters",
@ -4066,6 +4163,7 @@
"signups", "signups",
"http", "http",
"smtp", "smtp",
"notifications",
"filters", "filters",
"ratelimits" "ratelimits"
], ],

View file

@ -90,6 +90,7 @@
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/qs": "^6.9.17", "@types/qs": "^6.9.17",
"@types/web-push": "^3.6.4",
"drizzle-kit": "^0.30.1", "drizzle-kit": "^0.30.1",
"markdown-it-image-figures": "^2.1.1", "markdown-it-image-figures": "^2.1.1",
"markdown-it-mathjax3": "^4.3.2", "markdown-it-mathjax3": "^4.3.2",
@ -163,6 +164,7 @@
"table": "^6.9.0", "table": "^6.9.0",
"unzipit": "^1.4.3", "unzipit": "^1.4.3",
"uqr": "^0.1.2", "uqr": "^0.1.2",
"web-push": "^3.6.7",
"xss": "^1.0.15", "xss": "^1.0.15",
"zod": "^3.24.1", "zod": "^3.24.1",
"zod-validation-error": "^3.4.0" "zod-validation-error": "^3.4.0"

View file

@ -454,6 +454,33 @@ export const configValidator = z
key: "", key: "",
}, },
}), }),
notifications: z
.object({
push: z
.object({
enabled: z.boolean().default(true),
vapid: z
.object({
public: z.string(),
private: z.string(),
subject: z.string().optional(),
})
.strict()
.default({
public: "",
private: "",
}),
})
.strict()
.default({
enabled: true,
vapid: {
public: "",
private: "",
},
}),
})
.strict(),
defaults: z defaults: z
.object({ .object({
visibility: z.string().default("public"), visibility: z.string().default("public"),
@ -585,6 +612,24 @@ export const configValidator = z
remove_on_complete: 60 * 60 * 24 * 365, remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365, remove_on_failure: 60 * 60 * 24 * 365,
}), }),
push: z
.object({
remove_on_complete: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
remove_on_failure: z
.number()
.int()
// 1 year
.default(60 * 60 * 24 * 365),
})
.strict()
.default({
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
}),
}) })
.strict() .strict()
.default({ .default({
@ -600,6 +645,10 @@ export const configValidator = z
remove_on_complete: 60 * 60 * 24 * 365, remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365, remove_on_failure: 60 * 60 * 24 * 365,
}, },
push: {
remove_on_complete: 60 * 60 * 24 * 365,
remove_on_failure: 60 * 60 * 24 * 365,
},
}), }),
instance: z instance: z
.object({ .object({

View file

@ -1,6 +1,7 @@
import { getLogger } from "@logtape/logtape"; import { getLogger } from "@logtape/logtape";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import chalk from "chalk"; import chalk from "chalk";
import { generateVAPIDKeys } from "web-push";
import type { Config } from "~/packages/config-manager"; import type { Config } from "~/packages/config-manager";
export const checkConfig = async (config: Config): Promise<void> => { export const checkConfig = async (config: Config): Promise<void> => {
@ -9,6 +10,8 @@ export const checkConfig = async (config: Config): Promise<void> => {
await checkHttpProxyConfig(config); await checkHttpProxyConfig(config);
await checkChallengeConfig(config); await checkChallengeConfig(config);
await checkVapidConfig(config);
}; };
const checkHttpProxyConfig = async (config: Config): Promise<void> => { const checkHttpProxyConfig = async (config: Config): Promise<void> => {
@ -110,3 +113,40 @@ const checkFederationConfig = async (config: Config): Promise<void> => {
); );
} }
}; };
const checkVapidConfig = async (config: Config): Promise<void> => {
const logger = getLogger("server");
if (
config.notifications.push.enabled &&
!(
config.notifications.push.vapid.public ||
config.notifications.push.vapid.private
)
) {
logger.fatal`The VAPID keys are not set in the config, but push notifications are enabled.`;
logger.fatal`Below are generated keys for you to copy in the config at notifications.push.vapid`;
const { privateKey, publicKey } = await generateVAPIDKeys();
logger.fatal`Generated public key: ${chalk.gray(publicKey)}`;
logger.fatal`Generated private key: ${chalk.gray(privateKey)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
}
// These use a format I don't understand, so I'm just going to check the length
const validateKey = (key: string): boolean => key.length > 10;
if (
!(
validateKey(config.notifications.push.vapid.public) &&
validateKey(config.notifications.push.vapid.private)
)
) {
throw new Error(
"The VAPID keys could not be imported! You may generate new ones by removing the old ones from the config and restarting the server.",
);
}
};