feat: Misskey Gamesのプレイ可否をロールで制限できるように

This commit is contained in:
kakkokari-gtyih 2024-11-14 16:25:11 +09:00
parent 4d54101510
commit eec30992ce
21 changed files with 153 additions and 19 deletions

8
locales/index.d.ts vendored
View file

@ -5218,6 +5218,10 @@ export interface Locale extends ILocale {
*
*/
"availableRoles": string;
/**
* Misskey Gamesをプレイする権限がありません
*/
"youCannotPlayGames": string;
"_accountSettings": {
/**
*
@ -6989,6 +6993,10 @@ export interface Locale extends ILocale {
*
*/
"canImportUserLists": string;
/**
* Misskey Gamesの利用
*/
"canPlayGames": string;
};
"_condition": {
/**

View file

@ -1300,6 +1300,7 @@ thisContentsAreMarkedAsSigninRequiredByAuthor: "投稿者により、表示に
lockdown: "ロックダウン"
pleaseSelectAccount: "アカウントを選択してください"
availableRoles: "利用可能なロール"
youCannotPlayGames: "このアカウントにはMisskey Gamesをプレイする権限がありません。"
_accountSettings:
requireSigninToViewContents: "コンテンツの表示にログインを必須にする"
@ -1806,6 +1807,7 @@ _role:
canImportFollowing: "フォローのインポートを許可"
canImportMuting: "ミュートのインポートを許可"
canImportUserLists: "リストのインポートを許可"
canPlayGames: "Misskey Gamesの利用"
_condition:
roleAssignedTo: "マニュアルロールにアサイン済み"
isLocal: "ローカルユーザー"

View file

@ -17,12 +17,14 @@ import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
import { RoleService } from '@/core/RoleService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { Serialized } from '@/types.js';
import { ReversiGameEntityService } from './entities/ReversiGameEntityService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
const INVITATION_TIMEOUT_MS = 1000 * 20; // 20sec
@ -41,6 +43,7 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
private reversiGamesRepository: ReversiGamesRepository,
private cacheService: CacheService,
private roleService: RoleService,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
private reversiGameEntityService: ReversiGameEntityService,
@ -93,7 +96,14 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async matchSpecificUser(me: MiUser, targetUser: MiUser, multiple = false): Promise<MiReversiGame | null> {
if (targetUser.id === me.id) {
throw new Error('You cannot match yourself.');
throw new IdentifiableError('eeb95261-6538-4294-a1d1-ed9a40d2c25b', 'You cannot match with yourself.');
}
const myPolicy = await this.roleService.getUserPolicies(me.id);
const targetPolicy = await this.roleService.getUserPolicies(targetUser.id);
if (!myPolicy.canPlayGames || !targetPolicy.canPlayGames) {
throw new IdentifiableError('6a8a09eb-f359-4339-9b1d-2fb3f8c0df45', 'You or target user is not available due to server policy.');
}
if (!multiple) {
@ -143,6 +153,11 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
@bindThis
public async matchAnyUser(me: MiUser, options: { noIrregularRules: boolean }, multiple = false): Promise<MiReversiGame | null> {
const myPolicy = await this.roleService.getUserPolicies(me.id);
if (!myPolicy.canPlayGames) {
throw new IdentifiableError('6a8a09eb-f359-4339-9b1d-2fb3f8c0df45', 'You cannot play due to server policy.');
}
if (!multiple) {
// 既にマッチしている対局が無いか探す(3分以内)
const games = await this.reversiGamesRepository.find({

View file

@ -63,6 +63,7 @@ export type RolePolicies = {
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
canPlayGames: boolean;
};
export const DEFAULT_POLICIES: RolePolicies = {
@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportFollowing: true,
canImportMuting: true,
canImportUserLists: true,
canPlayGames: true,
};
@Injectable()
@ -402,6 +404,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
canPlayGames: calc('canPlayGames', vs => vs.some(v => v === true)),
};
}

View file

@ -104,3 +104,8 @@ export function toArray<T>(x: T | T[] | undefined): T[] {
export function toSingle<T>(x: T | T[] | undefined): T | undefined {
return Array.isArray(x) ? x[0] : x;
}
export async function asyncFilter<T>(xs: T[], f: (x: T) => Promise<boolean>): Promise<T[]> {
const bits = await Promise.all(xs.map(f));
return xs.filter((_, i) => bits[i]);
}

View file

@ -292,6 +292,10 @@ export const packedRolePoliciesSchema = {
type: 'boolean',
optional: false, nullable: false,
},
canPlayGames: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View file

@ -13,6 +13,7 @@ import { ApiError } from '../../error.js';
export const meta = {
requireCredential: true,
requireRolePolicy: 'canPlayGames',
kind: 'write:account',

View file

@ -9,6 +9,7 @@ import { ReversiService } from '@/core/ReversiService.js';
export const meta = {
requireCredential: true,
requireRolePolicy: 'canPlayGames',
kind: 'write:account',

View file

@ -8,8 +8,9 @@ import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { DI } from '@/di-symbols.js';
import type { ReversiGamesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import type { ReversiGamesRepository } from '@/models/_.js';
export const meta = {
requireCredential: false,
@ -39,6 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private reversiGamesRepository: ReversiGamesRepository,
private reversiGameEntityService: ReversiGameEntityService,
private roleService: RoleService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
@ -52,6 +54,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where('game.user1Id = :userId', { userId: me.id })
.orWhere('game.user2Id = :userId', { userId: me.id });
}));
const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.canPlayGames) {
// 今ゲームをプレイできない場合は終了済みのゲームのみ表示
query.andWhere('game.isEnded = TRUE');
}
} else {
query.andWhere('game.isStarted = TRUE');
}

View file

@ -7,10 +7,13 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import { ReversiService } from '@/core/ReversiService.js';
import { asyncFilter } from '@/misc/prelude/array.js';
export const meta = {
requireCredential: true,
requireRolePolicy: 'canPlayGames',
kind: 'read:account',
@ -28,10 +31,14 @@ export const paramDef = {
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private userEntityService: UserEntityService,
private roleService: RoleService,
private reversiService: ReversiService,
) {
super(meta, paramDef, async (ps, me) => {
const invitations = await this.reversiService.getInvitations(me);
const invitations = await asyncFilter(await this.reversiService.getInvitations(me), async (userId) => {
const policies = await this.roleService.getUserPolicies(userId);
return policies.canPlayGames;
});
return await this.userEntityService.packMany(invitations, me);
});

View file

@ -5,13 +5,16 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { RoleService } from '@/core/RoleService.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { ApiError } from '../../error.js';
import { GetterService } from '../../GetterService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
export const meta = {
requireCredential: true,
requireRolePolicy: 'canPlayGames',
kind: 'write:account',
@ -27,6 +30,12 @@ export const meta = {
code: 'TARGET_IS_YOURSELF',
id: '96fd7bd6-d2bc-426c-a865-d055dcd2828e',
},
isNotAvailable: {
message: 'You or target user is not available due to server policy.',
code: 'TARGET_IS_NOT_AVAILABLE',
id: '3a8a677f-98e5-4c4d-b059-e5874b44bd4f',
},
},
res: {
@ -61,13 +70,28 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err;
}) : null;
const game = target
? await this.reversiService.matchSpecificUser(me, target, ps.multiple)
: await this.reversiService.matchAnyUser(me, { noIrregularRules: ps.noIrregularRules }, ps.multiple);
try {
const game = target
? await this.reversiService.matchSpecificUser(me, target, ps.multiple)
: await this.reversiService.matchAnyUser(me, { noIrregularRules: ps.noIrregularRules }, ps.multiple);
if (game == null) return;
if (game == null) return;
return await this.reversiGameEntityService.packDetail(game);
return await this.reversiGameEntityService.packDetail(game);
} catch (err) {
if (err instanceof IdentifiableError) {
switch (err.id) {
case 'eeb95261-6538-4294-a1d1-ed9a40d2c25b':
throw new ApiError(meta.errors.isYourself);
case '6a8a09eb-f359-4339-9b1d-2fb3f8c0df45':
throw new ApiError(meta.errors.isNotAvailable);
default:
throw err;
}
} else {
throw err;
}
}
});
}
}

View file

@ -10,6 +10,7 @@ import { ApiError } from '../../error.js';
export const meta = {
requireCredential: true,
requireRolePolicy: 'canPlayGames',
kind: 'write:account',

View file

@ -106,6 +106,7 @@ export const ROLE_POLICIES = [
'canImportFollowing',
'canImportMuting',
'canImportUserLists',
'canPlayGames',
] as const;
// なんか動かない

View file

@ -132,7 +132,7 @@ watch(modelValue, () => {
}, { immediate: true });
function show() {
if (opening.value) return;
if (opening.value || props.disabled || props.readonly) return;
focus();
opening.value = true;
@ -233,7 +233,7 @@ function show() {
outline: none;
}
&:hover {
&:hover:not(.disabled) {
> .inputCore {
border-color: var(--MI_THEME-inputBorderHover) !important;
}

View file

@ -66,6 +66,10 @@ const toggle = () => {
&.disabled {
opacity: 0.6;
cursor: not-allowed;
.label {
cursor: not-allowed;
}
}
}

View file

@ -690,6 +690,26 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canPlayGames, 'canPlayGames'])">
<template #label>{{ i18n.ts._role._options.canPlayGames }}</template>
<template #suffix>
<span v-if="role.policies.canPlayGames.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.canPlayGames.value ? i18n.ts.yes : i18n.ts.no }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.canPlayGames)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.canPlayGames.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkSwitch v-model="role.policies.canPlayGames.value" :disabled="role.policies.canPlayGames.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
<MkRange v-model="role.policies.canPlayGames.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
</div>
</FormSlot>
</div>

View file

@ -256,6 +256,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canPlayGames, 'canPlayGames'])">
<template #label>{{ i18n.ts._role._options.canPlayGames }}</template>
<template #suffix>{{ policies.canPlayGames ? i18n.ts.yes : i18n.ts.no }}</template>
<MkSwitch v-model="policies.canPlayGames">
<template #label>{{ i18n.ts.enable }}</template>
</MkSwitch>
</MkFolder>
</div>
</MkFolder>
<MkButton primary rounded @click="create"><i class="ti ti-plus"></i> {{ i18n.ts._role.new }}</MkButton>

View file

@ -23,20 +23,21 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="_woodenFrame" style="text-align: center;">
<div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;">
<MkSelect v-model="gameMode">
<MkInfo v-if="$i && !$i.policies.canPlayGames" warn>{{ i18n.ts.youCannotPlayGames }}</MkInfo>
<MkSelect :disabled="$i == null || !$i.policies.canPlayGames" v-model="gameMode">
<option value="normal">NORMAL</option>
<option value="square">SQUARE</option>
<option value="yen">YEN</option>
<option value="sweets">SWEETS</option>
<!--<option value="space">SPACE</option>-->
</MkSelect>
<MkButton primary gradate large rounded inline @click="start">{{ i18n.ts.start }}</MkButton>
<MkButton primary gradate large rounded inline :disabled="$i == null || !$i.policies.canPlayGames" @click="start">{{ i18n.ts.start }}</MkButton>
</div>
</div>
<div class="_woodenFrameInner">
<div class="_gaps" style="padding: 16px;">
<div style="font-size: 90%;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
<MkSwitch v-model="mute">
<MkSwitch :disabled="$i == null || !$i.policies.canPlayGames" v-model="mute">
<template #label>{{ i18n.ts.mute }}</template>
</MkSwitch>
</div>
@ -90,7 +91,9 @@ import { computed, ref, watch } from 'vue';
import XGame from './drop-and-fusion.game.vue';
import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkButton from '@/components/MkButton.vue';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import MkInfo from '@/components/MkInfo.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import { misskeyApiGet } from '@/scripts/misskey-api.js';

View file

@ -181,7 +181,7 @@ const engine = shallowRef<Reversi.Game>(Reversi.Serializer.restoreGame({
}));
const iAmPlayer = computed(() => {
return game.value.user1Id === $i.id || game.value.user2Id === $i.id;
return $i.policies.canPlayGames && (game.value.user1Id === $i.id || game.value.user2Id === $i.id);
});
const myColor = computed(() => {

View file

@ -11,14 +11,15 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div class="_panel _gaps" style="padding: 16px;">
<MkInfo v-if="$i && !$i.policies.canPlayGames" warn>{{ i18n.ts.youCannotPlayGames }}</MkInfo>
<div class="_buttonsCenter">
<MkButton primary gradate rounded @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton>
<MkButton primary gradate rounded @click="matchUser">{{ i18n.ts.invite }}</MkButton>
<MkButton primary gradate rounded :disabled="$i == null || !$i.policies.canPlayGames" @click="matchAny">{{ i18n.ts._reversi.freeMatch }}</MkButton>
<MkButton primary gradate rounded :disabled="$i == null || !$i.policies.canPlayGames" @click="matchUser">{{ i18n.ts.invite }}</MkButton>
</div>
<div style="font-size: 90%; opacity: 0.7; text-align: center;"><i class="ti ti-music"></i> {{ i18n.ts.soundWillBePlayed }}</div>
</div>
<MkFolder v-if="invitations.length > 0" :defaultOpen="true">
<MkFolder v-if="$i && $i.policies.canPlayGames && invitations.length > 0" :defaultOpen="true">
<template #label>{{ i18n.ts.invitations }}</template>
<div class="_gaps_s">
<button v-for="user in invitations" :key="user.id" v-panel :class="$style.invitation" class="_button" tabindex="-1" @click="accept(user)">
@ -112,6 +113,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import { useStream } from '@/stream.js';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInfo from '@/components/MkInfo.vue';
import { i18n } from '@/i18n.js';
import { $i } from '@/account.js';
import MkPagination from '@/components/MkPagination.vue';
@ -176,7 +178,7 @@ async function matchHeatbeat() {
if (matchingUser.value) {
const res = await misskeyApi('reversi/match', {
userId: matchingUser.value.id,
});
}).catch(onApiError);
if (res != null) {
startGame(res);
@ -185,7 +187,7 @@ async function matchHeatbeat() {
const res = await misskeyApi('reversi/match', {
userId: null,
noIrregularRules: noIrregularRules.value,
});
}).catch(onApiError);
if (res != null) {
startGame(res);
@ -193,6 +195,21 @@ async function matchHeatbeat() {
}
}
function onApiError(err) {
if (err.id === '7f86f06f-7e15-4057-8561-f4b6d4ac755a') {
// Role permission error
matchingUser.value = null;
matchingAny.value = false;
os.alert({
type: 'error',
title: i18n.ts.permissionDeniedError,
text: i18n.ts.permissionDeniedErrorDescription,
});
}
return null;
}
async function matchUser() {
pleaseLogin();
@ -248,6 +265,8 @@ useInterval(matchHeatbeat, 1000 * 5, { immediate: false, afterMounted: true });
onMounted(() => {
misskeyApi('reversi/invitations').then(_invitations => {
invitations.value = _invitations;
}).catch(() => {
invitations.value = [];
});
window.addEventListener('beforeunload', cancelMatching);

View file

@ -4872,6 +4872,7 @@ export type components = {
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
canPlayGames: boolean;
};
ReversiGameLite: {
/** Format: id */