diff --git a/assets/schemas.json b/assets/schemas.json index 4efe4d5c..84fc55ad 100755 Binary files a/assets/schemas.json and b/assets/schemas.json differ diff --git a/package-lock.json b/package-lock.json index af6d8af7..573811e6 100644 Binary files a/package-lock.json and b/package-lock.json differ diff --git a/package.json b/package.json index f862ba8a..0609112f 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.35.1", "@typescript-eslint/parser": "^8.35.1", + "discord-protos": "^1.2.43", "eslint": "^9.30.1", "express": "^4.21.2", "globals": "^15.15.0", diff --git a/scripts/schema.js b/scripts/schema.js index fd5b09f5..b39948e7 100644 --- a/scripts/schema.js +++ b/scripts/schema.js @@ -112,7 +112,29 @@ function main() { definitions = { ...definitions, [name]: { ...part } }; } + deleteOneOfKindUndefinedRecursive(definitions, "$"); + fs.writeFileSync(schemaPath, JSON.stringify(definitions, null, 4)); } +function deleteOneOfKindUndefinedRecursive(obj, path) { + if ( + obj?.type === "object" && + obj?.properties?.oneofKind?.type === "undefined" + ) + return true; + + for (const key in obj) { + if ( + typeof obj[key] === "object" && + deleteOneOfKindUndefinedRecursive(obj[key], path + "." + key) + ) { + console.log("Deleting", path, key); + delete obj[key]; + } + } + + return false; +} + main(); diff --git a/src/api/routes/users/@me/settings-proto/1.ts b/src/api/routes/users/@me/settings-proto/1.ts new file mode 100644 index 00000000..3ac64292 --- /dev/null +++ b/src/api/routes/users/@me/settings-proto/1.ts @@ -0,0 +1,187 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 . +*/ + +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { + OrmUtils, + SettingsProtoJsonResponse, + SettingsProtoResponse, + SettingsProtoUpdateJsonSchema, + SettingsProtoUpdateSchema, + UserSettingsProtos, +} from "@spacebar/util"; +import { PreloadedUserSettings } from "discord-protos"; + +const router: Router = Router(); + +//#region Protobuf +router.get( + "/", + route({ + responses: { + 200: { + body: "SettingsProtoResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: PreloadedUserSettings.toBase64( + userSettings.userSettings!, + ), + } as SettingsProtoResponse); + }, +); + +router.patch( + "/", + route({ + requestBody: "SettingsProtoUpdateSchema", + responses: { + 200: { + body: "SettingsProtoUpdateResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateSchema; + const { atomic } = req.query; + const updatedSettings = PreloadedUserSettings.fromBase64(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: PreloadedUserSettings.toBase64(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion +//#region JSON +router.get( + "/json", + route({ + responses: { + 200: { + body: "SettingsProtoJsonResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: PreloadedUserSettings.toJson(userSettings.userSettings!), + } as SettingsProtoJsonResponse); + }, +); + +router.patch( + "/json", + route({ + requestBody: "SettingsProtoUpdateJsonSchema", + responses: { + 200: { + body: "SettingsProtoUpdateJsonResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateJsonSchema; + const { atomic } = req.query; + const updatedSettings = PreloadedUserSettings.fromJson(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: PreloadedUserSettings.toJson(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion + +async function patchUserSettings( + userId: string, + updatedSettings: PreloadedUserSettings, + required_data_version: number | undefined, + atomic: boolean = false, +) { + const userSettings = await UserSettingsProtos.getOrCreate(userId); + let settings = userSettings.userSettings!; + + if ( + required_data_version && + settings.versions && + settings.versions.dataVersion > required_data_version + ) { + return { + settings: settings, + out_of_date: true, + }; + } + + console.log(`Updating user settings for user ${userId} with atomic=${atomic}:`, updatedSettings); + + if (!atomic) { + settings = Object.assign(settings, updatedSettings); + } else { + settings = OrmUtils.mergeDeep(settings, updatedSettings); + } + + userSettings.userSettings = settings; + await userSettings.save(); + + return { + settings: settings, + out_of_date: false, + }; +} + +export default router; diff --git a/src/api/routes/users/@me/settings-proto/2.ts b/src/api/routes/users/@me/settings-proto/2.ts new file mode 100644 index 00000000..e0cf786f --- /dev/null +++ b/src/api/routes/users/@me/settings-proto/2.ts @@ -0,0 +1,187 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 . +*/ + +import { route } from "@spacebar/api"; +import { Request, Response, Router } from "express"; +import { + OrmUtils, + SettingsProtoJsonResponse, + SettingsProtoResponse, + SettingsProtoUpdateJsonSchema, + SettingsProtoUpdateSchema, + UserSettingsProtos, +} from "@spacebar/util"; +import { FrecencyUserSettings } from "discord-protos"; + +const router: Router = Router(); + +//#region Protobuf +router.get( + "/", + route({ + responses: { + 200: { + body: "SettingsProtoResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: FrecencyUserSettings.toBase64( + userSettings.frecencySettings!, + ), + } as SettingsProtoResponse); + }, +); + +router.patch( + "/", + route({ + requestBody: "SettingsProtoUpdateSchema", + responses: { + 200: { + body: "SettingsProtoUpdateResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateSchema; + const { atomic } = req.query; + const updatedSettings = FrecencyUserSettings.fromBase64(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: FrecencyUserSettings.toBase64(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion +//#region JSON +router.get( + "/json", + route({ + responses: { + 200: { + body: "SettingsProtoJsonResponse", + }, + }, + }), + async (req: Request, res: Response) => { + const userSettings = await UserSettingsProtos.getOrCreate(req.user_id); + + res.json({ + settings: FrecencyUserSettings.toJson(userSettings.frecencySettings!), + } as SettingsProtoJsonResponse); + }, +); + +router.patch( + "/json", + route({ + requestBody: "SettingsProtoUpdateJsonSchema", + responses: { + 200: { + body: "SettingsProtoUpdateJsonResponse", + }, + }, + query: { + atomic: { + type: "boolean", + description: + "Whether to try to apply the settings update atomically (default false)", + }, + }, + }), + async (req: Request, res: Response) => { + const { settings, required_data_version } = + req.body as SettingsProtoUpdateJsonSchema; + const { atomic } = req.query; + const updatedSettings = FrecencyUserSettings.fromJson(settings); + + const resultObj = await patchUserSettings( + req.user_id, + updatedSettings, + required_data_version, + atomic == "true", + ); + + res.json({ + settings: FrecencyUserSettings.toJson(resultObj.settings), + out_of_date: resultObj.out_of_date, + }); + }, +); + +//#endregion + +async function patchUserSettings( + userId: string, + updatedSettings: FrecencyUserSettings, + required_data_version: number | undefined, + atomic: boolean = false, +) { + const userSettings = await UserSettingsProtos.getOrCreate(userId); + let settings = userSettings.frecencySettings!; + + if ( + required_data_version && + settings.versions && + settings.versions.dataVersion > required_data_version + ) { + return { + settings: settings, + out_of_date: true, + }; + } + + console.log(`Updating frecency settings for user ${userId} with atomic=${atomic}:`, updatedSettings); + + if (!atomic) { + settings = Object.assign(settings, updatedSettings); + } else { + settings = OrmUtils.mergeDeep(settings, updatedSettings); + } + + userSettings.frecencySettings = settings; + await userSettings.save(); + + return { + settings: settings, + out_of_date: false, + }; +} + +export default router; diff --git a/src/util/entities/UserSettingsProtos.ts b/src/util/entities/UserSettingsProtos.ts new file mode 100644 index 00000000..b7b8f607 --- /dev/null +++ b/src/util/entities/UserSettingsProtos.ts @@ -0,0 +1,170 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 . +*/ + +import { Column, Entity, JoinColumn, OneToOne, RelationId } from "typeorm"; +import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass"; +import { dbEngine } from "@spacebar/util"; +import { User } from "./User"; +import { + FrecencyUserSettings, + PreloadedUserSettings, + PreloadedUserSettings_LaunchPadMode, + PreloadedUserSettings_SwipeRightToLeftMode, + PreloadedUserSettings_Theme, + PreloadedUserSettings_TimestampHourCycle, + PreloadedUserSettings_UIDensity, +} from "discord-protos"; + +@Entity({ + name: "user_settings_protos", + engine: dbEngine, +}) +export class UserSettingsProtos extends BaseClassWithoutId { + @OneToOne(() => User, { + cascade: true, + orphanedRowAction: "delete", + eager: false, + }) + @JoinColumn({ name: "user_id" }) + user: User; + + @PrimaryIdColumn({ type: "text" }) + user_id: string; + + @Column({ nullable: true, type: String, name: "userSettings" }) + _userSettings: string | undefined; + + @Column({ nullable: true, type: String, name: "frecencySettings" }) + _frecencySettings: string | undefined; + + // @Column({nullable: true, type: "simple-json"}) + // testSettings: {}; + + bigintReplacer(_key: string, value: any): any { + if (typeof value === "bigint") { + return (value as bigint).toString(); + } else if (value instanceof Uint8Array) { + return { + __type: "Uint8Array", + data: Array.from(value as Uint8Array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""), + }; + } else { + return value; + } + } + + bigintReviver(_key: string, value: any): any { + if (typeof value === "string" && /^\d+n$/.test(value)) { + return BigInt((value as string).slice(0, -1)); + } else if ( + typeof value === "object" && + value !== null && + "__type" in value + ) { + if (value.__type === "Uint8Array" && "data" in value) { + return new Uint8Array( + value.data + .match(/.{1,2}/g)! + .map((byte: string) => parseInt(byte, 16)), + ); + } + } + return value; + } + + get userSettings(): PreloadedUserSettings | undefined { + if (!this._userSettings) return undefined; + return PreloadedUserSettings.fromJson( + JSON.parse(this._userSettings, this.bigintReviver), + ); + } + + set userSettings(value: PreloadedUserSettings | undefined) { + if (value) { + // this._userSettings = JSON.stringify(value, this.bigintReplacer); + this._userSettings = PreloadedUserSettings.toJsonString(value); + } else { + this._userSettings = undefined; + } + } + + get frecencySettings(): FrecencyUserSettings | undefined { + if (!this._frecencySettings) return undefined; + return FrecencyUserSettings.fromJson( + JSON.parse(this._frecencySettings, this.bigintReviver), + ); + } + + set frecencySettings(value: FrecencyUserSettings | undefined) { + if (value) { + this._frecencySettings = JSON.stringify(value, this.bigintReplacer); + } else { + this._frecencySettings = undefined; + } + } + + static async getOrCreate(user_id: string): Promise { + const user = await User.findOneOrFail({ + where: { id: user_id }, + select: { settings: true }, + }); + let userSettings = await UserSettingsProtos.findOne({ + where: { user_id }, + }); + let modified = false; + if (!userSettings) { + userSettings = UserSettingsProtos.create({ + user_id, + }); + modified = true; + } + + if (!userSettings.userSettings) { + userSettings.userSettings = PreloadedUserSettings.create({ + ads: { + alwaysDeliver: false, + }, + appearance: { + developerMode: user.settings?.developer_mode ?? true, + theme: PreloadedUserSettings_Theme.DARK, + mobileRedesignDisabled: true, + launchPadMode: + PreloadedUserSettings_LaunchPadMode.LAUNCH_PAD_DISABLED, + swipeRightToLeftMode: + PreloadedUserSettings_SwipeRightToLeftMode.SWIPE_RIGHT_TO_LEFT_REPLY, + timestampHourCycle: + PreloadedUserSettings_TimestampHourCycle.AUTO, + uiDensity: + PreloadedUserSettings_UIDensity.UI_DENSITY_COMPACT, + }, + }); + modified = true; + } + + if (!userSettings.frecencySettings) { + userSettings.frecencySettings = FrecencyUserSettings.create({}); + modified = true; + } + + if (modified) userSettings = await userSettings.save(); + + return userSettings; + } +} diff --git a/src/util/entities/index.ts b/src/util/entities/index.ts index dd967ce5..4c95e83a 100644 --- a/src/util/entities/index.ts +++ b/src/util/entities/index.ts @@ -55,6 +55,7 @@ export * from "./TeamMember"; export * from "./Template"; export * from "./User"; export * from "./UserSettings"; +export * from "./UserSettingsProtos"; export * from "./ValidRegistrationTokens"; export * from "./VoiceState"; export * from "./Webhook"; diff --git a/src/util/migration/postgres/1752157979333-UserSettingsProtos.ts b/src/util/migration/postgres/1752157979333-UserSettingsProtos.ts new file mode 100644 index 00000000..0633e144 --- /dev/null +++ b/src/util/migration/postgres/1752157979333-UserSettingsProtos.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserSettingsProtos1752157979333 implements MigrationInterface { + name = 'UserSettingsProtos1752157979333' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "user_settings_protos"`); + await queryRunner.query(`CREATE TABLE "user_settings_protos" ("user_id" character varying NOT NULL, "userSettings" text, "frecencySettings" text, CONSTRAINT "PK_8ff3d1961a48b693810c9f99853" PRIMARY KEY ("user_id"))`); + await queryRunner.query(`ALTER TABLE "user_settings_protos" ADD CONSTRAINT "FK_8ff3d1961a48b693810c9f99853" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "user_settings_protos" DROP CONSTRAINT "FK_8ff3d1961a48b693810c9f99853"`); + await queryRunner.query(`DROP TABLE "user_settings_protos"`); + } + +} diff --git a/src/util/schemas/SettingsProtoUpdateSchema.ts b/src/util/schemas/SettingsProtoUpdateSchema.ts new file mode 100644 index 00000000..2b930bc0 --- /dev/null +++ b/src/util/schemas/SettingsProtoUpdateSchema.ts @@ -0,0 +1,47 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 . +*/ + +import { JsonValue } from "@protobuf-ts/runtime"; + +export interface SettingsProtoUpdateSchema { + settings: string; + required_data_version?: number; +} + +export interface SettingsProtoUpdateJsonSchema { + settings: JsonValue; + required_data_version?: number; +} + +// TODO: these dont work with schema validation +// typed JSON schemas: +// export interface SettingsProtoUpdatePreloadedUserSettingsSchema { +// settings: PreloadedUserSettings; +// required_data_version?: number; +// } +// +// export interface SettingsProtoUpdateFrecencyUserSettingsSchema { +// settings: FrecencyUserSettings; +// required_data_version?: number; +// } + +// TODO: what is this? +// export interface SettingsProtoUpdateTestSettingsSchema { +// settings: {}; +// required_data_version?: number; +// } \ No newline at end of file diff --git a/src/util/schemas/index.ts b/src/util/schemas/index.ts index a7aaa1fa..4088c158 100644 --- a/src/util/schemas/index.ts +++ b/src/util/schemas/index.ts @@ -69,6 +69,7 @@ export * from "./responses"; export * from "./RoleModifySchema"; export * from "./RolePositionUpdateSchema"; export * from "./SelectProtocolSchema"; +export * from "./SettingsProtoUpdateSchema"; export * from "./StreamCreateSchema"; export * from "./StreamDeleteSchema"; export * from "./StreamWatchSchema"; diff --git a/src/util/schemas/responses/SettingsProtoUpdateResponse.ts b/src/util/schemas/responses/SettingsProtoUpdateResponse.ts new file mode 100644 index 00000000..42f8621a --- /dev/null +++ b/src/util/schemas/responses/SettingsProtoUpdateResponse.ts @@ -0,0 +1,53 @@ +/* + Spacebar: A FOSS re-implementation and extension of the Discord.com backend. + Copyright (C) 2025 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 . +*/ + +import { JsonValue } from "@protobuf-ts/runtime"; + +export interface SettingsProtoResponse { + settings: string; +} + +export interface SettingsProtoUpdateResponse extends SettingsProtoResponse { + out_of_date?: boolean; +} + +export interface SettingsProtoJsonResponse { + settings: JsonValue; +} + +export interface SettingsProtoUpdateJsonResponse extends SettingsProtoJsonResponse { + out_of_date?: boolean; +} + +// TODO: these dont work with schemas validation +// Typed JSON schemas: +// export interface SettingsProtoUpdatePreloadedUserSettingsJsonResponse { +// settings: PreloadedUserSettings; +// out_of_date?: boolean; +// } +// +// export interface SettingsProtoUpdateFrecencyUserSettingsJsonResponse { +// settings: FrecencyUserSettings; +// out_of_date?: boolean; +// } + +// TODO: what is this? +// export interface SettingsProtoUpdateTestSettingsJsonResponse { +// settings: {}; +// out_of_date?: boolean; +// } diff --git a/src/util/schemas/responses/index.ts b/src/util/schemas/responses/index.ts index 29537cf7..8949924b 100644 --- a/src/util/schemas/responses/index.ts +++ b/src/util/schemas/responses/index.ts @@ -47,6 +47,7 @@ export * from "./LocationMetadataResponse"; export * from "./MemberJoinGuildResponse"; export * from "./OAuthAuthorizeResponse"; export * from "./RefreshUrlsResponse"; +export * from "./SettingsProtoUpdateResponse"; export * from "./TeamListResponse"; export * from "./Tenor"; export * from "./TokenResponse";