mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat(api): ✨ Add initial Push Notifications support
This commit is contained in:
parent
acd2bcb469
commit
d096ab830c
|
|
@ -8,6 +8,7 @@ Versia Server `0.8.0` is fully backwards compatible with `0.7.0`.
|
|||
|
||||
- Outbound federation, inbox processing and data fetching are now handled by a queue system (like most federated software).
|
||||
- Added an administration UI for managing the queue.
|
||||
- Added [Push Notifications](https://docs.joinmastodon.org/methods/push) support.
|
||||
- Upgraded Bun to `1.1.42`.
|
||||
- Implemented support for the [**Instance Messaging Extension**](https://versia.pub/extensions/instance-messaging)
|
||||
- Implement [**Shared Inboxes**](https://versia.pub/federation#inboxes) support.
|
||||
|
|
|
|||
31
api/api/v1/push/subscription/index.delete.schema.ts
Normal file
31
api/api/v1/push/subscription/index.delete.schema.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const route = createRoute({
|
||||
method: "delete",
|
||||
path: "/api/v1/push/subscription",
|
||||
summary: "Remove current subscription",
|
||||
description: "Removes the current Web Push API subscription.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/push/#delete",
|
||||
},
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermissions.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"PushSubscription successfully deleted or did not exist previously.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
23
api/api/v1/push/subscription/index.delete.ts
Normal file
23
api/api/v1/push/subscription/index.delete.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { route } from "./index.delete.schema";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { token } = context.get("auth");
|
||||
|
||||
const ps = await PushSubscription.fromToken(token);
|
||||
|
||||
if (!ps) {
|
||||
throw new ApiError(
|
||||
404,
|
||||
"No push subscription associated with this access token",
|
||||
);
|
||||
}
|
||||
|
||||
await ps.delete();
|
||||
|
||||
return context.json({}, 200);
|
||||
}),
|
||||
);
|
||||
85
api/api/v1/push/subscription/index.get.schema.ts
Normal file
85
api/api/v1/push/subscription/index.get.schema.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import { auth } from "@/api";
|
||||
import { createRoute, z } from "@hono/zod-openapi";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
|
||||
export const WebPushSubscriptionInput = z
|
||||
.object({
|
||||
subscription: z.object({
|
||||
endpoint: z.string().url().openapi({
|
||||
example: "https://yourdomain.example/listener",
|
||||
description: "Where push alerts will be sent to.",
|
||||
}),
|
||||
keys: z
|
||||
.object({
|
||||
p256dh: z.string().base64().openapi({
|
||||
description:
|
||||
"User agent public key. Base64 encoded string of a public key from a ECDH keypair using the prime256v1 curve.",
|
||||
example:
|
||||
"MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEoKCJeHCy69ywHcb3dAR/T8Sud5ljSFHJkuiR6it1ycqAjGTe5F1oZ0ef5QiMX/zdQ+d4jSKiO7RztIz+o/eGuQ==",
|
||||
}),
|
||||
auth: z.string().base64().length(24).openapi({
|
||||
description:
|
||||
"Auth secret. Base64 encoded string of 16 bytes of random data.",
|
||||
example: "u67u09PXZW4ncK9l9mAXkA==",
|
||||
}),
|
||||
})
|
||||
.strict(),
|
||||
}),
|
||||
data: z
|
||||
.object({
|
||||
policy: z
|
||||
.enum(["all", "followed", "follower", "none"])
|
||||
.default("all")
|
||||
.openapi({
|
||||
description:
|
||||
"Specify whether to receive push notifications from all, followed, follower, or none users.",
|
||||
}),
|
||||
alerts: PushSubscription.schema.shape.alerts,
|
||||
})
|
||||
.strict()
|
||||
.default({
|
||||
policy: "all",
|
||||
alerts: {
|
||||
mention: false,
|
||||
favourite: false,
|
||||
reblog: false,
|
||||
follow: false,
|
||||
poll: false,
|
||||
follow_request: false,
|
||||
status: false,
|
||||
update: false,
|
||||
"admin.sign_up": false,
|
||||
"admin.report": false,
|
||||
},
|
||||
}),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const route = createRoute({
|
||||
method: "get",
|
||||
path: "/api/v1/push/subscription",
|
||||
summary: "Get current subscription",
|
||||
description:
|
||||
"View the PushSubscription currently associated with this access token.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/push/#get",
|
||||
},
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermissions.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
] as const,
|
||||
responses: {
|
||||
200: {
|
||||
description: "WebPushSubscription",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: PushSubscription.schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
21
api/api/v1/push/subscription/index.get.ts
Normal file
21
api/api/v1/push/subscription/index.get.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { route } from "./index.get.schema";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { token } = context.get("auth");
|
||||
|
||||
const ps = await PushSubscription.fromToken(token);
|
||||
|
||||
if (!ps) {
|
||||
throw new ApiError(
|
||||
404,
|
||||
"No push subscription associated with this access token",
|
||||
);
|
||||
}
|
||||
|
||||
return context.json(ps.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
44
api/api/v1/push/subscription/index.post.schema.ts
Normal file
44
api/api/v1/push/subscription/index.post.schema.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { auth, jsonOrForm } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { WebPushSubscriptionInput } from "./index.get.schema";
|
||||
|
||||
export const route = createRoute({
|
||||
method: "post",
|
||||
path: "/api/v1/push/subscription",
|
||||
summary: "Subscribe to push notifications",
|
||||
description:
|
||||
"Add a Web Push API subscription to receive notifications. Each access token can have one push subscription. If you create a new subscription, the old subscription is deleted.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/push/#create",
|
||||
},
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermissions.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: WebPushSubscriptionInput,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"A new PushSubscription has been generated, which will send the requested alerts to your endpoint.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: PushSubscription.schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
45
api/api/v1/push/subscription/index.post.ts
Normal file
45
api/api/v1/push/subscription/index.post.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { route } from "./index.post.schema";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user, token } = context.get("auth");
|
||||
const { subscription, data } = context.req.valid("json");
|
||||
|
||||
if (
|
||||
data.alerts["admin.report"] &&
|
||||
!user.hasPermission(RolePermissions.ManageReports)
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
`You do not have the '${RolePermissions.ManageReports}' permission to receive report alerts`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
data.alerts["admin.sign_up"] &&
|
||||
!user.hasPermission(RolePermissions.ManageAccounts)
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
`You do not have the '${RolePermissions.ManageAccounts}' permission to receive sign-up alerts`,
|
||||
);
|
||||
}
|
||||
|
||||
await PushSubscription.clearAllOfToken(token);
|
||||
|
||||
const ps = await PushSubscription.insert({
|
||||
alerts: data.alerts,
|
||||
policy: data.policy,
|
||||
endpoint: subscription.endpoint,
|
||||
publicKey: subscription.keys.p256dh,
|
||||
authSecret: subscription.keys.auth,
|
||||
tokenId: token.id,
|
||||
});
|
||||
|
||||
return context.json(ps.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
43
api/api/v1/push/subscription/index.put.schema.ts
Normal file
43
api/api/v1/push/subscription/index.put.schema.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { auth, jsonOrForm } from "@/api";
|
||||
import { createRoute } from "@hono/zod-openapi";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { WebPushSubscriptionInput } from "./index.get.schema";
|
||||
|
||||
export const route = createRoute({
|
||||
method: "put",
|
||||
path: "/api/v1/push/subscription",
|
||||
summary: "Change types of notifications",
|
||||
description:
|
||||
"Updates the current push subscription. Only the data part can be updated. To change fundamentals, a new subscription must be created instead.",
|
||||
externalDocs: {
|
||||
url: "https://docs.joinmastodon.org/methods/push/#update",
|
||||
},
|
||||
middleware: [
|
||||
auth({
|
||||
auth: true,
|
||||
permissions: [RolePermissions.UsePushNotifications],
|
||||
scopes: ["push"],
|
||||
}),
|
||||
jsonOrForm(),
|
||||
] as const,
|
||||
request: {
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: WebPushSubscriptionInput.shape.data,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "The WebPushSubscription has been updated.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: PushSubscription.schema,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
51
api/api/v1/push/subscription/index.put.ts
Normal file
51
api/api/v1/push/subscription/index.put.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { apiRoute } from "@/api";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { ApiError } from "~/classes/errors/api-error";
|
||||
import { RolePermissions } from "~/drizzle/schema";
|
||||
import { route } from "./index.put.schema";
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.openapi(route, async (context) => {
|
||||
const { user, token } = context.get("auth");
|
||||
const { alerts, policy } = context.req.valid("json");
|
||||
|
||||
const ps = await PushSubscription.fromToken(token);
|
||||
|
||||
if (!ps) {
|
||||
throw new ApiError(
|
||||
404,
|
||||
"No push subscription associated with this access token",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
alerts["admin.report"] &&
|
||||
!user.hasPermission(RolePermissions.ManageReports)
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
`You do not have the '${RolePermissions.ManageReports}' permission to receive report alerts`,
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
alerts["admin.sign_up"] &&
|
||||
!user.hasPermission(RolePermissions.ManageAccounts)
|
||||
) {
|
||||
throw new ApiError(
|
||||
403,
|
||||
`You do not have the '${RolePermissions.ManageAccounts}' permission to receive sign-up alerts`,
|
||||
);
|
||||
}
|
||||
|
||||
await ps.update({
|
||||
policy,
|
||||
alerts: {
|
||||
...ps.data.alerts,
|
||||
...alerts,
|
||||
},
|
||||
});
|
||||
|
||||
return context.json(ps.toApi(), 200);
|
||||
}),
|
||||
);
|
||||
304
api/api/v1/push/subscription/index.test.ts
Normal file
304
api/api/v1/push/subscription/index.test.ts
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import { afterAll, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { PushSubscription } from "@versia/kit/db";
|
||||
import { fakeRequest, getTestUsers } from "~/tests/utils";
|
||||
|
||||
const { users, tokens, deleteUsers } = await getTestUsers(2);
|
||||
|
||||
afterAll(async () => {
|
||||
await deleteUsers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await PushSubscription.clearAllOfToken(tokens[0]);
|
||||
await PushSubscription.clearAllOfToken(tokens[1]);
|
||||
});
|
||||
|
||||
describe("/api/v1/push/subscriptions", () => {
|
||||
test("should create a push subscription", async () => {
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
alerts: {
|
||||
update: true,
|
||||
},
|
||||
policy: "all",
|
||||
},
|
||||
subscription: {
|
||||
endpoint: "https://example.com",
|
||||
keys: {
|
||||
p256dh: "test",
|
||||
auth: "testthatis24charactersha",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
endpoint: "https://example.com",
|
||||
alerts: {
|
||||
update: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should retrieve the same push subscription", async () => {
|
||||
await PushSubscription.insert({
|
||||
endpoint: "https://example.com",
|
||||
alerts: {
|
||||
update: true,
|
||||
"admin.report": false,
|
||||
"admin.sign_up": false,
|
||||
favourite: false,
|
||||
follow: false,
|
||||
follow_request: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
reblog: false,
|
||||
status: false,
|
||||
},
|
||||
policy: "all",
|
||||
authSecret: "test",
|
||||
publicKey: "test",
|
||||
tokenId: tokens[1].id,
|
||||
});
|
||||
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[1].data.accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
endpoint: "https://example.com",
|
||||
alerts: {
|
||||
update: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("should update a push subscription", async () => {
|
||||
await PushSubscription.insert({
|
||||
endpoint: "https://example.com",
|
||||
alerts: {
|
||||
update: true,
|
||||
"admin.report": false,
|
||||
"admin.sign_up": false,
|
||||
favourite: false,
|
||||
follow: false,
|
||||
follow_request: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
reblog: false,
|
||||
status: false,
|
||||
},
|
||||
policy: "all",
|
||||
authSecret: "test",
|
||||
publicKey: "test",
|
||||
tokenId: tokens[0].id,
|
||||
});
|
||||
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
alerts: {
|
||||
update: false,
|
||||
favourite: true,
|
||||
},
|
||||
policy: "follower",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
alerts: {
|
||||
update: false,
|
||||
favourite: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions", () => {
|
||||
test("should not allow watching admin reports without permissions", async () => {
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
alerts: {
|
||||
"admin.report": true,
|
||||
},
|
||||
policy: "all",
|
||||
},
|
||||
subscription: {
|
||||
endpoint: "https://example.com",
|
||||
keys: {
|
||||
p256dh: "test",
|
||||
auth: "testthatis24charactersha",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: expect.stringContaining("permission"),
|
||||
});
|
||||
});
|
||||
|
||||
test("should allow watching admin reports with permissions", async () => {
|
||||
await users[0].update({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
alerts: {
|
||||
"admin.report": true,
|
||||
},
|
||||
policy: "all",
|
||||
},
|
||||
subscription: {
|
||||
endpoint: "https://example.com",
|
||||
keys: {
|
||||
p256dh: "test",
|
||||
auth: "testthatis24charactersha",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
await users[0].update({
|
||||
isAdmin: false,
|
||||
});
|
||||
});
|
||||
|
||||
test("should not allow editing to add admin reports without permissions", async () => {
|
||||
await PushSubscription.insert({
|
||||
endpoint: "https://example.com",
|
||||
alerts: {
|
||||
update: true,
|
||||
"admin.report": false,
|
||||
"admin.sign_up": false,
|
||||
favourite: false,
|
||||
follow: false,
|
||||
follow_request: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
reblog: false,
|
||||
status: false,
|
||||
},
|
||||
policy: "all",
|
||||
authSecret: "test",
|
||||
publicKey: "test",
|
||||
tokenId: tokens[0].id,
|
||||
});
|
||||
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
alerts: {
|
||||
"admin.report": true,
|
||||
},
|
||||
policy: "all",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(await res.json()).toMatchObject({
|
||||
error: expect.stringContaining("permission"),
|
||||
});
|
||||
});
|
||||
|
||||
test("should allow editing to add admin reports with permissions", async () => {
|
||||
await users[0].update({
|
||||
isAdmin: true,
|
||||
});
|
||||
|
||||
await PushSubscription.insert({
|
||||
endpoint: "https://example.com",
|
||||
alerts: {
|
||||
update: true,
|
||||
"admin.report": false,
|
||||
"admin.sign_up": false,
|
||||
favourite: false,
|
||||
follow: false,
|
||||
follow_request: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
reblog: false,
|
||||
status: false,
|
||||
},
|
||||
policy: "all",
|
||||
authSecret: "test",
|
||||
publicKey: "test",
|
||||
tokenId: tokens[0].id,
|
||||
});
|
||||
|
||||
const res = await fakeRequest("/api/v1/push/subscription", {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokens[0].data.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
alerts: {
|
||||
"admin.report": true,
|
||||
},
|
||||
policy: "all",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toMatchObject({
|
||||
alerts: {
|
||||
update: true,
|
||||
"admin.report": true,
|
||||
"admin.sign_up": false,
|
||||
favourite: false,
|
||||
follow: false,
|
||||
follow_request: false,
|
||||
mention: false,
|
||||
poll: false,
|
||||
reblog: false,
|
||||
status: false,
|
||||
},
|
||||
});
|
||||
|
||||
await users[0].update({
|
||||
isAdmin: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
2
app.ts
2
app.ts
|
|
@ -110,7 +110,7 @@ export const appFactory = async (): Promise<OpenAPIHono<HonoEnv>> => {
|
|||
const route: ApiRouteExports = await import(path);
|
||||
|
||||
if (!route.default) {
|
||||
throw new Error(`Route ${path} does not have the correct exports.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
route.default(app);
|
||||
|
|
|
|||
256
classes/database/pushsubscription.ts
Normal file
256
classes/database/pushsubscription.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { z } from "@hono/zod-openapi";
|
||||
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 InferInsertModel,
|
||||
type InferSelectModel,
|
||||
type SQL,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
} from "drizzle-orm";
|
||||
import { BaseInterface } from "./base.ts";
|
||||
|
||||
type PushSubscriptionType = InferSelectModel<typeof PushSubscriptions>;
|
||||
|
||||
export class PushSubscription extends BaseInterface<
|
||||
typeof PushSubscriptions,
|
||||
PushSubscriptionType
|
||||
> {
|
||||
public static schema = z.object({
|
||||
id: z.string().uuid().openapi({
|
||||
example: "24eb1891-accc-43b4-b213-478e37d525b4",
|
||||
description: "The ID of the Web Push subscription in the database.",
|
||||
}),
|
||||
endpoint: z.string().url().openapi({
|
||||
example: "https://yourdomain.example/listener",
|
||||
description: "Where push alerts will be sent to.",
|
||||
}),
|
||||
alerts: z
|
||||
.object({
|
||||
mention: z.boolean().optional().openapi({
|
||||
example: true,
|
||||
description: "Receive mention notifications?",
|
||||
}),
|
||||
favourite: z.boolean().optional().openapi({
|
||||
example: true,
|
||||
description: "Receive favourite notifications?",
|
||||
}),
|
||||
reblog: z.boolean().optional().openapi({
|
||||
example: true,
|
||||
description: "Receive reblog notifications?",
|
||||
}),
|
||||
follow: z.boolean().optional().openapi({
|
||||
example: true,
|
||||
description: "Receive follow notifications?",
|
||||
}),
|
||||
poll: z.boolean().optional().openapi({
|
||||
example: false,
|
||||
description: "Receive poll notifications?",
|
||||
}),
|
||||
follow_request: z.boolean().optional().openapi({
|
||||
example: false,
|
||||
description: "Receive follow request notifications?",
|
||||
}),
|
||||
status: z.boolean().optional().openapi({
|
||||
example: false,
|
||||
description:
|
||||
"Receive new subscribed account notifications?",
|
||||
}),
|
||||
update: z.boolean().optional().openapi({
|
||||
example: false,
|
||||
description: "Receive status edited notifications?",
|
||||
}),
|
||||
"admin.sign_up": z.boolean().optional().openapi({
|
||||
example: false,
|
||||
description:
|
||||
"Receive new user signup notifications? Must have a role with the appropriate permissions.",
|
||||
}),
|
||||
"admin.report": z.boolean().optional().openapi({
|
||||
example: false,
|
||||
description:
|
||||
"Receive new report notifications? Must have a role with the appropriate permissions.",
|
||||
}),
|
||||
})
|
||||
.default({})
|
||||
.openapi({
|
||||
example: {
|
||||
mention: true,
|
||||
favourite: true,
|
||||
reblog: true,
|
||||
follow: true,
|
||||
poll: false,
|
||||
follow_request: false,
|
||||
status: false,
|
||||
update: false,
|
||||
"admin.sign_up": false,
|
||||
"admin.report": false,
|
||||
},
|
||||
description:
|
||||
"Which alerts should be delivered to the endpoint.",
|
||||
}),
|
||||
server_key: z.string().openapi({
|
||||
example:
|
||||
"BCk-QqERU0q-CfYZjcuB6lnyyOYfJ2AifKqfeGIm7Z-HiTU5T9eTG5GxVA0_OH5mMlI4UkkDTpaZwozy0TzdZ2M=",
|
||||
description: "The streaming server’s VAPID key.",
|
||||
}),
|
||||
});
|
||||
|
||||
public static $type: PushSubscriptionType;
|
||||
|
||||
public async reload(): Promise<void> {
|
||||
const reloaded = await PushSubscription.fromId(this.data.id);
|
||||
|
||||
if (!reloaded) {
|
||||
throw new Error("Failed to reload subscription");
|
||||
}
|
||||
|
||||
this.data = reloaded.data;
|
||||
}
|
||||
|
||||
public static async fromId(
|
||||
id: string | null,
|
||||
): Promise<PushSubscription | null> {
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await PushSubscription.fromSql(eq(PushSubscriptions.id, id));
|
||||
}
|
||||
|
||||
public static async fromIds(ids: string[]): Promise<PushSubscription[]> {
|
||||
return await PushSubscription.manyFromSql(
|
||||
inArray(PushSubscriptions.id, ids),
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromToken(
|
||||
token: Token,
|
||||
): Promise<PushSubscription | null> {
|
||||
return await PushSubscription.fromSql(
|
||||
eq(PushSubscriptions.tokenId, token.id),
|
||||
);
|
||||
}
|
||||
|
||||
public static async fromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
|
||||
): Promise<PushSubscription | null> {
|
||||
const found = await db.query.PushSubscriptions.findFirst({
|
||||
where: sql,
|
||||
orderBy,
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
return new PushSubscription(found);
|
||||
}
|
||||
|
||||
public static async manyFromSql(
|
||||
sql: SQL<unknown> | undefined,
|
||||
orderBy: SQL<unknown> | undefined = desc(PushSubscriptions.id),
|
||||
limit?: number,
|
||||
offset?: number,
|
||||
extra?: Parameters<typeof db.query.PushSubscriptions.findMany>[0],
|
||||
): Promise<PushSubscription[]> {
|
||||
const found = await db.query.PushSubscriptions.findMany({
|
||||
where: sql,
|
||||
orderBy,
|
||||
limit,
|
||||
offset,
|
||||
with: extra?.with,
|
||||
});
|
||||
|
||||
return found.map((s) => new PushSubscription(s));
|
||||
}
|
||||
|
||||
public async update(
|
||||
newSubscription: Partial<PushSubscriptionType>,
|
||||
): Promise<PushSubscriptionType> {
|
||||
await db
|
||||
.update(PushSubscriptions)
|
||||
.set(newSubscription)
|
||||
.where(eq(PushSubscriptions.id, this.id));
|
||||
|
||||
const updated = await PushSubscription.fromId(this.data.id);
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update subscription");
|
||||
}
|
||||
|
||||
this.data = updated.data;
|
||||
return updated.data;
|
||||
}
|
||||
|
||||
public save(): Promise<PushSubscriptionType> {
|
||||
return this.update(this.data);
|
||||
}
|
||||
|
||||
public static async clearAllOfToken(token: Token): Promise<void> {
|
||||
await db
|
||||
.delete(PushSubscriptions)
|
||||
.where(eq(PushSubscriptions.tokenId, token.id));
|
||||
}
|
||||
|
||||
public async delete(ids?: string[]): Promise<void> {
|
||||
if (Array.isArray(ids)) {
|
||||
await db
|
||||
.delete(PushSubscriptions)
|
||||
.where(inArray(PushSubscriptions.id, ids));
|
||||
} else {
|
||||
await db
|
||||
.delete(PushSubscriptions)
|
||||
.where(eq(PushSubscriptions.id, this.id));
|
||||
}
|
||||
}
|
||||
|
||||
public static async insert(
|
||||
data: InferInsertModel<typeof PushSubscriptions>,
|
||||
): Promise<PushSubscription> {
|
||||
const inserted = (
|
||||
await db.insert(PushSubscriptions).values(data).returning()
|
||||
)[0];
|
||||
|
||||
const subscription = await PushSubscription.fromId(inserted.id);
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error("Failed to insert subscription");
|
||||
}
|
||||
|
||||
return subscription;
|
||||
}
|
||||
|
||||
public get id(): string {
|
||||
return this.data.id;
|
||||
}
|
||||
|
||||
public getAlerts(): Alerts {
|
||||
return {
|
||||
mention: this.data.alerts.mention ?? false,
|
||||
favourite: this.data.alerts.favourite ?? false,
|
||||
reblog: this.data.alerts.reblog ?? false,
|
||||
follow: this.data.alerts.follow ?? false,
|
||||
poll: this.data.alerts.poll ?? false,
|
||||
follow_request: this.data.alerts.follow_request ?? false,
|
||||
status: this.data.alerts.status ?? false,
|
||||
update: this.data.alerts.update ?? false,
|
||||
"admin.sign_up": this.data.alerts["admin.sign_up"] ?? false,
|
||||
"admin.report": this.data.alerts["admin.report"] ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
public toApi(): ApiPushSubscription {
|
||||
return {
|
||||
id: this.data.id,
|
||||
alerts: this.getAlerts(),
|
||||
endpoint: this.data.endpoint,
|
||||
// FIXME: Add real key
|
||||
server_key: "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ ManageFollows: "follows",
|
|||
ManageOwnFollows: "owner:follow",
|
||||
ManageOwnApps: "owner:app",
|
||||
Search: "search",
|
||||
UsePushNotifications: "push_notifications",
|
||||
ViewPublicTimelines: "public_timelines",
|
||||
ViewPrimateTimelines: "private_timelines",
|
||||
IgnoreRateLimits: "ignore_rate_limits",
|
||||
|
|
|
|||
14
drizzle/migrations/0040_good_nocturne.sql
Normal file
14
drizzle/migrations/0040_good_nocturne.sql
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
CREATE TABLE "PushSubscriptions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT uuid_generate_v7() NOT NULL,
|
||||
"endpoint" text NOT NULL,
|
||||
"public_key" text NOT NULL,
|
||||
"auth_secret" text NOT NULL,
|
||||
"alerts" jsonb NOT NULL,
|
||||
"policy" text NOT NULL,
|
||||
"created_at" timestamp(3) DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp(3) DEFAULT now() NOT NULL,
|
||||
"tokenId" uuid NOT NULL,
|
||||
CONSTRAINT "PushSubscriptions_tokenId_unique" UNIQUE("tokenId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "PushSubscriptions" ADD CONSTRAINT "PushSubscriptions_tokenId_Tokens_id_fk" FOREIGN KEY ("tokenId") REFERENCES "public"."Tokens"("id") ON DELETE cascade ON UPDATE cascade;
|
||||
2323
drizzle/migrations/meta/0040_snapshot.json
Normal file
2323
drizzle/migrations/meta/0040_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -281,6 +281,13 @@
|
|||
"when": 1734555117380,
|
||||
"tag": "0039_special_serpent_society",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 40,
|
||||
"version": "7",
|
||||
"when": 1735776034097,
|
||||
"tag": "0040_good_nocturne",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -67,6 +67,49 @@ export const Emojis = pgTable("Emojis", {
|
|||
category: text("category"),
|
||||
});
|
||||
|
||||
export const PushSubscriptions = pgTable("PushSubscriptions", {
|
||||
id: id(),
|
||||
endpoint: text("endpoint").notNull(),
|
||||
publicKey: text("public_key").notNull(),
|
||||
authSecret: text("auth_secret").notNull(),
|
||||
alerts: jsonb("alerts").notNull().$type<
|
||||
Partial<{
|
||||
mention: boolean;
|
||||
favourite: boolean;
|
||||
reblog: boolean;
|
||||
follow: boolean;
|
||||
poll: boolean;
|
||||
follow_request: boolean;
|
||||
status: boolean;
|
||||
update: boolean;
|
||||
"admin.sign_up": boolean;
|
||||
"admin.report": boolean;
|
||||
}>
|
||||
>(),
|
||||
policy: text("policy")
|
||||
.notNull()
|
||||
.$type<"all" | "followed" | "follower" | "none">(),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
tokenId: uuid("tokenId")
|
||||
.references(() => Tokens.id, {
|
||||
onDelete: "cascade",
|
||||
onUpdate: "cascade",
|
||||
})
|
||||
.notNull()
|
||||
.unique(),
|
||||
});
|
||||
|
||||
export const PushSubscriptionsRelations = relations(
|
||||
PushSubscriptions,
|
||||
({ one }) => ({
|
||||
token: one(Tokens, {
|
||||
fields: [PushSubscriptions.tokenId],
|
||||
references: [Tokens.id],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
export const Reactions = pgTable("Reaction", {
|
||||
id: id(),
|
||||
uri: uri(),
|
||||
|
|
@ -548,6 +591,7 @@ export enum RolePermissions {
|
|||
ManageOwnFollows = "owner:follow",
|
||||
ManageOwnApps = "owner:app",
|
||||
Search = "search",
|
||||
UsePushNotifications = "push_notifications",
|
||||
ViewPublicTimelines = "public_timelines",
|
||||
ViewPrivateTimelines = "private_timelines",
|
||||
IgnoreRateLimits = "ignore_rate_limits",
|
||||
|
|
@ -583,6 +627,7 @@ export const DEFAULT_ROLES = [
|
|||
RolePermissions.ManageOwnFollows,
|
||||
RolePermissions.ManageOwnApps,
|
||||
RolePermissions.Search,
|
||||
RolePermissions.UsePushNotifications,
|
||||
RolePermissions.ViewPublicTimelines,
|
||||
RolePermissions.ViewPrivateTimelines,
|
||||
RolePermissions.OAuth,
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@
|
|||
"@oclif/core": "^4.2.0",
|
||||
"@sentry/bun": "^8.47.0",
|
||||
"@tufjs/canonical-json": "^2.0.0",
|
||||
"@versia/client": "^0.1.4",
|
||||
"@versia/client": "^0.1.5",
|
||||
"@versia/federation": "^0.1.4",
|
||||
"@versia/kit": "workspace:*",
|
||||
"altcha-lib": "^1.2.0",
|
||||
|
|
|
|||
|
|
@ -13,3 +13,4 @@ export { Like } from "~/classes/database/like.ts";
|
|||
export { Token } from "~/classes/database/token.ts";
|
||||
export { Notification } from "~/classes/database/notification.ts";
|
||||
export { Reaction } from "~/classes/database/reaction.ts";
|
||||
export { PushSubscription } from "~/classes/database/pushsubscription.ts";
|
||||
|
|
|
|||
|
|
@ -246,7 +246,10 @@ export const checkRouteNeedsChallenge = async (
|
|||
|
||||
type HonoEnvWithAuth = HonoEnv & {
|
||||
Variables: {
|
||||
auth: AuthData & { user: NonNullable<AuthData["user"]> };
|
||||
auth: AuthData & {
|
||||
user: NonNullable<AuthData["user"]>;
|
||||
token: NonNullable<AuthData["token"]>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue