diff --git a/apps/miiverse-api/src/models/community.ts b/apps/miiverse-api/src/models/community.ts index c06bbd1a..876873f7 100644 --- a/apps/miiverse-api/src/models/community.ts +++ b/apps/miiverse-api/src/models/community.ts @@ -1,6 +1,8 @@ +import crypto from 'node:crypto'; import { Schema, model } from 'mongoose'; +import { MongoError } from 'mongodb'; import type { CommunityData } from '@/types/miiverse/community'; -import type { ICommunity, ICommunityMethods, CommunityModel, ICommunityPermissions, HydratedCommunityDocument } from '@/types/mongoose/community'; +import type { ICommunity, ICommunityMethods, CommunityModel, ICommunityPermissions, HydratedCommunityDocument, ICommunityInput } from '@/types/mongoose/community'; const PermissionsSchema = new Schema({ open: { @@ -17,14 +19,28 @@ const PermissionsSchema = new Schema({ }, minimum_new_community_access_level: { type: Number, - default: 0 + default: 3 } }); +/* Constraints here (default, required etc.) apply to new documents being added + * See ICommunity for expected shape of query results + * If you add default: or required:, please also update ICommunity and ICommunityInput! + */ const CommunitySchema = new Schema({ - platform_id: Number, - name: String, - description: String, + platform_id: { + type: Number, + required: true + }, + name: { + type: String, + required: true + }, + description: { + type: String, + required: true + }, + open: { type: Boolean, default: true @@ -68,31 +84,48 @@ const CommunitySchema = new Schema('addUserFavorite', async function addUserFavorite(pid: number): Promise { - if (!this.user_favorites.includes(pid)) { + if (this.user_favorites === undefined) { + this.user_favorites = [pid]; + } else if (!this.user_favorites.includes(pid)) { this.user_favorites.push(pid); } @@ -100,7 +133,7 @@ CommunitySchema.method('addUserFavorite', async funct }); CommunitySchema.method('delUserFavorite', async function delUserFavorite(pid: number): Promise { - if (this.user_favorites.includes(pid)) { + if (this.user_favorites !== undefined && this.user_favorites.includes(pid)) { this.user_favorites.splice(this.user_favorites.indexOf(pid), 1); } @@ -114,10 +147,44 @@ CommunitySchema.method('json', function json(): Commu description: this.description, icon: this.icon.replace(/[^A-Za-z0-9+/=\s]/g, ''), icon_3ds: '', - pid: this.owner, + pid: this.owner ?? 0, app_data: this.app_data.replace(/[^A-Za-z0-9+/=\s]/g, ''), is_user_community: '0' }; }); export const Community = model('Community', CommunitySchema); + +export async function tryCreateCommunity(community: Omit, attempts: number = 0): Promise { + // If we're in too deep bail out + if (attempts > 10) { + throw new Error('Community creation failed - ID space exhausted?'); + } + + // Community ID is a random 32-bit int, but ensure larger than 524288. Values lower than this + // are reserved for game-specific hardcoding + const community_id = crypto.randomInt(0x80000, 0xFFFFFFFF).toString(); + // Olive community ID is random 64-bit, but some games (MK8?) seem to prefer if the top bit is set + const olive_community_id = (crypto.randomBytes(8).readBigUInt64BE(0) | (1n << 63n)).toString(); + + const document = { + community_id, + olive_community_id, + ...community + }; + + let hydrated: HydratedCommunityDocument; + try { + hydrated = await Community.create(document); + } catch (err) { + // Duplicate key + if (err instanceof MongoError && err.code == 11000) { + // Roll again + return tryCreateCommunity(community, attempts + 1); + } else { + throw err; + } + } + + return hydrated; +} diff --git a/apps/miiverse-api/src/services/api/routes/communities.ts b/apps/miiverse-api/src/services/api/routes/communities.ts index af40c7c5..d5ee8086 100644 --- a/apps/miiverse-api/src/services/api/routes/communities.ts +++ b/apps/miiverse-api/src/services/api/routes/communities.ts @@ -3,7 +3,7 @@ import xmlbuilder from 'xmlbuilder'; import multer from 'multer'; import { z } from 'zod'; import { Post } from '@/models/post'; -import { Community } from '@/models/community'; +import { Community, tryCreateCommunity } from '@/models/community'; import { getValueFromQueryString } from '@/util'; import { getMostPopularCommunities, @@ -298,9 +298,7 @@ router.post('/', multer().none(), async function (request: express.Request, resp return badRequest(response, ApiErrorCode.CREATE_TOO_MANY_FAVORITES, 403); } - const communitiesCount = await Community.countDocuments(); - const communityID = (parseInt(parentCommunity.community_id) + (5000 * communitiesCount)); // Change this to auto increment - const community = await Community.create({ + const community = await tryCreateCommunity({ platform_id: 0, // WiiU name: request.body.name, description: request.body.description || '', @@ -311,9 +309,7 @@ router.post('/', multer().none(), async function (request: express.Request, resp admins: parentCommunity.admins, owner: request.pid, icon: request.body.icon, - title_id: request.paramPack.title_id, - community_id: communityID.toString(), - olive_community_id: communityID.toString(), + title_id: [request.paramPack.title_id], app_data: request.body.app_data || '', user_favorites: [request.pid] }); diff --git a/apps/miiverse-api/src/types/mongoose/community.ts b/apps/miiverse-api/src/types/mongoose/community.ts index 23803645..8a64d989 100644 --- a/apps/miiverse-api/src/types/mongoose/community.ts +++ b/apps/miiverse-api/src/types/mongoose/community.ts @@ -1,4 +1,4 @@ -import type { Model, Types, HydratedDocument } from 'mongoose'; +import type { Model, HydratedDocument } from 'mongoose'; import type { CommunityData } from '@/types/miiverse/community'; enum COMMUNITY_TYPE { @@ -15,30 +15,53 @@ export interface ICommunityPermissions { minimum_new_community_access_level: number; } +/* This type needs to reflect "reality" as it is in the DB + * Thus, all the optionals, since some legacy documents are missing many fields + */ export interface ICommunity { - platform_id: number; + platform_id: number; // int name: string; description: string; - open: boolean; - allows_comments: boolean; + open?: boolean; + allows_comments?: boolean; type: COMMUNITY_TYPE; - parent: string; - admins: Types.Array; - owner: number; + parent?: string | null; // TFH community is undefined + admins?: number[]; + owner?: number; created_at: Date; empathy_count: number; followers: number; has_shop_page: number; icon: string; - title_ids: Types.Array; - title_id: Types.Array; + title_ids?: string[]; // Does not exist on any community + title_id: string[]; community_id: string; olive_community_id: string; is_recommended: number; app_data: string; - user_favorites: Types.Array; + user_favorites?: number[]; permissions: ICommunityPermissions; } +// Fields that have "default: " in the Mongoose schema should also be listed here to make them optional +// on input but not output +type CommunityDefaultedFields = + 'open' | + 'allows_comments' | + 'type' | + 'parent' | + 'admins' | + 'created_at' | + 'empathy_count' | + 'followers' | + 'has_shop_page' | + 'icon' | + 'title_ids' | + 'title_id' | + 'is_recommended' | + 'app_data' | + 'user_favorites' | + 'permissions'; +export type ICommunityInput = Omit & Partial>; export interface ICommunityMethods { addUserFavorite(pid: number): Promise;