Compare commits

...

4 Commits

Author SHA1 Message Date
tetsuya-ki e5989a53ca
Merge 05156ca854 into 65e51463c8 2026-02-02 00:19:36 +09:00
かっこかり 65e51463c8
fix(frontend): CSSの指定が誤っている問題を修正 (#17135) 2026-01-31 22:38:16 +09:00
Ken_Cir 39362f78a6
fix(backend): inconsistent permissions for /admin/get-user-ips (#17136)
* fix(backend): inconsistent permissions for /admin/get-user-ips

* Update Changelog
2026-01-31 22:37:48 +09:00
tetsuya-ki 05156ca854 fix #13456 2025-12-14 05:12:08 +00:00
7 changed files with 165 additions and 20 deletions

View File

@ -31,6 +31,7 @@
- JSONによるClient Information Discoveryを行うには、レスポンスの`Content-Type`ヘッダーが`application/json`である必要があります
- 従来の実装12 February 2022版・HTML Microformat形式も引き続きサポートされます
- Enhance: メモリ使用量を削減
- Fix: `/admin/get-user-ips` エンドポイントのアクセス権限を管理者のみに修正
## 2025.12.2

View File

@ -17,6 +17,7 @@ import { DebounceLoader } from '@/misc/loader.js';
import { IdService } from '@/core/IdService.js';
import { shouldHideNoteByTime } from '@/misc/should-hide-note-by-time.js';
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
import { CacheService } from '@/core/CacheService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { CustomEmojiService } from '../CustomEmojiService.js';
import type { ReactionService } from '../ReactionService.js';
@ -101,6 +102,7 @@ export class NoteEntityService implements OnModuleInit {
//private reactionService: ReactionService,
//private reactionsBufferingService: ReactionsBufferingService,
//private idService: IdService,
private cacheService: CacheService,
) {
}
@ -376,7 +378,46 @@ export class NoteEntityService implements OnModuleInit {
: this.meta.enableReactionsBuffering
? await this.reactionsBufferingService.get(note.id)
: { deltas: {}, pairs: [] };
const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {}));
let reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions.deltas ?? {}));
if (meId) {
// ログインユーザーがいる場合のみ、ブロック/ミュートユーザーを除外して集計し直す
// 1. ブロック・ミュートリストを取得
const [mutedIds, blockedIds] = await Promise.all([
this.cacheService.userMutingsCache.fetch(meId),
this.cacheService.userBlockingCache.fetch(meId),
]);
// 2. DBとバッファから、フィルタリングに必要な全ユーザー/リアクションペアを取得
// DBからの全リアクションレコードを取得
const dbReactions = await this.noteReactionsRepository.findBy({ noteId: note.id });
// バッファリングされたペアを追加
const bufferedPairs = bufferedReactions.pairs ?? []; // pairs: ([MiUser['id'], string])[]
// 3. フィルタリングして再集計
const filteredReactions: Record<string, number> = {};
// 3a. DBからのリアクションをフィルタリング
for (const reaction of dbReactions) {
const isBlockedOrMuted = blockedIds.has(reaction.userId) || mutedIds.has(reaction.userId);
if (!isBlockedOrMuted) {
const reactionName = this.reactionService.convertLegacyReaction(reaction.reaction);
filteredReactions[reactionName] = (filteredReactions[reactionName] || 0) + 1;
}
}
// 3b. バッファからのリアクションをフィルタリング
for (const [userId, reactionName] of bufferedPairs) {
const isBlockedOrMuted = blockedIds.has(userId) || mutedIds.has(userId);
if (!isBlockedOrMuted) {
const normalizedReaction = this.reactionService.convertLegacyReaction(reactionName);
filteredReactions[normalizedReaction] = (filteredReactions[normalizedReaction] || 0) + 1;
}
}
reactions = filteredReactions;
}
const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/')));
@ -600,7 +641,7 @@ export class NoteEntityService implements OnModuleInit {
}
@bindThis
public async fetchDiffs(noteIds: MiNote['id'][]) {
public async fetchDiffs(noteIds: MiNote['id'][], meId: MiUser['id'] | null) {
if (noteIds.length === 0) return [];
const notes = await this.notesRepository.find({
@ -617,12 +658,43 @@ export class NoteEntityService implements OnModuleInit {
const bufferedReactionsMap = this.meta.enableReactionsBuffering ? await this.reactionsBufferingService.getMany(noteIds) : null;
const packings = notes.map(note => {
const packings = notes.map(async note => {
const bufferedReactions = bufferedReactionsMap?.get(note.id);
//const reactionAndUserPairCache = note.reactionAndUserPairCache.concat(bufferedReactions.pairs.map(x => x.join('/')));
const reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {}));
let reactions = this.reactionService.convertLegacyReactions(this.reactionsBufferingService.mergeReactions(note.reactions, bufferedReactions?.deltas ?? {}));
if (meId) {
const [mutedIds, blockedIds] = await Promise.all([
this.cacheService.userMutingsCache.fetch(meId),
this.cacheService.userBlockingCache.fetch(meId),
]);
// 2. DBとバッファから、フィルタリングに必要な全ユーザー/リアクションペアを取得
const dbReactions = await this.noteReactionsRepository.findBy({ noteId: note.id });
const bufferedPairs = bufferedReactions?.pairs ?? [];
const filteredReactions: Record<string, number> = {};
// 3a. DBからのリアクションをフィルタリング
for (const reaction of dbReactions) {
const isBlockedOrMuted = blockedIds.has(reaction.userId) || mutedIds.has(reaction.userId);
if (!isBlockedOrMuted) {
const reactionName = this.reactionService.convertLegacyReaction(reaction.reaction);
filteredReactions[reactionName] = (filteredReactions[reactionName] || 0) + 1;
}
}
// 3b. バッファからのリアクションをフィルタリング
for (const [userId, reactionName] of bufferedPairs) {
const isBlockedOrMuted = blockedIds.has(userId) || mutedIds.has(userId);
if (!isBlockedOrMuted) {
const normalizedReaction = this.reactionService.convertLegacyReaction(reactionName);
filteredReactions[normalizedReaction] = (filteredReactions[normalizedReaction] || 0) + 1;
}
}
reactions = filteredReactions;
}
const reactionEmojiNames = Object.keys(reactions)
.filter(x => x.startsWith(':') && x.includes('@') && !x.includes('@.')) // リモートカスタム絵文字のみ
.map(x => this.reactionService.decodeReaction(x).reaction.replaceAll(':', ''));

View File

@ -9,10 +9,11 @@ import type { NoteReactionsRepository } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { } from '@/models/Blocking.js';
import { CacheService } from '@/core/CacheService.js';
import type { MiUser } from '@/models/User.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js';
import type { OnModuleInit } from '@nestjs/common';
import type { } from '@/models/Blocking.js';
import type { ReactionService } from '../ReactionService.js';
import type { UserEntityService } from './UserEntityService.js';
import type { NoteEntityService } from './NoteEntityService.js';
@ -24,6 +25,7 @@ export class NoteReactionEntityService implements OnModuleInit {
private noteEntityService: NoteEntityService;
private reactionService: ReactionService;
private idService: IdService;
private cacheService: CacheService;
constructor(
private moduleRef: ModuleRef,
@ -35,6 +37,7 @@ export class NoteReactionEntityService implements OnModuleInit {
//private noteEntityService: NoteEntityService,
//private reactionService: ReactionService,
//private idService: IdService,
//private cacheService: CacheService,
) {
}
@ -43,6 +46,7 @@ export class NoteReactionEntityService implements OnModuleInit {
this.noteEntityService = this.moduleRef.get('NoteEntityService');
this.reactionService = this.moduleRef.get('ReactionService');
this.idService = this.moduleRef.get('IdService');
this.cacheService = this.moduleRef.get('CacheService');
}
@bindThis
@ -75,10 +79,30 @@ export class NoteReactionEntityService implements OnModuleInit {
): Promise<Packed<'NoteReaction'>[]> {
const opts = Object.assign({
}, options);
const _users = reactions.map(({ user, userId }) => user ?? userId);
const meId = me ? me.id : null;
// ログインユーザーがいる場合のみ、ブロック・ミュートリストを取得
let muted: Set<string> | null = null;
let blocked: Set<string> | null = null;
let newReactions: MiNoteReaction[] = reactions;
if (meId) {
[blocked, muted] = await Promise.all([
this.cacheService.userBlockingCache.fetch(meId), // 自分がブロックしたユーザー
this.cacheService.userMutingsCache.fetch(meId), // 自分がミュートしたユーザー
]);
const filteredReactions = reactions.filter(reaction => {
const isBlockedOrMuted = blocked!.has(reaction.userId) || muted!.has(reaction.userId);
return !isBlockedOrMuted;
});
newReactions = filteredReactions;
}
const _users = newReactions.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(reactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
return Promise.all(newReactions.map(reaction => this.pack(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
}
@bindThis
@ -94,6 +118,22 @@ export class NoteReactionEntityService implements OnModuleInit {
}, options);
const reaction = typeof src === 'object' ? src : await this.noteReactionsRepository.findOneByOrFail({ id: src });
const meId = me ? me.id : null;
// ログインユーザーがいる場合のみ、ブロック・ミュートリストを取得
let muted: Set<string> | null = null;
let blocked: Set<string> | null = null;
if (meId) {
[blocked, muted] = await Promise.all([
this.cacheService.userBlockingCache.fetch(meId), // 自分がブロックしたユーザー
this.cacheService.userMutingsCache.fetch(meId), // 自分がミュートしたユーザー
]);
if (reaction.userId && (blocked?.has(reaction.userId) || muted?.has(reaction.userId))) {
return {} as any; // ミュート・ブロックされている場合は空オブジェクトを返す
}
}
return {
id: reaction.id,
@ -110,11 +150,24 @@ export class NoteReactionEntityService implements OnModuleInit {
me?: { id: MiUser['id'] } | null | undefined,
options?: object,
): Promise<Packed<'NoteReactionWithNote'>[]> {
const opts = Object.assign({
}, options);
const _users = reactions.map(({ user, userId }) => user ?? userId);
const opts = Object.assign({}, options);
// キャッシュからミュート・ブロック情報を取得
const blocked = me ? await this.cacheService.userBlockedCache.fetch(me.id) : null;
const muted = me ? await this.cacheService.userMutingsCache.fetch(me.id) : null;
// ミュート・ブロックされたユーザーのリアクションを除外
const filteredReactions = reactions.filter(reaction => {
if (!me) return true;
return !(blocked?.has(reaction.userId) || muted?.has(reaction.userId));
});
const _users = filteredReactions.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me)
.then(users => new Map(users.map(u => [u.id, u])));
return Promise.all(reactions.map(reaction => this.packWithNote(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) })));
return Promise.all(filteredReactions.map(reaction =>
this.packWithNote(reaction, me, opts, { packedUser: _userMap.get(reaction.userId) }),
));
}
}

View File

@ -13,7 +13,7 @@ export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
requireAdmin: true,
kind: 'read:admin:user-ips',
res: {
type: 'array',

View File

@ -60,7 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
) {
super(meta, paramDef, async (ps, me) => {
return await this.noteEntityService.fetchDiffs(ps.noteIds);
return await this.noteEntityService.fetchDiffs(ps.noteIds, me?.id ?? null);
});
}
}

View File

@ -227,6 +227,16 @@ export function useNoteCapture(props: {
function onReacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }): void {
let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, '');
const blockedIds: Set<string> = ($i as any)?.blockedIds ?? new Set();
const mutedIds: Set<string> = ($i as any)?.mutedIds ?? new Set();
const isBlocked = blockedIds.has(ctx.userId);
const isMuted = mutedIds.has(ctx.userId);
if (isBlocked || isMuted) {
// ブロック/ミュートユーザーからのリアクションは集計に含めず、処理を終了
return;
}
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === normalizedName) return;
reactionUserMap.set(ctx.userId, normalizedName);
@ -247,6 +257,15 @@ export function useNoteCapture(props: {
function onUnreacted(ctx: { userId: Misskey.entities.User['id']; reaction: string; emoji?: { name: string; url: string; } | null; }): void {
let normalizedName = ctx.reaction.replace(/^:(\w+):$/, ':$1@.:');
normalizedName = normalizedName.match('\u200d') ? normalizedName : normalizedName.replace(/\ufe0f/g, '');
const blockedIds: Set<string> = ($i as any)?.blockedIds ?? new Set();
const mutedIds: Set<string> = ($i as any)?.mutedIds ?? new Set();
const isBlocked = blockedIds.has(ctx.userId);
const isMuted = mutedIds.has(ctx.userId);
if (isBlocked || isMuted) {
// ブロック/ミュートユーザーによるリアクション削除は無視する
return;
}
// 確実に一度リアクションされて取り消されている場合のみ処理をとめるAPIで初回読み込み→Streamでアップデート等の場合、reactionUserMapに情報がないため
if (reactionUserMap.has(ctx.userId) && reactionUserMap.get(ctx.userId) === noReaction) return;

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div :class="$style.body">
<div :class="$style.top">
<button v-tooltip.noDelay.right="instance.name ?? i18n.ts.instance" class="_button" :class="$style.instance" @click="openInstanceMenu">
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="viewTransitionName: navbar-serverIcon;"/>
<img :src="instance.iconUrl || '/favicon.ico'" alt="" :class="$style.instanceIcon" style="view-transition-name: navbar-serverIcon;"/>
</button>
<button v-if="!iconOnly" v-tooltip.noDelay.right="i18n.ts.realtimeMode" class="_button" :class="[$style.realtimeMode, store.r.realtimeMode.value ? $style.on : null]" @click="toggleRealtimeMode">
<i v-if="store.r.realtimeMode.value" class="ti ti-bolt ti-fw"></i>
@ -20,7 +20,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
<div :class="$style.middle">
<MkA v-tooltip.noDelay.right="i18n.ts.timeline" :class="$style.item" :activeClass="$style.active" to="/" exact>
<i :class="$style.itemIcon" class="ti ti-home ti-fw" style="viewTransitionName: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span>
<i :class="$style.itemIcon" class="ti ti-home ti-fw" style="view-transition-name: navbar-homeIcon;"></i><span :class="$style.itemText">{{ i18n.ts.timeline }}</span>
</MkA>
<template v-for="item in prefer.r.menu.value">
<div v-if="item === '-'" :class="$style.divider"></div>
@ -43,14 +43,14 @@ SPDX-License-Identifier: AGPL-3.0-only
</template>
<div :class="$style.divider"></div>
<MkA v-if="$i != null && ($i.isAdmin || $i.isModerator)" v-tooltip.noDelay.right="i18n.ts.controlPanel" :class="$style.item" :activeClass="$style.active" to="/admin">
<i :class="$style.itemIcon" class="ti ti-dashboard ti-fw" style="viewTransitionName: navbar-controlPanel;"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span>
<i :class="$style.itemIcon" class="ti ti-dashboard ti-fw" style="view-transition-name: navbar-controlPanel;"></i><span :class="$style.itemText">{{ i18n.ts.controlPanel }}</span>
</MkA>
<button class="_button" :class="$style.item" @click="more">
<i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw" style="viewTransitionName: navbar-more;"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
<i :class="$style.itemIcon" class="ti ti-grid-dots ti-fw" style="view-transition-name: navbar-more;"></i><span :class="$style.itemText">{{ i18n.ts.more }}</span>
<span v-if="otherMenuItemIndicated" :class="$style.itemIndicator" class="_blink"><i class="_indicatorCircle"></i></span>
</button>
<MkA v-tooltip.noDelay.right="i18n.ts.settings" :class="$style.item" :activeClass="$style.active" to="/settings">
<i :class="$style.itemIcon" class="ti ti-settings ti-fw" style="viewTransitionName: navbar-settings;"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
<i :class="$style.itemIcon" class="ti ti-settings ti-fw" style="view-transition-name: navbar-settings;"></i><span :class="$style.itemText">{{ i18n.ts.settings }}</span>
</MkA>
</div>
<div :class="$style.bottom">
@ -65,7 +65,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<i class="ti ti-pencil ti-fw" :class="$style.postIcon"></i><span :class="$style.postText">{{ i18n.ts.note }}</span>
</button>
<button v-if="$i != null" v-tooltip.noDelay.right="`${i18n.ts.account}: @${$i.username}`" class="_button" :class="[$style.account]" @click="openAccountMenu">
<MkAvatar :user="$i" :class="$style.avatar" style="viewTransitionName: navbar-avatar;"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/>
<MkAvatar :user="$i" :class="$style.avatar" style="view-transition-name: navbar-avatar;"/><MkAcct class="_nowrap" :class="$style.acct" :user="$i"/>
</button>
</div>
</div>