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