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 c1942619..044cfda2 100644 Binary files a/package-lock.json and b/package-lock.json differ 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, + }, + }; +}