diff --git a/.github/config.workflow.toml b/.github/config.workflow.toml index 267997f6..7d048662 100644 --- a/.github/config.workflow.toml +++ b/.github/config.workflow.toml @@ -189,6 +189,20 @@ expiration = 300 # 5 minutes # Leave this empty to generate a new key 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] # Default visibility for new notes visibility = "public" diff --git a/CHANGELOG.md b/CHANGELOG.md index ff23fd09..63202abf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,21 +19,49 @@ Versia Server `0.8.0` is fully backwards compatible with `0.7.0`. ## New Configuration Options ```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] -# Control the delivery queue (for outbound federation) +# Controls the delivery queue (for outbound federation) [queues.delivery] # Time in seconds to remove completed jobs remove_on_complete = 31536000 # Time in seconds to remove failed jobs remove_on_failure = 31536000 -# Control the inbox processing queue (for inbound federation) +# Controls the inbox processing queue (for inbound federation) [queues.inbox] # Time in seconds to remove completed jobs remove_on_complete = 31536000 # Time in seconds to remove failed jobs 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] max_emoji_size = 1000000 max_emoji_shortcode_size = 100 diff --git a/bun.lockb b/bun.lockb index e1af2de5..c75e1f57 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/classes/database/pushsubscription.ts b/classes/database/pushsubscription.ts index 107e8605..efdc3219 100644 --- a/classes/database/pushsubscription.ts +++ b/classes/database/pushsubscription.ts @@ -3,8 +3,8 @@ import type { Alerts, PushSubscription as ApiPushSubscription, } from "@versia/client/types"; -import { type Token, db } from "@versia/kit/db"; -import { PushSubscriptions } from "@versia/kit/tables"; +import { type Token, type User, db } from "@versia/kit/db"; +import { PushSubscriptions, Users } from "@versia/kit/tables"; import { type InferInsertModel, type InferSelectModel, @@ -136,6 +136,27 @@ export class PushSubscription extends BaseInterface< ); } + public static async manyFromUser( + user: User, + limit?: number, + offset?: number, + ): Promise { + 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( sql: SQL | undefined, orderBy: SQL | undefined = desc(PushSubscriptions.id), diff --git a/classes/database/user.ts b/classes/database/user.ts index 6bcac35f..882a4b72 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -21,7 +21,7 @@ import type { FollowReject as VersiaFollowReject, User as VersiaUser, } from "@versia/federation/types"; -import { Notification, db } from "@versia/kit/db"; +import { Notification, PushSubscription, db } from "@versia/kit/db"; import { EmojiToUser, Likes, @@ -54,6 +54,7 @@ import { searchManager } from "~/classes/search/search-manager"; import { type Config, config } from "~/packages/config-manager"; import type { KnownEntity } from "~/types/api.ts"; import { DeliveryJobType, deliveryQueue } from "../queues/delivery.ts"; +import { PushJobType, pushQueue } from "../queues/push.ts"; import { BaseInterface } from "./base.ts"; import { Emoji } from "./emoji.ts"; import { Instance } from "./instance.ts"; @@ -572,12 +573,40 @@ export class User extends BaseInterface { relatedUser: User, note?: Note, ): Promise { - await Notification.insert({ + const notification = await Notification.insert({ accountId: relatedUser.id, type, notifiedId: this.id, 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 { + // 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 { diff --git a/classes/queues/push.ts b/classes/queues/push.ts new file mode 100644 index 00000000..cd804c13 --- /dev/null +++ b/classes/queues/push.ts @@ -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("push", { + connection, +}); diff --git a/classes/workers/push.ts b/classes/workers/push.ts new file mode 100644 index 00000000..cc50721b --- /dev/null +++ b/classes/workers/push.ts @@ -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 => + new Worker( + 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, + }, + }, + ); diff --git a/config/config.example.toml b/config/config.example.toml index 3809d3f2..2e06a5a4 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -247,6 +247,20 @@ expiration = 300 # 5 minutes # Leave this empty to generate a new 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] # Default visibility for new notes # Can be public, unlisted, private or direct @@ -262,27 +276,34 @@ language = "en" placeholder_style = "thumbs" [queues] -# Control the delivery queue (for outbound federation) +# Controls the delivery queue (for outbound federation) [queues.delivery] # Time in seconds to remove completed jobs remove_on_complete = 31536000 # Time in seconds to remove failed jobs remove_on_failure = 31536000 -# Control the inbox processing queue (for inbound federation) +# Controls the inbox processing queue (for inbound federation) [queues.inbox] # Time in seconds to remove completed jobs remove_on_complete = 31536000 # Time in seconds to remove failed jobs remove_on_failure = 31536000 -# Control the fetch queue (for remote data refreshes) +# 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 + [federation] # 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 diff --git a/config/config.schema.json b/config/config.schema.json index 41683991..031508ac 100644 --- a/config/config.schema.json +++ b/config/config.schema.json @@ -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": { "type": "object", "properties": { @@ -3364,6 +3407,24 @@ "remove_on_complete": 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, @@ -3379,6 +3440,10 @@ "fetch": { "remove_on_complete": 31536000, "remove_on_failure": 31536000 + }, + "push": { + "remove_on_complete": 31536000, + "remove_on_failure": 31536000 } } }, @@ -3482,6 +3547,9 @@ "emojis", "read:emoji", "owner:emoji", + "read:reaction", + "reactions", + "owner:reaction", "media", "owner:media", "blocks", @@ -3501,6 +3569,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "ignore_rate_limits", @@ -3522,6 +3591,8 @@ "owner:boost", "read:account", "owner:emoji", + "read:reaction", + "owner:reaction", "read:emoji", "owner:media", "owner:block", @@ -3533,6 +3604,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "oauth" @@ -3559,6 +3631,9 @@ "emojis", "read:emoji", "owner:emoji", + "read:reaction", + "reactions", + "owner:reaction", "media", "owner:media", "blocks", @@ -3578,6 +3653,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "ignore_rate_limits", @@ -3599,6 +3675,8 @@ "owner:boost", "read:account", "owner:emoji", + "read:reaction", + "owner:reaction", "read:emoji", "owner:media", "owner:block", @@ -3610,6 +3688,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "oauth" @@ -3636,6 +3715,9 @@ "emojis", "read:emoji", "owner:emoji", + "read:reaction", + "reactions", + "owner:reaction", "media", "owner:media", "blocks", @@ -3655,6 +3737,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "ignore_rate_limits", @@ -3676,6 +3759,8 @@ "owner:boost", "read:account", "owner:emoji", + "read:reaction", + "owner:reaction", "read:emoji", "owner:media", "owner:block", @@ -3687,6 +3772,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "oauth", @@ -3695,6 +3781,7 @@ "likes", "boosts", "emojis", + "reactions", "media", "blocks", "filters", @@ -3725,6 +3812,8 @@ "owner:boost", "read:account", "owner:emoji", + "read:reaction", + "owner:reaction", "read:emoji", "owner:media", "owner:block", @@ -3736,6 +3825,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "oauth" @@ -3751,6 +3841,8 @@ "owner:boost", "read:account", "owner:emoji", + "read:reaction", + "owner:reaction", "read:emoji", "owner:media", "owner:block", @@ -3762,6 +3854,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "oauth" @@ -3777,6 +3870,8 @@ "owner:boost", "read:account", "owner:emoji", + "read:reaction", + "owner:reaction", "read:emoji", "owner:media", "owner:block", @@ -3788,6 +3883,7 @@ "owner:follow", "owner:app", "search", + "push_notifications", "public_timelines", "private_timelines", "oauth", @@ -3796,6 +3892,7 @@ "likes", "boosts", "emojis", + "reactions", "media", "blocks", "filters", @@ -4066,6 +4163,7 @@ "signups", "http", "smtp", + "notifications", "filters", "ratelimits" ], diff --git a/package.json b/package.json index c56be7e3..5479594f 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@types/mime-types": "^2.1.4", "@types/pg": "^8.11.10", "@types/qs": "^6.9.17", + "@types/web-push": "^3.6.4", "drizzle-kit": "^0.30.1", "markdown-it-image-figures": "^2.1.1", "markdown-it-mathjax3": "^4.3.2", @@ -163,6 +164,7 @@ "table": "^6.9.0", "unzipit": "^1.4.3", "uqr": "^0.1.2", + "web-push": "^3.6.7", "xss": "^1.0.15", "zod": "^3.24.1", "zod-validation-error": "^3.4.0" diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index cd8016ae..bd346c50 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -454,6 +454,33 @@ export const configValidator = z 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 .object({ visibility: z.string().default("public"), @@ -585,6 +612,24 @@ export const configValidator = z remove_on_complete: 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() .default({ @@ -600,6 +645,10 @@ export const configValidator = z remove_on_complete: 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 .object({ diff --git a/utils/init.ts b/utils/init.ts index ff7ffacb..a3cc2198 100644 --- a/utils/init.ts +++ b/utils/init.ts @@ -1,6 +1,7 @@ import { getLogger } from "@logtape/logtape"; import { User } from "@versia/kit/db"; import chalk from "chalk"; +import { generateVAPIDKeys } from "web-push"; import type { Config } from "~/packages/config-manager"; export const checkConfig = async (config: Config): Promise => { @@ -9,6 +10,8 @@ export const checkConfig = async (config: Config): Promise => { await checkHttpProxyConfig(config); await checkChallengeConfig(config); + + await checkVapidConfig(config); }; const checkHttpProxyConfig = async (config: Config): Promise => { @@ -110,3 +113,40 @@ const checkFederationConfig = async (config: Config): Promise => { ); } }; + +const checkVapidConfig = async (config: Config): Promise => { + 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.", + ); + } +};