mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Finish push notification delivery
This commit is contained in:
parent
d096ab830c
commit
d839c274b1
14
.github/config.workflow.toml
vendored
14
.github/config.workflow.toml
vendored
|
|
@ -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"
|
||||||
|
|
|
||||||
32
CHANGELOG.md
32
CHANGELOG.md
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
18
classes/queues/push.ts
Normal 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
128
classes/workers/push.ts
Normal 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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue