From d2370440408751913bc37df382c9b833929ff03f Mon Sep 17 00:00:00 2001 From: murdle Date: Sat, 6 Dec 2025 18:46:59 +0200 Subject: [PATCH] start adding webpush and change index api --- assets/public/index.html | 18 ++++----- package-lock.json | Bin 355586 -> 359295 bytes package.json | 2 + src/api/Server.ts | 6 ++- src/api/routes/users/@me/devices/index.ts | 0 src/util/config/Config.ts | 5 ++- src/util/config/types/WebPushConfiguration.ts | 24 ++++++++++++ src/util/config/types/index.ts | 1 + src/util/entities/PushSubscription.ts | 26 +++++++++++++ .../1764870551028-webPushSubscriptions.ts | 36 ++++++++++++++++++ src/util/util/WebPush.ts | 35 +++++++++++++++++ 11 files changed, 140 insertions(+), 13 deletions(-) create mode 100644 src/api/routes/users/@me/devices/index.ts create mode 100644 src/util/config/types/WebPushConfiguration.ts create mode 100644 src/util/entities/PushSubscription.ts create mode 100644 src/util/migration/postgres/1764870551028-webPushSubscriptions.ts create mode 100644 src/util/util/WebPush.ts diff --git a/assets/public/index.html b/assets/public/index.html index c4230e2a..96f86eb7 100644 --- a/assets/public/index.html +++ b/assets/public/index.html @@ -30,12 +30,12 @@ async function loadGlobalEnv() { try { - const res = await fetch("/api/v9/policies/instance/domains"); + const res = await fetch("/.well-known/spacebar/client"); const data = await res.json(); window.GLOBAL_ENV = { - API_ENDPOINT: protocolRelative(data.apiEndpoint), - WEBAPP_ENDPOINT: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), + API_ENDPOINT: protocolRelative(data.api.baseUrl) + "/api/v9", + WEBAPP_ENDPOINT: protocolRelative(data.api.baseUrl), CDN_HOST: protocolRelative(data.cdn), ASSET_ENDPOINT: protocolRelative(data.cdn), MEDIA_PROXY_ENDPOINT: protocolRelative(data.cdn), @@ -44,17 +44,17 @@ GUILD_TEMPLATE_HOST: 'discord.new', GIFT_CODE_HOST: 'discord.gift', RELEASE_CHANNEL: 'stable', - MARKETING_ENDPOINT: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), + MARKETING_ENDPOINT: protocolRelative(data.api.baseUrl), BRAINTREE_KEY: 'production_5st77rrc_49pp2rp4phym7387', STRIPE_KEY: 'pk_live_CUQtlpQUF0vufWpnpUmQvcdi', - NETWORKING_ENDPOINT: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), - RTC_LATENCY_ENDPOINT: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), - ACTIVITY_APPLICATION_HOST: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), + NETWORKING_ENDPOINT: protocolRelative(data.api.baseUrl), + RTC_LATENCY_ENDPOINT: protocolRelative(data.api.baseUrl), + ACTIVITY_APPLICATION_HOST: protocolRelative(data.api.baseUrl), PROJECT_ENV: 'production', REMOTE_AUTH_ENDPOINT: '//remote-auth-gateway.discord.gg', SENTRY_TAGS: { buildId: '9af39da', buildType: 'normal' }, - MIGRATION_SOURCE_ORIGIN: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), - MIGRATION_DESTINATION_ORIGIN: protocolRelative(data.apiEndpoint.replace(/\/api$/, '')), + MIGRATION_SOURCE_ORIGIN: protocolRelative(data.api.baseUrl), + MIGRATION_DESTINATION_ORIGIN: protocolRelative(data.api.baseUrl), HTML_TIMESTAMP: Date.now(), ALGOLIA_KEY: 'aca0d7082e4e63af5ba5917d5e96bed0', GATEWAY_URL: data.gateway diff --git a/package-lock.json b/package-lock.json index c1942619a8616c338f5a0145826850f2e396f6f5..044cfda2c73c7ea49c6a937714031aadff85d4d1 100644 GIT binary patch delta 1479 zcmZ`(ON`r86qP?w8CukWXxf39X(wf9g`3!koyGxGki_}e`8m!waYoQiVmoo1v6I+| zlZ=G=nMDyrNN7Y~)o!RL3pP**L{Jx?BC%n`Y(PR{!HO;2uq~dngGLp+h2PV?*Y}-! z?z!*kZ$}^fee_-qnty9B>8Un8QXBr>(AIpfpY&ZI1Lp$NR}2(Jk34PO>UySK4|MBi zbk_r7KOn~tHyrF}Fag%TgAN?q%Y^&?P3m_ewwHI7#k9#8Ws4#*q!2T8!X~LyVO8eZ zLCvV9D&ed&Y$XY!RyAu{Yc-b1ak#=|Vv>NH{=&`XM;@qHP8Py6u9hKR)?)Jr5S)Wf5a> zoRVw2ELZ59V#IT8iB8mD8n8IeuIV&Hca=<8Ko51IgcC%Z+2nNnV|3Nvvcn98Evy|)HmAp zpe_6Bwyj!&S#U5w1PI?*@ZmMo3%345eA9QA;M4Q4`M9WU|7X+-v(#=mv)xYxf*}xm z)vd;)nDUqoMJvLp5TE3Qw4#@y?Y>tgpp$a-&VRB{`0DxTqaTn!3Ri!z>Zv;^687*IL`6W8uW;OX56@&JJ^s$l09;suv)L*$L~2zvOU zDyuFSZ7}HV9zp#kv?6j=iY}LP6+?F9a6uW$QC8lVqmEQ;l;GS=wOmk`>hyb~Pae$4sM~lv*JvTd4E2ZVPH9;*=DEz`>O}$UGp9 zA>L~j?jzF+_tCBQp?!06?)!X?d8|EuA#-@l>^jsZ>7CcB*8# zvWi8VB&$12w4>^2anf_>G|x0?ic|Qi-O19Md4c85jcaKG%Z@F&*5M~H!e!A5^SSLi zpD(}mWFThB3kS$uE_*%mNA^a4 z_XpdOaqvh%{GjkWGzZ@6!rr+(KMwi_U~Kwj3|WNs8NZJsq3QY(YVS8b_dfCp`0E`6 z1&6~3?%rE>d+2__$+K?HG$BP$%|6HSbga?n^uvX0+EsePBDwKK3F~WX!=_ZR2*w%e z*-4aKOEjdle1^$U$>N}^lpBfaxY!ryd^tFfVqtC6Pe=@iEw~jeTt}|%1NYH&7stKh zC zbO%!hk;yoZ*`a6+tE8Du*AcqSl4=l5J0dzw3ac40Wg;lU*((A*`2cywOpI|8@FI|IQ3 jx4A$A|9+R;D+L#qaCZd+hyTC>xBtKdVv4t3Qw1R?1^*yK diff --git a/package.json b/package.json index 01a705db..79a2416c 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "dependencies": { "@aws-sdk/client-s3": "^3.936.0", "@toondepauw/node-zstd": "^1.2.0", + "@types/web-push": "^3.6.4", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "amqplib": "^0.10.9", @@ -113,6 +114,7 @@ "sqlite3": "^5.1.7", "tslib": "^2.8.1", "typeorm": "^0.3.27", + "web-push": "^3.6.7", "wretch": "^2.11.1", "ws": "^8.18.3" }, diff --git a/src/api/Server.ts b/src/api/Server.ts index f7c0ba0a..b580fc78 100644 --- a/src/api/Server.ts +++ b/src/api/Server.ts @@ -25,6 +25,7 @@ import path from "path"; import express from "express"; import { red } from "picocolors"; import { initInstance } from "./util/handlers/Instance"; +import { configurePush } from "../util/util/WebPush"; const ASSETS_FOLDER = path.join(__dirname, "..", "..", "assets"); const PUBLIC_ASSETS_FOLDER = path.join(ASSETS_FOLDER, "public"); @@ -57,6 +58,7 @@ export class SpacebarServer extends Server { await ConnectionConfig.init(); await initInstance(); WebAuthn.init(); + configurePush(); const logRequests = process.env["LOG_REQUESTS"] != undefined; if (logRequests) { @@ -117,8 +119,6 @@ export class SpacebarServer extends Server { res.sendFile(path.join(ASSETS_FOLDER, "openapi.json")); }); - app.use("*_", (req, res) => res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "index.html"))); - // current well-known location (new commit 22 nov 2025 from spacebar) app.get("/.well-known/spacebar", (req, res) => { res.json({ @@ -155,6 +155,8 @@ export class SpacebarServer extends Server { }); }); + app.use("*_", (req, res) => res.sendFile(path.join(PUBLIC_ASSETS_FOLDER, "index.html"))); + this.app.use(ErrorHandler); ConnectionLoader.loadConnections(); diff --git a/src/api/routes/users/@me/devices/index.ts b/src/api/routes/users/@me/devices/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/util/config/Config.ts b/src/util/config/Config.ts index 1458ab4b..45094ffc 100644 --- a/src/util/config/Config.ts +++ b/src/util/config/Config.ts @@ -37,6 +37,7 @@ import { SecurityConfiguration, TemplateConfiguration, UserConfiguration, + WebPushConfiguration, } from "../config"; export class ConfigValue { @@ -58,7 +59,7 @@ export class ConfigValue { defaults: DefaultsConfiguration = new DefaultsConfiguration(); external: ExternalTokensConfiguration = new ExternalTokensConfiguration(); email: EmailConfiguration = new EmailConfiguration(); - passwordReset: PasswordResetConfiguration = - new PasswordResetConfiguration(); + passwordReset: PasswordResetConfiguration = new PasswordResetConfiguration(); user: UserConfiguration = new UserConfiguration(); + webPush: WebPushConfiguration = new WebPushConfiguration(); } diff --git a/src/util/config/types/WebPushConfiguration.ts b/src/util/config/types/WebPushConfiguration.ts new file mode 100644 index 00000000..36dd2665 --- /dev/null +++ b/src/util/config/types/WebPushConfiguration.ts @@ -0,0 +1,24 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2023 Spacebar and Spacebar Contributors + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +*/ + +export class WebPushConfiguration { + enabled: boolean = false; + email: string | null = null; + publicVapidKey: string | null = null; + privateVapidKey: string | null = null; +} diff --git a/src/util/config/types/index.ts b/src/util/config/types/index.ts index cc671bfa..ea9f13c8 100644 --- a/src/util/config/types/index.ts +++ b/src/util/config/types/index.ts @@ -37,3 +37,4 @@ export * from "./SecurityConfiguration"; export * from "./subconfigurations"; export * from "./TemplateConfiguration"; export * from "./UsersConfiguration"; +export * from "./WebPushConfiguration"; diff --git a/src/util/entities/PushSubscription.ts b/src/util/entities/PushSubscription.ts new file mode 100644 index 00000000..905fa183 --- /dev/null +++ b/src/util/entities/PushSubscription.ts @@ -0,0 +1,26 @@ +import { BaseClass } from "./BaseClass"; +import { User } from "./User"; +import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn, RelationId } from "typeorm"; + +@Entity({ name: "push_subscriptions" }) +export class PushSubscription extends BaseClass { + @Column({ type: "varchar", nullable: true }) + @RelationId((sub: PushSubscription) => sub.user) + user_id: string; + + @JoinColumn({ name: "user_id" }) + @ManyToOne(() => User, { onDelete: "SET NULL" }) + user: User; + + @Column({ type: "varchar", nullable: false }) + endpoint: string; + + @Column({ type: "bigint", nullable: true }) + expiration_time?: number; + + @Column({ type: "varchar", nullable: false }) + auth: string; + + @Column({ type: "varchar", nullable: false }) + p256dh: string; +} diff --git a/src/util/migration/postgres/1764870551028-webPushSubscriptions.ts b/src/util/migration/postgres/1764870551028-webPushSubscriptions.ts new file mode 100644 index 00000000..423aa057 --- /dev/null +++ b/src/util/migration/postgres/1764870551028-webPushSubscriptions.ts @@ -0,0 +1,36 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class PushSubscriptions1764870551028 implements MigrationInterface { + name = "PushSubscriptions1764870551028"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE "push_subscriptions" ( + "id" character varying NOT NULL, + "user_id" character varying, + "endpoint" character varying NOT NULL, + "expiration_time" bigint, + "auth" character varying NOT NULL, + "p256dh" character varying NOT NULL, + CONSTRAINT "PK_push_subscriptions_id" PRIMARY KEY ("id"), + CONSTRAINT "UQ_push_subscriptions_endpoint" UNIQUE ("endpoint") + ); + `); + + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + ADD CONSTRAINT "FK_push_subscriptions_user" + FOREIGN KEY ("user_id") REFERENCES "users"("id") + ON DELETE SET NULL ON UPDATE NO ACTION; + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "push_subscriptions" + DROP CONSTRAINT "FK_push_subscriptions_user"; + `); + + await queryRunner.query(`DROP TABLE "push_subscriptions"`); + } +} diff --git a/src/util/util/WebPush.ts b/src/util/util/WebPush.ts new file mode 100644 index 00000000..1868c847 --- /dev/null +++ b/src/util/util/WebPush.ts @@ -0,0 +1,35 @@ +import { Config } from "@spacebar/util"; +import { yellow } from "picocolors"; +import { PushSubscription } from "../entities/PushSubscription"; +import webpush, { PushSubscription as WebPushSubscription } from "web-push"; + +let vapidConfigured = false; + +export function configurePush() { + const { enabled, email, publicVapidKey, privateVapidKey } = Config.get().webPush; + + if (!enabled) { + vapidConfigured = false; + return; + } + + if (!email || !publicVapidKey || !privateVapidKey) { + console.warn("[WebPush]", yellow("VAPID details are missing. Push notifications will be disabled.")); + vapidConfigured = false; + return; + } + + webpush.setVapidDetails(email, publicVapidKey, privateVapidKey); + vapidConfigured = true; +} + +export function parseSubscription(result: PushSubscription): WebPushSubscription { + return { + endpoint: result.endpoint, + expirationTime: result.expiration_time ?? null, + keys: { + p256dh: result.p256dh, + auth: result.auth, + }, + }; +}