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,
+ },
+ };
+}