diff --git a/assets/public/content/store-banners/main-store-banner.png b/assets/public/content/store-banners/main-store-banner.png
new file mode 100644
index 0000000000..e1a0a81134
Binary files /dev/null and b/assets/public/content/store-banners/main-store-banner.png differ
diff --git a/assets/public/content/store-banners/main-store-banner.xcf b/assets/public/content/store-banners/main-store-banner.xcf
new file mode 100644
index 0000000000..c22aa43a6c
Binary files /dev/null and b/assets/public/content/store-banners/main-store-banner.xcf differ
diff --git a/src/api/routes/collectibles-shop.ts b/src/api/routes/collectibles-shop.ts
index 756c80c56a..d09ae6347f 100644
--- a/src/api/routes/collectibles-shop.ts
+++ b/src/api/routes/collectibles-shop.ts
@@ -18,7 +18,8 @@
import { route } from "@spacebar/api";
import { Request, Response, Router } from "express";
-import { CollectiblesShopResponse } from "@spacebar/schemas";
+import { Config } from "@spacebar/util";
+import { CollectiblesCategoryItem, CollectiblesShopResponse, ItemRowShopBlock } from "@spacebar/schemas";
const router = Router({ mergeParams: true });
@@ -33,10 +34,34 @@ router.get(
},
}),
(req: Request, res: Response) => {
- res.send({
- shop_blocks: [],
- categories: [],
- } as CollectiblesShopResponse);
+ const { endpointPublic: publicCdnEndpoint } = Config.get().cdn;
+ res.send({ shop_blocks: [], categories: [] });
+ // res.send({
+ // shop_blocks: [
+ // {
+ // type: 0,
+ // banner_asset: {
+ // animated: null,
+ // static: `${publicCdnEndpoint}/content/store/banners/main-store-banner.png`,
+ // },
+ // summary: "Welcome! Don't go alone, take this! :)",
+ // category_sku_id: "spacebarshop",
+ // name: "Spacebar",
+ // category_store_listing_id: "a",
+ // logo_url: "",
+ // unpublished_at: null,
+ // ranked_sku_ids: [],
+ // },
+ // ],
+ // categories: [
+ // {
+ // sku_id: "spacebarshop",
+ // name: "Spacebar shop category",
+ // summary: "Spacebar shop category items",
+ //
+ // }
+ // ],
+ // } as CollectiblesShopResponse);
},
);
diff --git a/src/cdn/routes/avatar-decoration-presets.ts b/src/cdn/routes/avatar-decoration-presets.ts
new file mode 100644
index 0000000000..62e39c932d
--- /dev/null
+++ b/src/cdn/routes/avatar-decoration-presets.ts
@@ -0,0 +1,54 @@
+/*
+ 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 { Router, Response, Request } from "express";
+import { Config, Snowflake } from "@spacebar/util";
+import { storage } from "../util/Storage";
+import FileType from "file-type";
+import { HTTPError } from "lambert-server";
+import crypto from "crypto";
+import { multer } from "../util/multer";
+
+const ANIMATED_MIME_TYPES = ["image/apng", "image/gif", "image/gifv"];
+const STATIC_MIME_TYPES = [
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/svg+xml",
+ "image/svg",
+];
+const ALLOWED_MIME_TYPES = [...ANIMATED_MIME_TYPES, ...STATIC_MIME_TYPES];
+
+const router = Router();
+
+router.get("/:asset_id", async (req: Request, res: Response) => {
+ let { asset_id } = req.params;
+ const path = `avatar-decoration-presets/${asset_id}`;
+
+ const file = await storage.get(path);
+ if (!file) throw new HTTPError("not found", 404);
+ const type = await FileType.fromBuffer(file);
+
+ res.set("Content-Type", type?.mime);
+ res.set("Cache-Control", "public, max-age=31536000");
+
+ return res.send(file);
+});
+
+
+export default router;
diff --git a/src/schemas/responses/CollectiblesShopResponse.ts b/src/schemas/responses/CollectiblesShopResponse.ts
index b739fe0cf6..fadc6e48ff 100644
--- a/src/schemas/responses/CollectiblesShopResponse.ts
+++ b/src/schemas/responses/CollectiblesShopResponse.ts
@@ -25,12 +25,12 @@ export interface CollectiblesShopResponse {
export type AnyShopBlock = ItemRowShopBlock | BundleTileRowShopBlock | ItemCollectionShopBlock;
-export interface BaseShopBlock {
+export class BaseShopBlock {
type: number;
}
-export interface ItemRowShopBlock extends BaseShopBlock {
- type: 0;
+export class ItemRowShopBlock extends BaseShopBlock {
+ declare type: 0;
category_sku_id: string;
name: string;
category_store_listing_id: string;
diff --git a/src/util/entities/Channel.ts b/src/util/entities/Channel.ts
index 8ec801c005..20a35b951d 100644
--- a/src/util/entities/Channel.ts
+++ b/src/util/entities/Channel.ts
@@ -20,7 +20,17 @@ import { HTTPError } from "lambert-server";
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, RelationId } from "typeorm";
import { DmChannelDTO } from "../dtos";
import { ChannelCreateEvent, ChannelRecipientRemoveEvent } from "../interfaces";
-import { InvisibleCharacters, Snowflake, emitEvent, getPermission, trimSpecial, Permissions, BitField } from "../util";
+import {
+ InvisibleCharacters,
+ Snowflake,
+ containsAll,
+ emitEvent,
+ getPermission,
+ trimSpecial,
+ DiscordApiErrors,
+ Permissions,
+ BitField
+} from "../util";
import { BaseClass } from "./BaseClass";
import { Guild } from "./Guild";
import { Invite } from "./Invite";
@@ -263,7 +273,7 @@ export class Channel extends BaseClass {
if (otherRecipientsUsers.length !== recipients.length) {
throw new HTTPError("Recipient/s not found");
}
- **/
+ **/
const type = recipients.length > 1 ? ChannelType.GROUP_DM : ChannelType.DM;
@@ -377,7 +387,6 @@ export class Channel extends BaseClass {
static async deleteChannel(channel: Channel) {
// TODO Delete attachments from the CDN for messages in the channel
- await Channel.delete({ id: channel.id });
if (channel.guild_id) {
const guild = await Guild.findOneOrFail({
@@ -385,9 +394,42 @@ export class Channel extends BaseClass {
select: { channel_ordering: true },
});
- const updatedOrdering = guild.channel_ordering.filter((id) => id != channel.id);
- await Guild.update({ id: channel.guild_id }, { channel_ordering: updatedOrdering });
- }
+ if (guild.features.includes("COMMUNITY")) {
+ if (
+ [
+ guild.afk_channel_id,
+ guild.system_channel_id,
+ guild.rules_channel_id,
+ guild.public_updates_channel_id,
+ ].includes(channel.id)
+ ) {
+ throw DiscordApiErrors.CANNOT_DELETE_COMMUNITY_REQUIRED_CHANNEL;
+ }
+ }
+ else {
+ if (guild.afk_channel_id === channel.id) {
+ guild.afk_channel_id = null;
+ }
+ if (guild.system_channel_id === channel.id) {
+ guild.system_channel_id = null;
+ }
+ if (guild.rules_channel_id === channel.id) {
+ guild.rules_channel_id = null;
+ }
+ if (guild.public_updates_channel_id === channel.id) {
+ guild.public_updates_channel_id = null;
+ }
+ }
+
+ await Channel.delete({ id: channel.id });
+
+ const updatedOrdering = guild.channel_ordering.filter(
+ (id) => id != channel.id,
+ );
+ await Guild.update(
+ { id: channel.guild_id },
+ { channel_ordering: updatedOrdering },
+ );
}
static async calculatePosition(channel_id: string, guild_id: string, guild?: Guild) {
diff --git a/src/util/entities/UserSettingsProtos.ts b/src/util/entities/UserSettingsProtos.ts
index c08b98ff6f..42368b4a40 100644
--- a/src/util/entities/UserSettingsProtos.ts
+++ b/src/util/entities/UserSettingsProtos.ts
@@ -19,7 +19,22 @@
import { Column, Entity, JoinColumn, OneToOne } from "typeorm";
import { BaseClassWithoutId, PrimaryIdColumn } from "./BaseClass";
import { User } from "./User";
-import { FrecencyUserSettings, PreloadedUserSettings } from "discord-protos";
+import {
+ FrecencyUserSettings,
+ PreloadedUserSettings,
+ PreloadedUserSettings_AppearanceSettings,
+ PreloadedUserSettings_CustomStatus,
+ PreloadedUserSettings_LaunchPadMode,
+ PreloadedUserSettings_PrivacySettings,
+ PreloadedUserSettings_StatusSettings,
+ PreloadedUserSettings_SwipeRightToLeftMode,
+ PreloadedUserSettings_TextAndImagesSettings,
+ PreloadedUserSettings_Theme,
+ PreloadedUserSettings_TimestampHourCycle,
+ PreloadedUserSettings_UIDensity,
+ PreloadedUserSettings_VoiceAndVideoSettings,
+} from "discord-protos";
+import { BoolValue, UInt32Value } from "discord-protos/dist/discord_protos/google/protobuf/wrappers";
@Entity({
name: "user_settings_protos",
@@ -45,40 +60,13 @@ export class UserSettingsProtos extends BaseClassWithoutId {
// @Column({nullable: true, type: "simple-json"})
// testSettings: {};
- bigintReplacer(_key: string, value: unknown): unknown {
- 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: unknown): unknown {
- 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 as string).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));
+ return PreloadedUserSettings.fromJsonString(this._userSettings);
}
set userSettings(value: PreloadedUserSettings | undefined) {
if (value) {
- // this._userSettings = JSON.stringify(value, this.bigintReplacer);
this._userSettings = PreloadedUserSettings.toJsonString(value);
} else {
this._userSettings = undefined;
@@ -87,37 +75,48 @@ export class UserSettingsProtos extends BaseClassWithoutId {
get frecencySettings(): FrecencyUserSettings | undefined {
if (!this._frecencySettings) return undefined;
- return FrecencyUserSettings.fromJson(JSON.parse(this._frecencySettings, this.bigintReviver));
+ return FrecencyUserSettings.fromJsonString(this._frecencySettings);
}
set frecencySettings(value: FrecencyUserSettings | undefined) {
if (value) {
- this._frecencySettings = JSON.stringify(value, this.bigintReplacer);
+ this._frecencySettings = FrecencyUserSettings.toJsonString(value);
} else {
this._frecencySettings = undefined;
}
}
- static async getOrDefault(user_id: string, save: boolean = false): Promise {
- const user = await User.findOneOrFail({
- where: { id: user_id },
- select: { settings: true },
- });
+ static async getOrCreate(user_id: string, save: boolean = false): Promise {
+ if (!(await User.existsBy({ id: user_id }))) throw new Error(`User with ID ${user_id} does not exist.`);
let userSettings = await UserSettingsProtos.findOne({
where: { user_id },
});
let modified = false;
+ let isNewSettings = false;
if (!userSettings) {
userSettings = UserSettingsProtos.create({
user_id,
});
modified = true;
+ isNewSettings = 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,
+ },
versions: {
dataVersion: 0,
clientVersion: 0,
@@ -138,8 +137,73 @@ export class UserSettingsProtos extends BaseClassWithoutId {
modified = true;
}
+ if (isNewSettings) userSettings = await this.importLegacySettings(user_id, userSettings);
+
if (modified && save) userSettings = await userSettings.save();
return userSettings;
}
+
+ static async importLegacySettings(user_id: string, settings: UserSettingsProtos): Promise {
+ const user = await User.findOneOrFail({
+ where: { id: user_id },
+ select: { settings: true },
+ });
+ if (!user) throw new Error(`User with ID ${user_id} does not exist.`);
+
+ const legacySettings = user.settings;
+ const { frecencySettings, userSettings } = settings;
+
+ if (userSettings === undefined) {
+ throw new Error("UserSettingsProtos.userSettings is undefined, this should not happen.");
+ }
+ if (frecencySettings === undefined) {
+ throw new Error("UserSettingsProtos.frecencySettings is undefined, this should not happen.");
+ }
+
+ if (legacySettings) {
+ if (legacySettings.afk_timeout !== null && legacySettings.afk_timeout !== undefined) {
+ userSettings.voiceAndVideo ??= PreloadedUserSettings_VoiceAndVideoSettings.create();
+ userSettings.voiceAndVideo.afkTimeout = UInt32Value.fromJson(legacySettings.afk_timeout);
+ }
+
+ if (legacySettings.allow_accessibility_detection !== null && legacySettings.allow_accessibility_detection !== undefined) {
+ userSettings.privacy ??= PreloadedUserSettings_PrivacySettings.create();
+ userSettings.privacy.allowAccessibilityDetection = legacySettings.allow_accessibility_detection;
+ }
+
+ if (legacySettings.animate_emoji !== null && legacySettings.animate_emoji !== undefined) {
+ userSettings.textAndImages ??= PreloadedUserSettings_TextAndImagesSettings.create();
+ userSettings.textAndImages.animateEmoji = BoolValue.fromJson(legacySettings.animate_emoji);
+ }
+
+ if (legacySettings.animate_stickers !== null && legacySettings.animate_stickers !== undefined) {
+ userSettings.textAndImages ??= PreloadedUserSettings_TextAndImagesSettings.create();
+ userSettings.textAndImages.animateStickers = UInt32Value.fromJson(legacySettings.animate_stickers);
+ }
+
+ if (legacySettings.contact_sync_enabled !== null && legacySettings.contact_sync_enabled !== undefined) {
+ userSettings.privacy ??= PreloadedUserSettings_PrivacySettings.create();
+ userSettings.privacy.contactSyncEnabled = BoolValue.fromJson(legacySettings.contact_sync_enabled);
+ }
+
+ if (legacySettings.convert_emoticons !== null && legacySettings.convert_emoticons !== undefined) {
+ userSettings.textAndImages ??= PreloadedUserSettings_TextAndImagesSettings.create();
+ userSettings.textAndImages.convertEmoticons = BoolValue.fromJson(legacySettings.convert_emoticons);
+ }
+
+ if (legacySettings.custom_status !== null && legacySettings.custom_status !== undefined) {
+ userSettings.status ??= PreloadedUserSettings_StatusSettings.create();
+ userSettings.status.customStatus = PreloadedUserSettings_CustomStatus.create({
+ emojiId: legacySettings.custom_status.emoji_id === undefined ? undefined : (BigInt(legacySettings.custom_status.emoji_id) as bigint),
+ emojiName: legacySettings.custom_status.emoji_name,
+ expiresAtMs: legacySettings.custom_status.expires_at === undefined ? undefined : (BigInt(legacySettings.custom_status.expires_at) as bigint),
+ text: legacySettings.custom_status.text,
+ createdAtMs: BigInt(Date.now()) as bigint,
+ });
+ }
+ }
+
+ return settings;
+ }
}