merge: 2024.8 (!610)

View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/610

Approved-by: Julia <julia@insertdomain.name>
This commit is contained in:
Julia 2024-09-13 15:57:16 +00:00
commit d4c5322f9c
87 changed files with 1501 additions and 883 deletions

View file

@ -122,7 +122,7 @@
"form-data": "4.0.0",
"glob": "10.3.10",
"got": "14.4.2",
"happy-dom": "10.0.3",
"happy-dom": "15.6.1",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",

View file

@ -4,12 +4,15 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository } from '@/models/_.js';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, MiUser, UsersRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable()
export class DeleteAccountService {
@ -17,9 +20,14 @@ export class DeleteAccountService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userSuspendService: UserSuspendService,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userEntityService: UserEntityService,
private apRendererService: ApRendererService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
) {
}
@ -27,16 +35,52 @@ export class DeleteAccountService {
public async deleteAccount(user: {
id: string;
host: string | null;
}): Promise<void> {
}, moderator?: MiUser): Promise<void> {
const _user = await this.usersRepository.findOneByOrFail({ id: user.id });
if (_user.isRoot) throw new Error('cannot delete a root account');
// 物理削除する前にDelete activityを送信する
await this.userSuspendService.doPostSuspend(user).catch(e => {});
if (moderator != null) {
this.moderationLogService.log(moderator, 'deleteAccount', {
userId: user.id,
userUsername: _user.username,
userHost: user.host,
});
}
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
// 物理削除する前にDelete activityを送信する
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
const queue: string[] = [];
const followings = await this.followingsRepository.find({
where: [
{ followerSharedInbox: Not(IsNull()) },
{ followeeSharedInbox: Not(IsNull()) },
],
select: ['followerSharedInbox', 'followeeSharedInbox'],
});
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
}
for (const inbox of queue) {
this.queueService.deliver(user, content, inbox, true);
}
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
} else {
// リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
this.queueService.createDeleteAccountJob(user, {
soft: true,
});
}
await this.usersRepository.update(user.id, {
isDeleted: true,

View file

@ -12,7 +12,7 @@ import FFmpeg from 'fluent-ffmpeg';
import isSvg from 'is-svg';
import probeImageSize from 'probe-image-size';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import { encode } from 'blurhash';
import * as blurhash from 'blurhash';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
@ -283,7 +283,7 @@ export class FileInfoService {
}
/**
* Calculate average color of image
* Calculate blurhash string of image
*/
@bindThis
private getBlurhash(path: string, type: string): Promise<string> {
@ -298,7 +298,7 @@ export class FileInfoService {
let hash;
try {
hash = encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
} catch (e) {
return reject(e);
}

View file

@ -9,7 +9,8 @@ import type { ModerationLogsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
import { ModerationLogPayloads, moderationLogTypes } from '@/types.js';
import type { ModerationLogPayloads } from '@/types.js';
import { moderationLogTypes } from '@/types.js';
@Injectable()
export class ModerationLogService {

View file

@ -281,192 +281,6 @@ export class NoteCreateService implements OnApplicationShutdown {
data.visibility = 'home';
}
if (this.isRenote(data)) {
switch (data.renote.visibility) {
case 'public':
// public noteは無条件にrenote可能
break;
case 'home':
// home noteはhome以下にrenote可能
if (data.visibility === 'public') {
data.visibility = 'home';
}
break;
case 'followers':
// 他人のfollowers noteはreject
if (data.renote.userId !== user.id) {
throw new Error('Renote target is not public or home');
}
// Renote対象がfollowersならfollowersにする
data.visibility = 'followers';
break;
case 'specified':
// specified / direct noteはreject
throw new Error('Renote target is not public or home');
}
}
// Check blocking
if (this.isRenote(data) && !this.isQuote(data)) {
if (data.renote.userHost === null) {
if (data.renote.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(data.renote.userId, user.id);
if (blocked) {
throw new Error('blocked');
}
}
}
}
// 返信対象がpublicではないならhomeにする
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
data.visibility = 'home';
}
// ローカルのみをRenoteしたらローカルのみにする
if (data.renote && data.renote.localOnly && data.channel == null) {
data.localOnly = true;
}
// ローカルのみにリプライしたらローカルのみにする
if (data.reply && data.reply.localOnly && data.channel == null) {
data.localOnly = true;
}
if (data.text) {
if (data.text.length > DB_MAX_NOTE_TEXT_LENGTH) {
data.text = data.text.slice(0, DB_MAX_NOTE_TEXT_LENGTH);
}
data.text = data.text.trim();
if (data.text === '') {
data.text = null;
}
} else {
data.text = null;
}
let tags = data.apHashtags;
let emojis = data.apEmojis;
let mentionedUsers = data.apMentions;
// Parse MFM if needed
if (!tags || !emojis || !mentionedUsers) {
const tokens = (data.text ? mfm.parse(data.text)! : []);
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
const choiceTokens = data.poll && data.poll.choices
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
: [];
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
tags = data.apHashtags ?? extractHashtags(combinedTokens);
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
mentionedUsers = data.apMentions ?? await this.extractMentionedUsers(user, combinedTokens);
}
tags = tags.filter(tag => Array.from(tag).length <= 128).splice(0, 32);
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
}
if (data.visibility === 'specified') {
if (data.visibleUsers == null) throw new Error('invalid param');
for (const u of data.visibleUsers) {
if (!mentionedUsers.some(x => x.id === u.id)) {
mentionedUsers.push(u);
}
}
if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
}
}
if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => {
if (i.isNSFW) {
data.cw = 'Instance is marked as NSFW';
}
});
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
return note;
}
@bindThis
public async import(user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option, silent = false): Promise<MiNote> {
// チャンネル外にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
if (data.reply.channelId) {
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
} else {
data.channel = null;
}
}
// チャンネル内にリプライしたら対象のスコープに合わせる
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
if (data.reply && (data.channel == null) && data.reply.channelId) {
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
}
if (data.createdAt == null) data.createdAt = new Date();
if (data.visibility == null) data.visibility = 'public';
if (data.localOnly == null) data.localOnly = false;
if (data.channel != null) data.visibility = 'public';
if (data.channel != null) data.visibleUsers = [];
if (data.channel != null) data.localOnly = true;
const meta = await this.metaService.fetch();
if (data.visibility === 'public' && data.channel == null) {
const sensitiveWords = meta.sensitiveWords;
if (this.utilityService.isKeyWordIncluded(data.cw ?? data.text ?? '', sensitiveWords)) {
data.visibility = 'home';
} else if ((await this.roleService.getUserPolicies(user.id)).canPublicNote === false) {
data.visibility = 'home';
}
}
const hasProhibitedWords = await this.checkProhibitedWordsContain({
cw: data.cw,
text: data.text,
pollChoices: data.poll?.choices,
}, meta.prohibitedWords);
if (hasProhibitedWords) {
throw new IdentifiableError('689ee33f-f97c-479a-ac49-1b9f8140af99', 'Note contains prohibited words');
}
const inSilencedInstance = this.utilityService.isSilencedHost(meta.silencedHosts, user.host);
if (data.visibility === 'public' && inSilencedInstance && user.host !== null) {
data.visibility = 'home';
}
if (data.renote) {
switch (data.renote.visibility) {
case 'public':
@ -576,6 +390,14 @@ export class NoteCreateService implements OnApplicationShutdown {
}
}
if (user.host && !data.cw) {
await this.federatedInstanceService.fetch(user.host).then(async i => {
if (i.isNSFW) {
data.cw = 'Instance is marked as NSFW';
}
});
}
if (mentionedUsers.length > 0 && mentionedUsers.length > (await this.roleService.getUserPolicies(user.id)).mentionLimit) {
throw new IdentifiableError('9f466dab-c856-48cd-9e65-ff90ff750580', 'Note contains too many mentions');
}
@ -583,13 +405,24 @@ export class NoteCreateService implements OnApplicationShutdown {
const note = await this.insertNote(user, data, tags, emojis, mentionedUsers);
setImmediate('post created', { signal: this.#shutdownController.signal }).then(
() => this.postNoteImported(note, user, data, silent, tags!, mentionedUsers!),
() => this.postNoteCreated(note, user, data, silent, tags!, mentionedUsers!),
() => { /* aborted, ignore this */ },
);
return note;
}
@bindThis
public async import(user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option): Promise<MiNote> {
return this.create(user, data, true);
}
@bindThis
private async insertNote(user: { id: MiUser['id']; host: MiUser['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
const insert = new MiNote({
@ -707,7 +540,7 @@ export class NoteCreateService implements OnApplicationShutdown {
const meta = await this.metaService.fetch();
this.notesChart.update(note, true);
if (meta.enableChartsForRemoteUser || (user.host == null)) {
if (note.visibility !== 'specified' && (meta.enableChartsForRemoteUser || (user.host == null))) {
this.perUserNotesChart.update(user, note, true);
}
@ -967,105 +800,6 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!user.noindex) this.index(note);
}
@bindThis
private async postNoteImported(note: MiNote, user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
noindex: MiUser['noindex'];
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
const meta = await this.metaService.fetch();
this.notesChart.update(note, true);
if (meta.enableChartsForRemoteUser || (user.host == null)) {
this.perUserNotesChart.update(user, note, true);
}
// Register host
if (this.userEntityService.isRemoteUser(user)) {
this.federatedInstanceService.fetch(user.host).then(async i => {
if (note.renote && note.text) {
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
} else if (!note.renote) {
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
}
if ((await this.metaService.fetch()).enableChartsForFederatedInstances) {
this.instanceChart.updateNote(i.host, note, true);
}
});
}
if (data.renote && data.text) {
// Increment notes count (user)
this.incNotesCountOfUser(user);
} else if (!data.renote) {
// Increment notes count (user)
this.incNotesCountOfUser(user);
}
this.pushToTl(note, user);
this.antennaService.addNoteToAntennas(note, user);
if (data.reply) {
this.saveReply(data.reply, note);
}
if (data.reply == null) {
// TODO: キャッシュ
this.followingsRepository.findBy({
followeeId: user.id,
notify: 'normal',
}).then(followings => {
for (const following of followings) {
// TODO: ワードミュート考慮
this.notificationService.createNotification(following.followerId, 'note', {
noteId: note.id,
}, user.id);
}
});
}
if (data.renote && data.text == null && data.renote.userId !== user.id && !user.isBot) {
this.incRenoteCount(data.renote);
}
if (data.poll && data.poll.expiresAt) {
const delay = data.poll.expiresAt.getTime() - Date.now();
this.queueService.endedPollNotificationQueue.add(note.id, {
noteId: note.id,
}, {
delay,
removeOnComplete: true,
});
}
// Pack the note
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
if (data.channel) {
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
this.channelsRepository.update(data.channel.id, {
lastNotedAt: new Date(),
});
this.notesRepository.countBy({
userId: user.id,
channelId: data.channel.id,
}).then(count => {
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
if (count === 1) {
this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1);
}
});
}
// Register to search database
if (!user.noindex) this.index(note);
}
@bindThis
private isRenote(note: Option): note is Option & { renote: MiNote } {
return note.renote != null;

View file

@ -99,7 +99,7 @@ export class NoteDeleteService {
this.deliverToConcerned(user, note, content);
}
// also deliever delete activity to cascaded notes
// also deliver delete activity to cascaded notes
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
for (const cascadingNote of federatedLocalCascadingNotes) {
if (!cascadingNote.user) continue;

View file

@ -6,6 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import { ModuleRef } from '@nestjs/core';
import { reversiUpdateKeys } from 'misskey-js';
import * as Reversi from 'misskey-reversi';
import { IsNull, LessThan, MoreThan } from 'typeorm';
import type {
@ -399,7 +400,33 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
public async updateSettings(gameId: MiReversiGame['id'], user: MiUser, key: string, value: any) {
public isValidReversiUpdateKey(key: unknown): key is typeof reversiUpdateKeys[number] {
if (typeof key !== 'string') return false;
return (reversiUpdateKeys as string[]).includes(key);
}
@bindThis
public isValidReversiUpdateValue<K extends typeof reversiUpdateKeys[number]>(key: K, value: unknown): value is MiReversiGame[K] {
switch (key) {
case 'map':
return Array.isArray(value) && value.every(row => typeof row === 'string');
case 'bw':
return typeof value === 'string' && ['random', '1', '2'].includes(value);
case 'isLlotheo':
return typeof value === 'boolean';
case 'canPutEverywhere':
return typeof value === 'boolean';
case 'loopedBoard':
return typeof value === 'boolean';
case 'timeLimitForEachTurn':
return typeof value === 'number' && value >= 0;
default:
return false;
}
}
@bindThis
public async updateSettings<K extends typeof reversiUpdateKeys[number]>(gameId: MiReversiGame['id'], user: MiUser, key: K, value: MiReversiGame[K]) {
const game = await this.get(gameId);
if (game == null) throw new Error('game not found');
if (game.isStarted) return;
@ -407,10 +434,6 @@ export class ReversiService implements OnApplicationShutdown, OnModuleInit {
if ((game.user1Id === user.id) && game.user1Ready) return;
if ((game.user2Id === user.id) && game.user2Ready) return;
if (!['map', 'bw', 'isLlotheo', 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn'].includes(key)) return;
// TODO: より厳格なバリデーション
const updatedGame = {
...game,
[key]: value,

View file

@ -5,7 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository } from '@/models/_.js';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@ -13,24 +13,75 @@ import { DI } from '@/di-symbols.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
@Injectable()
export class UserSuspendService {
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private userEntityService: UserEntityService,
private queueService: QueueService,
private globalEventService: GlobalEventService,
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
) {
}
@bindThis
public async doPostSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
public async suspend(user: MiUser, moderator: MiUser): Promise<void> {
await this.usersRepository.update(user.id, {
isSuspended: true,
});
this.moderationLogService.log(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.postSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
})();
}
@bindThis
public async unsuspend(user: MiUser, moderator: MiUser): Promise<void> {
await this.usersRepository.update(user.id, {
isSuspended: false,
});
this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.postUnsuspend(user).catch(e => {});
})();
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
this.followRequestsRepository.delete({
followeeId: user.id,
});
this.followRequestsRepository.delete({
followerId: user.id,
});
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
@ -58,7 +109,7 @@ export class UserSuspendService {
}
@bindThis
public async doPostUnsuspend(user: MiUser): Promise<void> {
private async postUnsuspend(user: MiUser): Promise<void> {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
if (this.userEntityService.isLocalUser(user)) {
@ -86,4 +137,26 @@ export class UserSuspendService {
}
}
}
@bindThis
private async unFollowAll(follower: MiUser) {
const followings = await this.followingsRepository.find({
where: {
followerId: follower.id,
followeeId: Not(IsNull()),
},
});
const jobs: RelationshipJobData[] = [];
for (const following of followings) {
if (following.followeeId && following.followerId) {
jobs.push({
from: { id: following.followerId },
to: { id: following.followeeId },
silent: true,
});
}
}
this.queueService.createUnfollowJob(jobs);
}
}

View file

@ -6,6 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { Window } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@ -182,7 +183,8 @@ export class ApRequestService {
* @param url URL to fetch
*/
@bindThis
public async signedGet(url: string, user: { id: MiUser['id'] }): Promise<unknown> {
public async signedGet(url: string, user: { id: MiUser['id'] }, followAlternate?: boolean): Promise<unknown> {
const _followAlternate = followAlternate ?? true;
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const req = ApRequestCreator.createSignedGet({
@ -200,9 +202,52 @@ export class ApRequestService {
headers: req.request.headers,
}, {
throwErrorWhenResponseNotOk: true,
validators: [validateContentTypeSetAsActivityPub],
});
//#region リクエスト先がhtmlかつactivity+jsonへのalternate linkタグがあるとき
const contentType = res.headers.get('content-type');
if ((contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' && _followAlternate === true) {
const html = await res.text();
const window = new Window({
settings: {
disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
disableComputedStyleRendering: true,
handleDisabledFileLoadingAsSuccess: true,
navigation: {
disableMainFrameNavigation: true,
disableChildFrameNavigation: true,
disableChildPageNavigation: true,
disableFallbackToSetURL: true,
},
timer: {
maxTimeout: 0,
maxIntervalTime: 0,
maxIntervalIterations: 0,
},
},
});
const document = window.document;
try {
document.documentElement.innerHTML = html;
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) {
const href = alternate.getAttribute('href');
if (href) {
return await this.signedGet(href, user, false);
}
}
} catch (e) {
// something went wrong parsing the HTML, ignore the whole thing
}
}
//#endregion
validateContentTypeSetAsActivityPub(res);
const finalUrl = res.url; // redirects may have been involved
const activity = await res.json() as IObject;

View file

@ -358,7 +358,7 @@ export class ApNoteService {
value,
object,
});
throw new Error('invalid note');
throw err;
}
const note = object as IPost;
@ -471,19 +471,19 @@ export class ApNoteService {
| { status: 'ok'; res: MiNote }
| { status: 'permerror' | 'temperror' }
> => {
if (!/^https?:/.test(uri)) return { status: 'permerror' };
if (typeof uri !== 'string' || !/^https?:/.test(uri)) return { status: 'permerror' };
try {
const res = await this.resolveNote(uri, { resolver });
if (res == null) return { status: 'permerror' };
return { status: 'ok', res };
} catch (e) {
return {
status: (e instanceof StatusError && e.isClientError) ? 'permerror' : 'temperror',
status: (e instanceof StatusError && !e.isRetryable) ? 'permerror' : 'temperror',
};
}
};
const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter((x): x is string => typeof x === 'string'));
const uris = unique([note._misskey_quote, note.quoteUrl, note.quoteUri].filter(x => x != null));
const results = await Promise.all(uris.map(tryResolveNote));
quote = results.filter((x): x is { status: 'ok', res: MiNote } => x.status === 'ok').map(x => x.res).at(0);
@ -496,10 +496,10 @@ export class ApNoteService {
// vote
if (reply && reply.hasPoll) {
const replyPoll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
const poll = await this.pollsRepository.findOneByOrFail({ noteId: reply.id });
const tryCreateVote = async (name: string, index: number): Promise<null> => {
if (replyPoll.expiresAt && Date.now() > new Date(replyPoll.expiresAt).getTime()) {
if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) {
this.logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
} else if (index >= 0) {
this.logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`);
@ -512,7 +512,7 @@ export class ApNoteService {
};
if (note.name) {
return await tryCreateVote(note.name, replyPoll.choices.findIndex(x => x === note.name));
return await tryCreateVote(note.name, poll.choices.findIndex(x => x === note.name));
}
}

View file

@ -48,7 +48,7 @@ import type { ApResolverService, Resolver } from '../ApResolverService.js';
import type { ApLoggerService } from '../ApLoggerService.js';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
import type { ApImageService } from './ApImageService.js';
import type { IActor, IObject } from '../type.js';
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
const nameLength = 128;
const summaryLength = 2048;
@ -308,6 +308,21 @@ export class ApPersonService implements OnModuleInit {
const isBot = getApType(object) === 'Service' || getApType(object) === 'Application';
const [followingVisibility, followersVisibility] = await Promise.all(
[
this.isPublicCollection(person.following, resolver),
this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private'> => p
.then(isPublic => isPublic ? 'public' : 'private')
.catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) {
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
}
return 'private';
})
)
);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
@ -382,6 +397,8 @@ export class ApPersonService implements OnModuleInit {
description: _description,
url,
fields,
followingVisibility,
followersVisibility,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
userHost: host,
@ -490,6 +507,23 @@ export class ApPersonService implements OnModuleInit {
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
const [followingVisibility, followersVisibility] = await Promise.all(
[
this.isPublicCollection(person.following, resolver),
this.isPublicCollection(person.followers, resolver),
].map((p): Promise<'public' | 'private' | undefined> => p
.then(isPublic => isPublic ? 'public' : 'private')
.catch(err => {
if (!(err instanceof StatusError) || err.isRetryable) {
this.logger.error('error occurred while fetching following/followers collection', { stack: err });
// Do not update the visibiility on transient errors.
return undefined;
}
return 'private';
})
)
);
const bday = person['vcard:bday']?.match(/^\d{4}-\d{2}-\d{2}/);
const url = getOneApHrefNullable(person.url);
@ -561,6 +595,8 @@ export class ApPersonService implements OnModuleInit {
url,
fields,
description: _description,
followingVisibility,
followersVisibility,
birthday: bday?.[0] ?? null,
location: person['vcard:Address'] ?? null,
listenbrainz: person.listenbrainz ?? null,
@ -733,4 +769,16 @@ export class ApPersonService implements OnModuleInit {
return 'ok';
}
@bindThis
private async isPublicCollection(collection: string | ICollection | IOrderedCollection | undefined, resolver: Resolver): Promise<boolean> {
if (collection) {
const resolved = await resolver.resolveCollection(collection);
if (resolved.first || (resolved as ICollection).items || (resolved as IOrderedCollection).orderedItems) {
return true;
}
}
return false;
}
}

View file

@ -100,13 +100,15 @@ export interface IActivity extends IObject {
export interface ICollection extends IObject {
type: 'Collection';
totalItems: number;
items: ApObject;
first?: IObject | string;
items?: ApObject;
}
export interface IOrderedCollection extends IObject {
type: 'OrderedCollection';
totalItems: number;
orderedItems: ApObject;
first?: IObject | string;
orderedItems?: ApObject;
}
export const validPost = ['Note', 'Question', 'Article', 'Audio', 'Document', 'Image', 'Page', 'Video', 'Event'];

View file

@ -49,6 +49,7 @@ export class FlashEntityService {
title: flash.title,
summary: flash.summary,
script: flash.script,
visibility: flash.visibility,
likedCount: flash.likedCount,
isLiked: meId ? await this.flashLikesRepository.exists({ where: { flashId: flash.id, userId: meId } }) : undefined,
});

View file

@ -490,12 +490,12 @@ export class UserEntityService implements OnModuleInit {
const mastoapi = !isDetailed ? opts.userProfile ?? await this.userProfilesRepository.findOneByOrFail({ userId: user.id }) : null;
const followingCount = profile == null ? null :
(profile.followingVisibility === 'public') || isMe ? user.followingCount :
(profile.followingVisibility === 'public') || isMe || iAmModerator ? user.followingCount :
(profile.followingVisibility === 'followers') && (relation && relation.isFollowing) ? user.followingCount :
null;
const followersCount = profile == null ? null :
(profile.followersVisibility === 'public') || isMe ? user.followersCount :
(profile.followersVisibility === 'public') || isMe || iAmModerator ? user.followersCount :
(profile.followersVisibility === 'followers') && (relation && relation.isFollowing) ? user.followersCount :
null;

View file

@ -72,6 +72,10 @@ export class RedisKVCache<T> {
/**
* fetcherを呼び出して結果をキャッシュ&
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/
@bindThis
public async fetch(key: string): Promise<T> {
@ -172,6 +176,10 @@ export class RedisSingleCache<T> {
/**
* fetcherを呼び出して結果をキャッシュ&
* This awaits the call to Redis to ensure that the write succeeded, which is important for a few reasons:
* * Other code uses this to synchronize changes between worker processes. A failed write can internally de-sync the cluster.
* * Without an `await`, consecutive calls could race. An unlucky race could result in the older write overwriting the newer value.
* * Not awaiting here makes the entire cache non-consistent. The prevents many possible uses.
*/
@bindThis
public async fetch(): Promise<T> {

View file

@ -6,3 +6,7 @@
export type JsonValue = JsonArray | JsonObject | string | number | boolean | null;
export type JsonObject = {[K in string]?: JsonValue};
export type JsonArray = JsonValue[];
export function isJsonObject(value: JsonValue | undefined): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}

View file

@ -104,7 +104,3 @@ 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 function toSingleLast<T>(x: T | T[] | undefined): T | undefined {
return Array.isArray(x) ? x.at(-1) : x;
}

View file

@ -44,6 +44,11 @@ export const packedFlashSchema = {
type: 'string',
optional: false, nullable: false,
},
visibility: {
type: 'string',
optional: false, nullable: false,
enum: ['private', 'public'],
},
likedCount: {
type: 'number',
optional: false, nullable: true,

View file

@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js';
import { QueueService } from '@/core/QueueService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DeleteAccountService } from '@/core/DeleteAccountService.js';
export const meta = {
tags: ['admin'],
@ -33,9 +33,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private userEntityService: UserEntityService,
private queueService: QueueService,
private userSuspendService: UserSuspendService,
private deleteAccoountService: DeleteAccountService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -48,22 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('cannot delete a root account');
}
if (this.userEntityService.isLocalUser(user)) {
// 物理削除する前にDelete activityを送信する
await this.userSuspendService.doPostSuspend(user).catch(err => {});
this.queueService.createDeleteAccountJob(user, {
soft: false,
});
} else {
this.queueService.createDeleteAccountJob(user, {
soft: true, // リモートユーザーの削除は、完全にDBから物理削除してしまうと再度連合してきてアカウントが復活する可能性があるため、soft指定する
});
}
await this.usersRepository.update(user.id, {
isDeleted: true,
});
await this.deleteAccoountService.deleteAccount(user);
});
}
}

View file

@ -7,6 +7,7 @@ import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { MetaService } from '@/core/MetaService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export const meta = {
tags: ['admin', 'role'],
@ -33,12 +34,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
private metaService: MetaService,
private globalEventService: GlobalEventService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps) => {
super(meta, paramDef, async (ps, me) => {
const before = await this.metaService.fetch(true);
await this.metaService.update({
policies: ps.policies,
});
this.globalEventService.publishInternalEvent('policiesUpdated', ps.policies);
const after = await this.metaService.fetch(true);
this.globalEventService.publishInternalEvent('policiesUpdated', after.policies);
this.moderationLogService.log(me, 'updateServerSettings', {
before: before.policies,
after: after.policies,
});
});
}
}

View file

@ -3,18 +3,12 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsNull, Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, FollowingsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js';
import type { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import type { UsersRepository } from '@/models/_.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js';
export const meta = {
tags: ['admin'],
@ -38,13 +32,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
private userSuspendService: UserSuspendService,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private queueService: QueueService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -57,42 +46,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('cannot suspend moderator account');
}
await this.usersRepository.update(user.id, {
isSuspended: true,
});
this.moderationLogService.log(me, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
(async () => {
await this.userSuspendService.doPostSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
})();
await this.userSuspendService.suspend(user, me);
});
}
@bindThis
private async unFollowAll(follower: MiUser) {
const followings = await this.followingsRepository.find({
where: {
followerId: follower.id,
followeeId: Not(IsNull()),
},
});
const jobs: RelationshipJobData[] = [];
for (const following of followings) {
if (following.followeeId && following.followerId) {
jobs.push({
from: { id: following.followerId },
to: { id: following.followeeId },
silent: true,
});
}
}
this.queueService.createUnfollowJob(jobs);
}
}

View file

@ -6,7 +6,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository } from '@/models/_.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { UserSuspendService } from '@/core/UserSuspendService.js';
import { DI } from '@/di-symbols.js';
@ -33,7 +32,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private usersRepository: UsersRepository,
private userSuspendService: UserSuspendService,
private moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy({ id: ps.userId });
@ -42,17 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new Error('user not found');
}
await this.usersRepository.update(user.id, {
isSuspended: false,
});
this.moderationLogService.log(me, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
this.userSuspendService.doPostUnsuspend(user);
await this.userSuspendService.unsuspend(user, me);
});
}
}

View file

@ -4,9 +4,11 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { FlashsRepository } from '@/models/_.js';
import type { FlashsRepository, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -44,17 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.flashsRepository)
private flashsRepository: FlashsRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const flash = await this.flashsRepository.findOneBy({ id: ps.flashId });
if (flash == null) {
throw new ApiError(meta.errors.noSuchFlash);
}
if (flash.userId !== me.id) {
if (!await this.roleService.isModerator(me) && flash.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.flashsRepository.delete(flash.id);
if (flash.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: flash.userId });
this.moderationLogService.log(me, 'deleteFlash', {
flashId: flash.id,
flashUserId: flash.userId,
flashUserUsername: user.username,
flash,
});
}
});
}
}

View file

@ -5,8 +5,10 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { GalleryPostsRepository } from '@/models/_.js';
import type { GalleryPostsRepository, UsersRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../../error.js';
export const meta = {
@ -22,6 +24,12 @@ export const meta = {
code: 'NO_SUCH_POST',
id: 'ae52f367-4bd7-4ecd-afc6-5672fff427f5',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: 'c86e09de-1c48-43ac-a435-1c7e42ed4496',
},
},
} as const;
@ -38,18 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.galleryPostsRepository)
private galleryPostsRepository: GalleryPostsRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const post = await this.galleryPostsRepository.findOneBy({
id: ps.postId,
userId: me.id,
});
const post = await this.galleryPostsRepository.findOneBy({ id: ps.postId });
if (post == null) {
throw new ApiError(meta.errors.noSuchPost);
}
if (!await this.roleService.isModerator(me) && post.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.galleryPostsRepository.delete(post.id);
if (post.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: post.userId });
this.moderationLogService.log(me, 'deleteGalleryPost', {
postId: post.id,
postUserId: post.userId,
postUserUsername: user.username,
post,
});
}
});
}
}

View file

@ -4,9 +4,11 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import type { PagesRepository } from '@/models/_.js';
import type { PagesRepository, UsersRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -44,17 +46,35 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private moderationLogService: ModerationLogService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const page = await this.pagesRepository.findOneBy({ id: ps.pageId });
if (page == null) {
throw new ApiError(meta.errors.noSuchPage);
}
if (page.userId !== me.id) {
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.pagesRepository.delete(page.id);
if (page.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
this.moderationLogService.log(me, 'deletePage', {
pageId: page.id,
pageUserId: page.userId,
pageUserUsername: user.username,
page,
});
}
});
}
}

View file

@ -11,6 +11,7 @@ import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -81,6 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private utilityService: UtilityService,
private followingEntityService: FollowingEntityService,
private queryService: QueryService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null
@ -93,23 +95,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.followersVisibility === 'private') {
if (me == null || (me.id !== user.id)) {
throw new ApiError(meta.errors.forbidden);
}
} else if (profile.followersVisibility === 'followers') {
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
if (!isFollowing) {
if (profile.followersVisibility !== 'public' && !await this.roleService.isModerator(me)) {
if (profile.followersVisibility === 'private') {
if (me == null || (me.id !== user.id)) {
throw new ApiError(meta.errors.forbidden);
}
} else if (profile.followersVisibility === 'followers') {
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}
}
}
}

View file

@ -12,6 +12,7 @@ import { QueryService } from '@/core/QueryService.js';
import { FollowingEntityService } from '@/core/entities/FollowingEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -90,6 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private utilityService: UtilityService,
private followingEntityService: FollowingEntityService,
private queryService: QueryService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const user = await this.usersRepository.findOneBy(ps.userId != null
@ -102,23 +104,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: user.id });
if (profile.followingVisibility === 'private') {
if (me == null || (me.id !== user.id)) {
throw new ApiError(meta.errors.forbidden);
}
} else if (profile.followingVisibility === 'followers') {
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
if (!isFollowing) {
if (profile.followingVisibility !== 'public' && !await this.roleService.isModerator(me)) {
if (profile.followingVisibility === 'private') {
if (me == null || (me.id !== user.id)) {
throw new ApiError(meta.errors.forbidden);
}
} else if (profile.followingVisibility === 'followers') {
if (me == null) {
throw new ApiError(meta.errors.forbidden);
} else if (me.id !== user.id) {
const isFollowing = await this.followingsRepository.exists({
where: {
followeeId: user.id,
followerId: me.id,
},
});
if (!isFollowing) {
throw new ApiError(meta.errors.forbidden);
}
}
}
}

View file

@ -14,7 +14,8 @@ import { CacheService } from '@/core/CacheService.js';
import { MiFollowing, MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
@ -149,8 +150,6 @@ export default class Connection {
const { type, body } = obj;
if (typeof body !== 'object' || body === null || Array.isArray(body)) return;
switch (type) {
case 'readNotification': this.onReadNotification(body); break;
case 'subNote': this.onSubscribeNote(body); break;
@ -191,7 +190,8 @@ export default class Connection {
}
@bindThis
private readNote(body: JsonObject) {
private readNote(body: JsonValue | undefined) {
if (!isJsonObject(body)) return;
const id = body.id;
const note = this.cachedNotes.find(n => n.id === id);
@ -203,7 +203,7 @@ export default class Connection {
}
@bindThis
private onReadNotification(payload: JsonObject) {
private onReadNotification(payload: JsonValue | undefined) {
this.notificationService.readAllNotification(this.user!.id);
}
@ -211,7 +211,8 @@ export default class Connection {
* 稿
*/
@bindThis
private onSubscribeNote(payload: JsonObject) {
private onSubscribeNote(payload: JsonValue | undefined) {
if (!isJsonObject(payload)) return;
if (!payload.id || typeof payload.id !== 'string') return;
const current = this.subscribingNotes[payload.id] ?? 0;
@ -227,7 +228,8 @@ export default class Connection {
* 稿
*/
@bindThis
private onUnsubscribeNote(payload: JsonObject) {
private onUnsubscribeNote(payload: JsonValue | undefined) {
if (!isJsonObject(payload)) return;
if (!payload.id || typeof payload.id !== 'string') return;
const current = this.subscribingNotes[payload.id];
@ -265,12 +267,13 @@ export default class Connection {
*
*/
@bindThis
private onChannelConnectRequested(payload: JsonObject) {
private onChannelConnectRequested(payload: JsonValue | undefined) {
if (!isJsonObject(payload)) return;
const { channel, id, params, pong } = payload;
if (typeof id !== 'string') return;
if (typeof channel !== 'string') return;
if (typeof pong !== 'boolean' && typeof pong !== 'undefined' && pong !== null) return;
if (typeof params !== 'undefined' && (typeof params !== 'object' || params === null || Array.isArray(params))) return;
if (typeof params !== 'undefined' && !isJsonObject(params)) return;
this.connectChannel(id, params, channel, pong ?? undefined);
}
@ -278,7 +281,8 @@ export default class Connection {
*
*/
@bindThis
private onChannelDisconnectRequested(payload: JsonObject) {
private onChannelDisconnectRequested(payload: JsonValue | undefined) {
if (!isJsonObject(payload)) return;
const { id } = payload;
if (typeof id !== 'string') return;
this.disconnectChannel(id);
@ -350,7 +354,8 @@ export default class Connection {
* @param data
*/
@bindThis
private onChannelMessageRequested(data: JsonObject) {
private onChannelMessageRequested(data: JsonValue | undefined) {
if (!isJsonObject(data)) return;
if (typeof data.id !== 'string') return;
if (typeof data.type !== 'string') return;
if (typeof data.body === 'undefined') return;

View file

@ -6,6 +6,7 @@
import Xev from 'xev';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -36,7 +37,7 @@ class QueueStatsChannel extends Channel {
public onMessage(type: string, body: JsonValue) {
switch (type) {
case 'requestLog':
if (typeof body !== 'object' || body === null || Array.isArray(body)) return;
if (!isJsonObject(body)) return;
if (typeof body.id !== 'string') return;
if (typeof body.length !== 'number') return;
ev.once(`queueStatsLog:${body.id}`, statsLog => {

View file

@ -9,8 +9,10 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { ReversiService } from '@/core/ReversiService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
import { reversiUpdateKeys } from 'misskey-js';
class ReversiGameChannel extends Channel {
public readonly chName = 'reversiGame';
@ -44,16 +46,17 @@ class ReversiGameChannel extends Channel {
this.ready(body);
break;
case 'updateSettings':
if (typeof body !== 'object' || body === null || Array.isArray(body)) return;
if (typeof body.key !== 'string') return;
if (typeof body.value !== 'object' || body.value === null || Array.isArray(body.value)) return;
if (!isJsonObject(body)) return;
if (!this.reversiService.isValidReversiUpdateKey(body.key)) return;
if (!this.reversiService.isValidReversiUpdateValue(body.key, body.value)) return;
this.updateSettings(body.key, body.value);
break;
case 'cancel':
this.cancelGame();
break;
case 'putStone':
if (typeof body !== 'object' || body === null || Array.isArray(body)) return;
if (!isJsonObject(body)) return;
if (typeof body.pos !== 'number') return;
if (typeof body.id !== 'string') return;
this.putStone(body.pos, body.id);
@ -63,7 +66,7 @@ class ReversiGameChannel extends Channel {
}
@bindThis
private async updateSettings(key: string, value: JsonObject) {
private async updateSettings<K extends typeof reversiUpdateKeys[number]>(key: K, value: MiReversiGame[K]) {
if (this.user == null) return;
this.reversiService.updateSettings(this.gameId!, this.user, key, value);

View file

@ -6,6 +6,7 @@
import Xev from 'xev';
import { Injectable } from '@nestjs/common';
import { bindThis } from '@/decorators.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import Channel, { type MiChannelService } from '../channel.js';
@ -36,7 +37,7 @@ class ServerStatsChannel extends Channel {
public onMessage(type: string, body: JsonValue) {
switch (type) {
case 'requestLog':
if (typeof body !== 'object' || body === null || Array.isArray(body)) return;
if (!isJsonObject(body)) return;
ev.once(`serverStatsLog:${body.id}`, statsLog => {
this.send('statsLog', statsLog);
});

View file

@ -32,6 +32,7 @@ html
meta(property='og:site_name' content= instanceName || 'Sharkey')
meta(property='instance_url' content= instanceUrl)
meta(name='viewport' content='width=device-width, initial-scale=1')
meta(name='format-detection' content='telephone=no,date=no,address=no,email=no,url=no')
link(rel='icon' href= icon || '/favicon.ico')
link(rel='apple-touch-icon' href= appleTouchIcon || '/apple-touch-icon.png')
link(rel='manifest' href='/manifest.json')

View file

@ -98,6 +98,10 @@ export const moderationLogTypes = [
'createAbuseReportNotificationRecipient',
'updateAbuseReportNotificationRecipient',
'deleteAbuseReportNotificationRecipient',
'deleteAccount',
'deletePage',
'deleteFlash',
'deleteGalleryPost',
] as const;
export type ModerationLogPayloads = {
@ -321,6 +325,29 @@ export type ModerationLogPayloads = {
recipientId: string;
recipient: any;
};
deleteAccount: {
userId: string;
userUsername: string;
userHost: string | null;
};
deletePage: {
pageId: string;
pageUserId: string;
pageUserUsername: string;
page: any;
};
deleteFlash: {
flashId: string;
flashUserId: string;
flashUserUsername: string;
flash: any;
};
deleteGalleryPost: {
postId: string;
postUserId: string;
postUserUsername: string;
post: any;
};
};
export type Serialized<T> = {

View file

@ -20,7 +20,8 @@ import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IObject, IPost } from '@/core/activitypub/type.js';
import { MiMeta, MiNote } from '@/models/_.js';
import { MiMeta, MiNote, UserProfilesRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
import { MetaService } from '@/core/MetaService.js';
@ -86,6 +87,7 @@ async function createRandomRemoteUser(
}
describe('ActivityPub', () => {
let userProfilesRepository: UserProfilesRepository;
let imageService: ApImageService;
let noteService: ApNoteService;
let personService: ApPersonService;
@ -127,6 +129,8 @@ describe('ActivityPub', () => {
await app.init();
app.enableShutdownHooks();
userProfilesRepository = app.get(DI.userProfilesRepository);
noteService = app.get<ApNoteService>(ApNoteService);
personService = app.get<ApPersonService>(ApPersonService);
rendererService = app.get<ApRendererService>(ApRendererService);
@ -205,6 +209,53 @@ describe('ActivityPub', () => {
});
});
describe('Collection visibility', () => {
test('Public following/followers', async () => {
const actor = createRandomActor();
actor.following = {
id: `${actor.id}/following`,
type: 'OrderedCollection',
totalItems: 0,
first: `${actor.id}/following?page=1`,
};
actor.followers = `${actor.id}/followers`;
resolver.register(actor.id, actor);
resolver.register(actor.followers, {
id: actor.followers,
type: 'OrderedCollection',
totalItems: 0,
first: `${actor.followers}?page=1`,
});
const user = await personService.createPerson(actor.id, resolver);
const userProfile = await userProfilesRepository.findOneByOrFail({ userId: user.id });
assert.deepStrictEqual(userProfile.followingVisibility, 'public');
assert.deepStrictEqual(userProfile.followersVisibility, 'public');
});
test('Private following/followers', async () => {
const actor = createRandomActor();
actor.following = {
id: `${actor.id}/following`,
type: 'OrderedCollection',
totalItems: 0,
// first: …
};
actor.followers = `${actor.id}/followers`;
resolver.register(actor.id, actor);
//resolver.register(actor.followers, { … });
const user = await personService.createPerson(actor.id, resolver);
const userProfile = await userProfilesRepository.findOneByOrFail({ userId: user.id });
assert.deepStrictEqual(userProfile.followingVisibility, 'private');
assert.deepStrictEqual(userProfile.followersVisibility, 'private');
});
});
describe('Renderer', () => {
test('Render an announce with visibility: followers', () => {
rendererService.renderAnnounce('https://example.com/notes/00example', {