Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 81 additions & 14 deletions apps/miiverse-api/src/models/community.ts
Original file line number Diff line number Diff line change
@@ -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<ICommunityPermissions>({
open: {
Expand All @@ -17,14 +19,28 @@ const PermissionsSchema = new Schema<ICommunityPermissions>({
},
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<ICommunity, CommunityModel, ICommunityMethods>({
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
Expand Down Expand Up @@ -68,39 +84,56 @@ const CommunitySchema = new Schema<ICommunity, CommunityModel, ICommunityMethods
type: Number,
default: 0
},
icon: String,
icon: {
type: String,
required: true
},
title_ids: {
type: [String],
default: undefined
},
title_id: {
type: [String],
default: undefined
default: []
},
community_id: {
type: String,
required: true
},
olive_community_id: {
type: String,
required: true
},
community_id: String,
olive_community_id: String,
is_recommended: {
type: Number,
default: 0
},
app_data: String,
app_data: {
type: String,
default: ''
},
user_favorites: {
type: [Number],
default: []
},
permissions: PermissionsSchema
permissions: {
type: PermissionsSchema,
default: {}
}
});

CommunitySchema.method<HydratedCommunityDocument>('addUserFavorite', async function addUserFavorite(pid: number): Promise<void> {
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);
}

await this.save();
});

CommunitySchema.method<HydratedCommunityDocument>('delUserFavorite', async function delUserFavorite(pid: number): Promise<void> {
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);
}

Expand All @@ -114,10 +147,44 @@ CommunitySchema.method<HydratedCommunityDocument>('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<ICommunity, CommunityModel>('Community', CommunitySchema);

export async function tryCreateCommunity(community: Omit<ICommunityInput, 'community_id' | 'olive_community_id'>, attempts: number = 0): Promise<HydratedCommunityDocument> {
// 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;
}
10 changes: 3 additions & 7 deletions apps/miiverse-api/src/services/api/routes/communities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 || '',
Expand All @@ -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]
});
Expand Down
43 changes: 33 additions & 10 deletions apps/miiverse-api/src/types/mongoose/community.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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<number>;
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<string>;
title_id: Types.Array<string>;
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<number>;
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<ICommunity, CommunityDefaultedFields> & Partial<Pick<ICommunity, CommunityDefaultedFields>>;

export interface ICommunityMethods {
addUserFavorite(pid: number): Promise<void>;
Expand Down