Merge branch 'develop'

This commit is contained in:
syuilo 2023-02-04 14:23:38 +09:00
commit 9bde9edcf6
30 changed files with 280 additions and 180 deletions

View file

@ -130,6 +130,7 @@ proxyBypassHosts:
#proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5 #proxySmtp: socks5://127.0.0.1:1080 # use SOCKS5
# Media Proxy # Media Proxy
# Reference Implementation: https://github.com/misskey-dev/media-proxy
#mediaProxy: https://example.com/proxy #mediaProxy: https://example.com/proxy
# Proxy remote files (default: false) # Proxy remote files (default: false)

View file

@ -9,6 +9,17 @@
You should also include the user name that made the change. You should also include the user name that made the change.
--> -->
## 13.3.2 (2023/02/04)
### Improvements
- 外部メディアプロキシへの対応を強化しました
外部メディアプロキシのFastify実装を作りました
https://github.com/misskey-dev/media-proxy
- Server: improve performance
### Bugfixes
- Client: validate urls to improve security
## 13.3.1 (2023/02/04) ## 13.3.1 (2023/02/04)
### Bugfixes ### Bugfixes

View file

@ -1,6 +1,6 @@
{ {
"name": "misskey", "name": "misskey",
"version": "13.3.1", "version": "13.3.2",
"codename": "nasubi", "codename": "nasubi",
"repository": { "repository": {
"type": "git", "type": "git",

View file

@ -87,6 +87,8 @@ export type Mixin = {
userAgent: string; userAgent: string;
clientEntry: string; clientEntry: string;
clientManifestExists: boolean; clientManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
}; };
export type Config = Source & Mixin; export type Config = Source & Mixin;
@ -135,6 +137,13 @@ export function loadConfig() {
mixin.clientEntry = clientManifest['src/init.ts']; mixin.clientEntry = clientManifest['src/init.ts'];
mixin.clientManifestExists = clientManifestExists; mixin.clientManifestExists = clientManifestExists;
const externalMediaProxy = config.mediaProxy ?
config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy
: null;
const internalMediaProxy = `${mixin.scheme}://${mixin.host}/proxy`;
mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy;
mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy;
if (!config.redis.prefix) config.redis.prefix = mixin.host; if (!config.redis.prefix) config.redis.prefix = mixin.host;
return Object.assign(config, mixin); return Object.assign(config, mixin);

View file

@ -10,10 +10,9 @@ import { isUserRelated } from '@/misc/is-user-related.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { PushNotificationService } from '@/core/PushNotificationService.js'; import { PushNotificationService } from '@/core/PushNotificationService.js';
import * as Acct from '@/misc/acct.js'; import * as Acct from '@/misc/acct.js';
import { Cache } from '@/misc/cache.js';
import type { Packed } from '@/misc/schema.js'; import type { Packed } from '@/misc/schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js'; import type { MutingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import { StreamMessages } from '@/server/api/stream/types.js';
@ -23,7 +22,6 @@ import type { OnApplicationShutdown } from '@nestjs/common';
export class AntennaService implements OnApplicationShutdown { export class AntennaService implements OnApplicationShutdown {
private antennasFetched: boolean; private antennasFetched: boolean;
private antennas: Antenna[]; private antennas: Antenna[];
private blockingCache: Cache<User['id'][]>;
constructor( constructor(
@Inject(DI.redisSubscriber) @Inject(DI.redisSubscriber)
@ -32,9 +30,6 @@ export class AntennaService implements OnApplicationShutdown {
@Inject(DI.mutingsRepository) @Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.notesRepository) @Inject(DI.notesRepository)
private notesRepository: NotesRepository, private notesRepository: NotesRepository,
@ -52,14 +47,13 @@ export class AntennaService implements OnApplicationShutdown {
private utilityService: UtilityService, private utilityService: UtilityService,
private idService: IdService, private idService: IdService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService, private pushNotificationService: PushNotificationService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private antennaEntityService: AntennaEntityService, private antennaEntityService: AntennaEntityService,
) { ) {
this.antennasFetched = false; this.antennasFetched = false;
this.antennas = []; this.antennas = [];
this.blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
this.redisSubscriber.on('message', this.onRedisMessage); this.redisSubscriber.on('message', this.onRedisMessage);
} }
@ -109,7 +103,7 @@ export class AntennaService implements OnApplicationShutdown {
read: read, read: read,
}); });
this.globalEventServie.publishAntennaStream(antenna.id, 'note', note); this.globalEventService.publishAntennaStream(antenna.id, 'note', note);
if (!read) { if (!read) {
const mutings = await this.mutingsRepository.find({ const mutings = await this.mutingsRepository.find({
@ -139,7 +133,7 @@ export class AntennaService implements OnApplicationShutdown {
setTimeout(async () => { setTimeout(async () => {
const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false }); const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false });
if (unread) { if (unread) {
this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna); this.globalEventService.publishMainStream(antenna.userId, 'unreadAntenna', antenna);
this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', { this.pushNotificationService.pushNotification(antenna.userId, 'unreadAntennaNote', {
antenna: { id: antenna.id, name: antenna.name }, antenna: { id: antenna.id, name: antenna.name },
note: await this.noteEntityService.pack(note), note: await this.noteEntityService.pack(note),
@ -155,10 +149,6 @@ export class AntennaService implements OnApplicationShutdown {
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }): Promise<boolean> { public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }): Promise<boolean> {
if (note.visibility === 'specified') return false; if (note.visibility === 'specified') return false;
if (note.visibility === 'followers') return false; if (note.visibility === 'followers') return false;
// アンテナ作成者がノート作成者にブロックされていたらスキップ
const blockings = await this.blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
if (blockings.some(blocking => blocking === antenna.userId)) return false;
if (!antenna.withReplies && note.replyId != null) return false; if (!antenna.withReplies && note.replyId != null) return false;

View file

@ -26,7 +26,7 @@ export class CreateNotificationService {
private notificationEntityService: NotificationEntityService, private notificationEntityService: NotificationEntityService,
private idService: IdService, private idService: IdService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService, private pushNotificationService: PushNotificationService,
) { ) {
} }
@ -60,7 +60,7 @@ export class CreateNotificationService {
const packed = await this.notificationEntityService.pack(notification, {}); const packed = await this.notificationEntityService.pack(notification, {});
// Publish notification event // Publish notification event
this.globalEventServie.publishMainStream(notifieeId, 'notification', packed); this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(async () => { setTimeout(async () => {
@ -77,7 +77,7 @@ export class CreateNotificationService {
} }
//#endregion //#endregion
this.globalEventServie.publishMainStream(notifieeId, 'unreadNotification', packed); this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));

View file

@ -120,7 +120,7 @@ export class CustomEmojiService {
const url = isLocal const url = isLocal
? emojiUrl ? emojiUrl
: this.config.proxyRemoteFiles : this.config.proxyRemoteFiles
? `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}` ? `${this.config.mediaProxy}/emoji.webp?${query({ url: emojiUrl })}`
: emojiUrl; : emojiUrl;
return url; return url;

View file

@ -14,7 +14,7 @@ export class DeleteAccountService {
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
private queueService: QueueService, private queueService: QueueService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
} }
@ -38,6 +38,6 @@ export class DeleteAccountService {
}); });
// Terminate streaming // Terminate streaming
this.globalEventServie.publishUserEvent(user.id, 'terminate', {}); this.globalEventService.publishUserEvent(user.id, 'terminate', {});
} }
} }

View file

@ -175,7 +175,7 @@ export class NoteCreateService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private idService: IdService, private idService: IdService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private queueService: QueueService, private queueService: QueueService,
private noteReadService: NoteReadService, private noteReadService: NoteReadService,
private createNotificationService: CreateNotificationService, private createNotificationService: CreateNotificationService,
@ -535,7 +535,7 @@ export class NoteCreateService {
// Pack the note // Pack the note
const noteObj = await this.noteEntityService.pack(note); const noteObj = await this.noteEntityService.pack(note);
this.globalEventServie.publishNotesStream(noteObj); this.globalEventService.publishNotesStream(noteObj);
this.webhookService.getActiveWebhooks().then(webhooks => { this.webhookService.getActiveWebhooks().then(webhooks => {
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note')); webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
@ -561,7 +561,7 @@ export class NoteCreateService {
if (!threadMuted) { if (!threadMuted) {
nm.push(data.reply.userId, 'reply'); nm.push(data.reply.userId, 'reply');
this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj); this.globalEventService.publishMainStream(data.reply.userId, 'reply', noteObj);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -584,7 +584,7 @@ export class NoteCreateService {
// Publish event // Publish event
if ((user.id !== data.renote.userId) && data.renote.userHost === null) { if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj); this.globalEventService.publishMainStream(data.renote.userId, 'renote', noteObj);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -684,7 +684,7 @@ export class NoteCreateService {
detail: true, detail: true,
}); });
this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote); this.globalEventService.publishMainStream(u.id, 'mention', detailPackedNote);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
for (const webhook of webhooks) { for (const webhook of webhooks) {

View file

@ -34,7 +34,7 @@ export class NoteDeleteService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private relayService: RelayService, private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService, private federatedInstanceService: FederatedInstanceService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
@ -63,7 +63,7 @@ export class NoteDeleteService {
} }
if (!quiet) { if (!quiet) {
this.globalEventServie.publishNoteStream(note.id, 'deleted', { this.globalEventService.publishNoteStream(note.id, 'deleted', {
deletedAt: deletedAt, deletedAt: deletedAt,
}); });

View file

@ -40,7 +40,7 @@ export class NoteReadService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private notificationService: NotificationService, private notificationService: NotificationService,
private antennaService: AntennaService, private antennaService: AntennaService,
private pushNotificationService: PushNotificationService, private pushNotificationService: PushNotificationService,
@ -87,13 +87,13 @@ export class NoteReadService {
if (exist == null) return; if (exist == null) return;
if (params.isMentioned) { if (params.isMentioned) {
this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id); this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
} }
if (params.isSpecified) { if (params.isSpecified) {
this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id); this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
} }
if (note.channelId) { if (note.channelId) {
this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id); this.globalEventService.publishMainStream(userId, 'unreadChannel', note.id);
} }
}, 2000); }, 2000);
} }
@ -155,7 +155,7 @@ export class NoteReadService {
}).then(mentionsCount => { }).then(mentionsCount => {
if (mentionsCount === 0) { if (mentionsCount === 0) {
// 全て既読になったイベントを発行 // 全て既読になったイベントを発行
this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions'); this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
} }
}); });
@ -165,7 +165,7 @@ export class NoteReadService {
}).then(specifiedCount => { }).then(specifiedCount => {
if (specifiedCount === 0) { if (specifiedCount === 0) {
// 全て既読になったイベントを発行 // 全て既読になったイベントを発行
this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes'); this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
} }
}); });
@ -175,7 +175,7 @@ export class NoteReadService {
}).then(channelNoteCount => { }).then(channelNoteCount => {
if (channelNoteCount === 0) { if (channelNoteCount === 0) {
// 全て既読になったイベントを発行 // 全て既読になったイベントを発行
this.globalEventServie.publishMainStream(userId, 'readAllChannels'); this.globalEventService.publishMainStream(userId, 'readAllChannels');
} }
}); });
@ -200,14 +200,14 @@ export class NoteReadService {
}); });
if (count === 0) { if (count === 0) {
this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna); this.globalEventService.publishMainStream(userId, 'readAntenna', antenna);
this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id }); this.pushNotificationService.pushNotification(userId, 'readAntenna', { antennaId: antenna.id });
} }
} }
this.userEntityService.getHasUnreadAntenna(userId).then(unread => { this.userEntityService.getHasUnreadAntenna(userId).then(unread => {
if (!unread) { if (!unread) {
this.globalEventServie.publishMainStream(userId, 'readAllAntennas'); this.globalEventService.publishMainStream(userId, 'readAllAntennas');
this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined); this.pushNotificationService.pushNotification(userId, 'readAllAntennas', undefined);
} }
}); });

View file

@ -1,17 +1,17 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Not } from 'typeorm'; import { Not } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { NotesRepository, UsersRepository, BlockingsRepository, PollsRepository, PollVotesRepository } from '@/models/index.js'; import type { NotesRepository, UsersRepository, PollsRepository, PollVotesRepository } from '@/models/index.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
import { RelayService } from '@/core/RelayService.js'; import { RelayService } from '@/core/RelayService.js';
import type { CacheableUser } from '@/models/entities/User.js'; import type { CacheableUser } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js'; import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
@Injectable() @Injectable()
export class PollService { export class PollService {
@ -28,14 +28,11 @@ export class PollService {
@Inject(DI.pollVotesRepository) @Inject(DI.pollVotesRepository)
private pollVotesRepository: PollVotesRepository, private pollVotesRepository: PollVotesRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
private relayService: RelayService, private relayService: RelayService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private createNotificationService: CreateNotificationService, private userBlockingService: UserBlockingService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
) { ) {
@ -52,11 +49,8 @@ export class PollService {
// Check blocking // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const block = await this.blockingsRepository.findOneBy({ const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
blockerId: note.userId, if (blocked) {
blockeeId: user.id,
});
if (block) {
throw new Error('blocked'); throw new Error('blocked');
} }
} }
@ -88,7 +82,7 @@ export class PollService {
const index = choice + 1; // In SQL, array index is 1 based const index = choice + 1; // In SQL, array index is 1 based
await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`); await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
this.globalEventServie.publishNoteStream(note.id, 'pollVoted', { this.globalEventService.publishNoteStream(note.id, 'pollVoted', {
choice: choice, choice: choice,
userId: user.id, userId: user.id,
}); });

View file

@ -18,7 +18,8 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { MetaService } from '@/core/MetaService.js'; import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UtilityService } from './UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
const legacies: Record<string, string> = { const legacies: Record<string, string> = {
'like': '👍', 'like': '👍',
@ -73,8 +74,9 @@ export class ReactionService {
private metaService: MetaService, private metaService: MetaService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService, private noteEntityService: NoteEntityService,
private userBlockingService: UserBlockingService,
private idService: IdService, private idService: IdService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private apDeliverManagerService: ApDeliverManagerService, private apDeliverManagerService: ApDeliverManagerService,
private createNotificationService: CreateNotificationService, private createNotificationService: CreateNotificationService,
@ -86,11 +88,8 @@ export class ReactionService {
public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string) { public async create(user: { id: User['id']; host: User['host']; isBot: User['isBot'] }, note: Note, reaction?: string) {
// Check blocking // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const block = await this.blockingsRepository.findOneBy({ const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
blockerId: note.userId, if (blocked) {
blockeeId: user.id,
});
if (block) {
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7'); throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
} }
} }
@ -157,7 +156,7 @@ export class ReactionService {
select: ['name', 'host', 'originalUrl', 'publicUrl'], select: ['name', 'host', 'originalUrl', 'publicUrl'],
}); });
this.globalEventServie.publishNoteStream(note.id, 'reacted', { this.globalEventService.publishNoteStream(note.id, 'reacted', {
reaction: decodedReaction.reaction, reaction: decodedReaction.reaction,
emoji: emoji != null ? { emoji: emoji != null ? {
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`, name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
@ -229,7 +228,7 @@ export class ReactionService {
if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1); if (!user.isBot) this.notesRepository.decrement({ id: note.id }, 'score', 1);
this.globalEventServie.publishNoteStream(note.id, 'unreacted', { this.globalEventService.publishNoteStream(note.id, 'unreacted', {
reaction: this.decodeReaction(exist.reaction).reaction, reaction: this.decodeReaction(exist.reaction).reaction,
userId: user.id, userId: user.id,
}); });

View file

@ -1,5 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import Redis from 'ioredis';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { CacheableUser, User } from '@/models/entities/User.js'; import type { CacheableUser, User } from '@/models/entities/User.js';
import type { Blocking } from '@/models/entities/Blocking.js'; import type { Blocking } from '@/models/entities/Blocking.js';
@ -7,7 +8,6 @@ import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js'; import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import logger from '@/logger.js';
import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, UserListsRepository, UserListJoiningsRepository } from '@/models/index.js';
import Logger from '@/logger.js'; import Logger from '@/logger.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -15,12 +15,20 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { LoggerService } from '@/core/LoggerService.js'; import { LoggerService } from '@/core/LoggerService.js';
import { WebhookService } from '@/core/WebhookService.js'; import { WebhookService } from '@/core/WebhookService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { Cache } from '@/misc/cache.js';
import { StreamMessages } from '@/server/api/stream/types.js';
@Injectable() @Injectable()
export class UserBlockingService { export class UserBlockingService implements OnApplicationShutdown {
private logger: Logger; private logger: Logger;
// キーがユーザーIDで、値がそのユーザーがブロックしているユーザーのIDのリストなキャッシュ
private blockingsByUserIdCache: Cache<User['id'][]>;
constructor( constructor(
@Inject(DI.redisSubscriber)
private redisSubscriber: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -42,13 +50,44 @@ export class UserBlockingService {
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private idService: IdService, private idService: IdService,
private queueService: QueueService, private queueService: QueueService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private webhookService: WebhookService, private webhookService: WebhookService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
private loggerService: LoggerService, private loggerService: LoggerService,
) { ) {
this.logger = this.loggerService.getLogger('user-block'); this.logger = this.loggerService.getLogger('user-block');
this.blockingsByUserIdCache = new Cache<User['id'][]>(Infinity);
this.redisSubscriber.on('message', this.onMessage);
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload'];
switch (type) {
case 'blockingCreated': {
const cached = this.blockingsByUserIdCache.get(body.blockerId);
if (cached) {
this.blockingsByUserIdCache.set(body.blockerId, [...cached, ...[body.blockeeId]]);
}
break;
}
case 'blockingDeleted': {
const cached = this.blockingsByUserIdCache.get(body.blockerId);
if (cached) {
this.blockingsByUserIdCache.set(body.blockerId, cached.filter(x => x !== body.blockeeId));
}
break;
}
default:
break;
}
}
} }
@bindThis @bindThis
@ -72,6 +111,11 @@ export class UserBlockingService {
await this.blockingsRepository.insert(blocking); await this.blockingsRepository.insert(blocking);
this.globalEventService.publishInternalEvent('blockingCreated', {
blockerId: blocker.id,
blockeeId: blockee.id,
});
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking)); const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking));
this.queueService.deliver(blocker, content, blockee.inbox); this.queueService.deliver(blocker, content, blockee.inbox);
@ -97,15 +141,15 @@ export class UserBlockingService {
if (this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isLocalUser(followee)) {
this.userEntityService.pack(followee, followee, { this.userEntityService.pack(followee, followee, {
detail: true, detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
} }
if (this.userEntityService.isLocalUser(follower)) { if (this.userEntityService.isLocalUser(follower)) {
this.userEntityService.pack(followee, follower, { this.userEntityService.pack(followee, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -152,8 +196,8 @@ export class UserBlockingService {
this.userEntityService.pack(followee, follower, { this.userEntityService.pack(followee, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -210,10 +254,31 @@ export class UserBlockingService {
await this.blockingsRepository.delete(blocking.id); await this.blockingsRepository.delete(blocking.id);
this.globalEventService.publishInternalEvent('blockingDeleted', {
blockerId: blocker.id,
blockeeId: blockee.id,
});
// deliver if remote bloking // deliver if remote bloking
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker)); const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker));
this.queueService.deliver(blocker, content, blockee.inbox); this.queueService.deliver(blocker, content, blockee.inbox);
} }
} }
@bindThis
public async checkBlocked(blockerId: User['id'], blockeeId: User['id']): Promise<boolean> {
const blockedUserIds = await this.blockingsByUserIdCache.fetch(blockerId, () => this.blockingsRepository.find({
where: {
blockerId,
},
select: ['blockeeId'],
}).then(records => records.map(record => record.blockeeId)));
return blockedUserIds.includes(blockeeId);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined) {
this.redisSubscriber.off('message', this.onMessage);
}
} }

View file

@ -12,10 +12,11 @@ import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { WebhookService } from '@/core/WebhookService.js'; import { WebhookService } from '@/core/WebhookService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { BlockingsRepository, FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { FollowingsRepository, FollowRequestsRepository, InstancesRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import Logger from '../logger.js'; import Logger from '../logger.js';
const logger = new Logger('following/create'); const logger = new Logger('following/create');
@ -48,21 +49,18 @@ export class UserFollowingService {
@Inject(DI.followRequestsRepository) @Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository, private followRequestsRepository: FollowRequestsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.instancesRepository) @Inject(DI.instancesRepository)
private instancesRepository: InstancesRepository, private instancesRepository: InstancesRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private userBlockingService: UserBlockingService,
private idService: IdService, private idService: IdService,
private queueService: QueueService, private queueService: QueueService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private createNotificationService: CreateNotificationService, private createNotificationService: CreateNotificationService,
private federatedInstanceService: FederatedInstanceService, private federatedInstanceService: FederatedInstanceService,
private webhookService: WebhookService, private webhookService: WebhookService,
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private globalEventService: GlobalEventService,
private perUserFollowingChart: PerUserFollowingChart, private perUserFollowingChart: PerUserFollowingChart,
private instanceChart: InstanceChart, private instanceChart: InstanceChart,
) { ) {
@ -77,26 +75,20 @@ export class UserFollowingService {
// check blocking // check blocking
const [blocking, blocked] = await Promise.all([ const [blocking, blocked] = await Promise.all([
this.blockingsRepository.findOneBy({ this.userBlockingService.checkBlocked(follower.id, followee.id),
blockerId: follower.id, this.userBlockingService.checkBlocked(followee.id, follower.id),
blockeeId: followee.id,
}),
this.blockingsRepository.findOneBy({
blockerId: followee.id,
blockeeId: follower.id,
}),
]); ]);
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) { if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) {
// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。 // リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee)); const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
this.queueService.deliver(followee, content, follower.inbox); this.queueService.deliver(followee, content, follower.inbox);
return; return;
} else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) { } else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) {
// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。 // リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
await this.blockingsRepository.delete(blocking.id); await this.userBlockingService.unblock(follower, followee);
} else { } else {
// それ以外は単純に例外 // それ以外は単純に例外
if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking'); if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked'); if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
} }
@ -227,8 +219,8 @@ export class UserFollowingService {
this.userEntityService.pack(followee.id, follower, { this.userEntityService.pack(followee.id, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); this.globalEventService.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>); this.globalEventService.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -242,7 +234,7 @@ export class UserFollowingService {
// Publish followed event // Publish followed event
if (this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isLocalUser(followee)) {
this.userEntityService.pack(follower.id, followee).then(async packed => { this.userEntityService.pack(follower.id, followee).then(async packed => {
this.globalEventServie.publishMainStream(followee.id, 'followed', packed); this.globalEventService.publishMainStream(followee.id, 'followed', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -288,8 +280,8 @@ export class UserFollowingService {
this.userEntityService.pack(followee.id, follower, { this.userEntityService.pack(followee.id, follower, {
detail: true, detail: true,
}).then(async packed => { }).then(async packed => {
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed); this.globalEventService.publishUserEvent(follower.id, 'unfollow', packed);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed); this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) { for (const webhook of webhooks) {
@ -357,14 +349,8 @@ export class UserFollowingService {
// check blocking // check blocking
const [blocking, blocked] = await Promise.all([ const [blocking, blocked] = await Promise.all([
this.blockingsRepository.findOneBy({ this.userBlockingService.checkBlocked(follower.id, followee.id),
blockerId: follower.id, this.userBlockingService.checkBlocked(followee.id, follower.id),
blockeeId: followee.id,
}),
this.blockingsRepository.findOneBy({
blockerId: followee.id,
blockeeId: follower.id,
}),
]); ]);
if (blocking != null) throw new Error('blocking'); if (blocking != null) throw new Error('blocking');
@ -388,11 +374,11 @@ export class UserFollowingService {
// Publish receiveRequest event // Publish receiveRequest event
if (this.userEntityService.isLocalUser(followee)) { if (this.userEntityService.isLocalUser(followee)) {
this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed)); this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventService.publishMainStream(followee.id, 'receiveFollowRequest', packed));
this.userEntityService.pack(followee.id, followee, { this.userEntityService.pack(followee.id, followee, {
detail: true, detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
// 通知を作成 // 通知を作成
this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', { this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', {
@ -440,7 +426,7 @@ export class UserFollowingService {
this.userEntityService.pack(followee.id, followee, { this.userEntityService.pack(followee.id, followee, {
detail: true, detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
} }
@bindThis @bindThis
@ -468,7 +454,7 @@ export class UserFollowingService {
this.userEntityService.pack(followee.id, followee, { this.userEntityService.pack(followee.id, followee, {
detail: true, detail: true,
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed)); }).then(packed => this.globalEventService.publishMainStream(followee.id, 'meUpdated', packed));
} }
@bindThis @bindThis
@ -583,8 +569,8 @@ export class UserFollowingService {
detail: true, detail: true,
}); });
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee); this.globalEventService.publishUserEvent(follower.id, 'unfollow', packedFollowee);
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee); this.globalEventService.publishMainStream(follower.id, 'unfollow', packedFollowee);
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow')); const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
for (const webhook of webhooks) { for (const webhook of webhooks) {

View file

@ -25,7 +25,7 @@ export class UserListService {
private idService: IdService, private idService: IdService,
private userFollowingService: UserFollowingService, private userFollowingService: UserFollowingService,
private roleService: RoleService, private roleService: RoleService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
private proxyAccountService: ProxyAccountService, private proxyAccountService: ProxyAccountService,
) { ) {
} }
@ -46,7 +46,7 @@ export class UserListService {
userListId: list.id, userListId: list.id,
} as UserListJoining); } as UserListJoining);
this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
if (this.userEntityService.isRemoteUser(target)) { if (this.userEntityService.isRemoteUser(target)) {

View file

@ -18,7 +18,7 @@ export class UserMutingService {
private idService: IdService, private idService: IdService,
private queueService: QueueService, private queueService: QueueService,
private globalEventServie: GlobalEventService, private globalEventService: GlobalEventService,
) { ) {
} }

View file

@ -54,7 +54,7 @@ export class ChannelEntityService {
name: channel.name, name: channel.name,
description: channel.description, description: channel.description,
userId: channel.userId, userId: channel.userId,
bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner, false) : null, bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null,
usersCount: channel.usersCount, usersCount: channel.usersCount,
notesCount: channel.notesCount, notesCount: channel.notesCount,

View file

@ -71,27 +71,41 @@ export class DriveFileEntityService {
} }
@bindThis @bindThis
public getPublicUrl(file: DriveFile, thumbnail = false): string | null { public getPublicUrl(file: DriveFile, mode? : 'static' | 'avatar'): string | null { // static = thumbnail
const proxiedUrl = (url: string) => appendQuery(
`${this.config.mediaProxy}/${mode ?? 'image'}.webp`,
query({
url,
...(mode ? { [mode]: '1' } : {}),
})
);
// リモートかつメディアプロキシ // リモートかつメディアプロキシ
if (file.uri != null && file.userHost != null && this.config.mediaProxy != null) { if (file.uri != null && file.userHost != null && this.config.externalMediaProxyEnabled) {
return appendQuery(this.config.mediaProxy, query({ return proxiedUrl(file.uri);
url: file.uri,
thumbnail: thumbnail ? '1' : undefined,
}));
} }
// リモートかつ期限切れはローカルプロキシを試みる // リモートかつ期限切れはローカルプロキシを試みる
if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) { if (file.uri != null && file.isLink && this.config.proxyRemoteFiles) {
const key = thumbnail ? file.thumbnailAccessKey : file.webpublicAccessKey; const key = mode === 'static' ? file.thumbnailAccessKey : file.webpublicAccessKey;
if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外 if (key && !key.match('/')) { // 古いものはここにオブジェクトストレージキーが入ってるので除外
return `${this.config.url}/files/${key}`; const url = `${this.config.url}/files/${key}`;
if (mode === 'avatar') return proxiedUrl(url);
return url;
} }
} }
const isImage = file.type && ['image/png', 'image/apng', 'image/gif', 'image/jpeg', 'image/webp', 'image/avif', 'image/svg+xml'].includes(file.type); const isImage = file.type && ['image/png', 'image/apng', 'image/gif', 'image/jpeg', 'image/webp', 'image/avif', 'image/svg+xml'].includes(file.type);
return thumbnail ? (file.thumbnailUrl ?? (isImage ? (file.webpublicUrl ?? file.url) : null)) : (file.webpublicUrl ?? file.url); if (mode === 'static') {
return file.thumbnailUrl ?? (isImage ? (file.webpublicUrl ?? file.url) : null);
}
const url = file.webpublicUrl ?? file.url;
if (mode === 'avatar') return proxiedUrl(url);
return url;
} }
@bindThis @bindThis
@ -166,8 +180,8 @@ export class DriveFileEntityService {
isSensitive: file.isSensitive, isSensitive: file.isSensitive,
blurhash: file.blurhash, blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file), properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file, false), url: opts.self ? file.url : this.getPublicUrl(file),
thumbnailUrl: this.getPublicUrl(file, true), thumbnailUrl: this.getPublicUrl(file, 'static'),
comment: file.comment, comment: file.comment,
folderId: file.folderId, folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {
@ -201,8 +215,8 @@ export class DriveFileEntityService {
isSensitive: file.isSensitive, isSensitive: file.isSensitive,
blurhash: file.blurhash, blurhash: file.blurhash,
properties: opts.self ? file.properties : this.getPublicProperties(file), properties: opts.self ? file.properties : this.getPublicProperties(file),
url: opts.self ? file.url : this.getPublicUrl(file, false), url: opts.self ? file.url : this.getPublicUrl(file),
thumbnailUrl: this.getPublicUrl(file, true), thumbnailUrl: this.getPublicUrl(file, 'static'),
comment: file.comment, comment: file.comment,
folderId: file.folderId, folderId: file.folderId,
folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, { folder: opts.detail && file.folderId ? this.driveFolderEntityService.pack(file.folderId, {

View file

@ -314,10 +314,10 @@ export class UserEntityService implements OnModuleInit {
@bindThis @bindThis
public async getAvatarUrl(user: User): Promise<string> { public async getAvatarUrl(user: User): Promise<string> {
if (user.avatar) { if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, true) ?? this.getIdenticonUrl(user.id); return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
} else if (user.avatarId) { } else if (user.avatarId) {
const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId }); const avatar = await this.driveFilesRepository.findOneByOrFail({ id: user.avatarId });
return this.driveFileEntityService.getPublicUrl(avatar, true) ?? this.getIdenticonUrl(user.id); return this.driveFileEntityService.getPublicUrl(avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
} else { } else {
return this.getIdenticonUrl(user.id); return this.getIdenticonUrl(user.id);
} }
@ -326,7 +326,7 @@ export class UserEntityService implements OnModuleInit {
@bindThis @bindThis
public getAvatarUrlSync(user: User): string { public getAvatarUrlSync(user: User): string {
if (user.avatar) { if (user.avatar) {
return this.driveFileEntityService.getPublicUrl(user.avatar, true) ?? this.getIdenticonUrl(user.id); return this.driveFileEntityService.getPublicUrl(user.avatar, 'avatar') ?? this.getIdenticonUrl(user.id);
} else { } else {
return this.getIdenticonUrl(user.id); return this.getIdenticonUrl(user.id);
} }
@ -422,7 +422,7 @@ export class UserEntityService implements OnModuleInit {
createdAt: user.createdAt.toISOString(), createdAt: user.createdAt.toISOString(),
updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null, updatedAt: user.updatedAt ? user.updatedAt.toISOString() : null,
lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null, lastFetchedAt: user.lastFetchedAt ? user.lastFetchedAt.toISOString() : null,
bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner, false) : null, bannerUrl: user.banner ? this.driveFileEntityService.getPublicUrl(user.banner) : null,
bannerBlurhash: user.banner?.blurhash ?? null, bannerBlurhash: user.banner?.blurhash ?? null,
isLocked: user.isLocked, isLocked: user.isLocked,
isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote), isSilenced: this.roleService.getUserPolicies(user.id).then(r => !r.canPublicNote),

View file

@ -1,5 +1,7 @@
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
// TODO: メモリ節約のためあまり参照されないキーを定期的に削除できるようにする?
export class Cache<T> { export class Cache<T> {
public cache: Map<string | null, { date: number; value: T; }>; public cache: Map<string | null, { date: number; value: T; }>;
private lifetime: number; private lifetime: number;

View file

@ -137,38 +137,38 @@ export class FileServerService {
try { try {
if (file.state === 'remote') { if (file.state === 'remote') {
const convertFile = async () => { let image: IImageStreamable | null = null;
if (file.fileRole === 'thumbnail') {
if (['image/jpeg', 'image/webp', 'image/avif', 'image/png', 'image/svg+xml'].includes(file.mime)) {
return this.imageProcessingService.convertToWebpStream(
file.path,
498,
280
);
} else if (file.mime.startsWith('video/')) {
return await this.videoProcessingService.generateVideoThumbnail(file.path);
}
}
if (file.fileRole === 'webpublic') { if (file.fileRole === 'thumbnail') {
if (['image/svg+xml'].includes(file.mime)) { if (isMimeImage(file.mime, 'sharp-convertible-image')) {
return this.imageProcessingService.convertToWebpStream( reply.header('Cache-Control', 'max-age=31536000, immutable');
file.path,
2048,
2048,
{ ...webpDefault, lossless: true }
)
}
}
return { const url = new URL(`${this.config.mediaProxy}/static.webp`);
url.searchParams.set('url', file.url);
url.searchParams.set('static', '1');
return await reply.redirect(301, url.toString());
} else if (file.mime.startsWith('video/')) {
image = await this.videoProcessingService.generateVideoThumbnail(file.path);
}
}
if (file.fileRole === 'webpublic') {
if (['image/svg+xml'].includes(file.mime)) {
reply.header('Cache-Control', 'max-age=31536000, immutable');
const url = new URL(`${this.config.mediaProxy}/svg.webp`);
url.searchParams.set('url', file.url);
return await reply.redirect(301, url.toString());
}
}
if (!image) {
image = {
data: fs.createReadStream(file.path), data: fs.createReadStream(file.path),
ext: file.ext, ext: file.ext,
type: file.mime, type: file.mime,
}; };
}; }
const image = await convertFile();
if ('pipe' in image.data && typeof image.data.pipe === 'function') { if ('pipe' in image.data && typeof image.data.pipe === 'function') {
// image.dataがstreamなら、stream終了後にcleanup // image.dataがstreamなら、stream終了後にcleanup
@ -180,7 +180,6 @@ export class FileServerService {
} }
reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream'); reply.header('Content-Type', FILE_TYPE_BROWSERSAFE.includes(image.type) ? image.type : 'application/octet-stream');
reply.header('Cache-Control', 'max-age=31536000, immutable');
return image.data; return image.data;
} }
@ -217,6 +216,23 @@ export class FileServerService {
return; return;
} }
if (this.config.externalMediaProxyEnabled) {
// 外部のメディアプロキシが有効なら、そちらにリダイレクト
reply.header('Cache-Control', 'public, max-age=259200'); // 3 days
const url = new URL(`${this.config.mediaProxy}/${request.params.url || ''}`);
for (const [key, value] of Object.entries(request.query)) {
url.searchParams.append(key, value);
}
return await reply.redirect(
301,
url.toString(),
);
}
// Create temp file // Create temp file
const file = await this.getStreamAndTypeFromUrl(url); const file = await this.getStreamAndTypeFromUrl(url);
if (file === '404') { if (file === '404') {
@ -236,7 +252,7 @@ export class FileServerService {
const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image'); const isAnimationConvertibleImage = isMimeImage(file.mime, 'sharp-animation-convertible-image');
let image: IImageStreamable | null = null; let image: IImageStreamable | null = null;
if ('emoji' in request.query && isConvertibleImage) { if (('emoji' in request.query || 'avatar' in request.query) && isConvertibleImage) {
if (!isAnimationConvertibleImage && !('static' in request.query)) { if (!isAnimationConvertibleImage && !('static' in request.query)) {
image = { image = {
data: fs.createReadStream(file.path), data: fs.createReadStream(file.path),
@ -246,7 +262,7 @@ export class FileServerService {
} else { } else {
const data = sharp(file.path, { animated: !('static' in request.query) }) const data = sharp(file.path, { animated: !('static' in request.query) })
.resize({ .resize({
height: 128, height: 'emoji' in request.query ? 128 : 320,
withoutEnlargement: true, withoutEnlargement: true,
}) })
.webp(webpDefault); .webp(webpDefault);
@ -370,7 +386,7 @@ export class FileServerService {
@bindThis @bindThis
private async getFileFromKey(key: string): Promise< private async getFileFromKey(key: string): Promise<
{ state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; } { state: 'remote'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; url: string; mime: string; ext: string | null; path: string; cleanup: () => void; }
| { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; } | { state: 'stored_internal'; fileRole: 'thumbnail' | 'webpublic' | 'original'; file: DriveFile; mime: string; ext: string | null; path: string; }
| '404' | '404'
| '204' | '204'
@ -392,6 +408,7 @@ export class FileServerService {
const result = await this.downloadAndDetectTypeFromUrl(file.uri); const result = await this.downloadAndDetectTypeFromUrl(file.uri);
return { return {
...result, ...result,
url: file.uri,
fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original', fileRole: isThumbnail ? 'thumbnail' : isWebpublic ? 'webpublic' : 'original',
file, file,
} }

View file

@ -106,7 +106,7 @@ export class ServerService {
} }
} }
const url = new URL('/proxy/emoji.webp', this.config.url); const url = new URL(`${this.config.mediaProxy}/emoji.webp`);
// || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ) // || emoji.originalUrl してるのは後方互換性のためpublicUrlはstringなので??はだめ)
url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl); url.searchParams.set('url', emoji.publicUrl || emoji.originalUrl);
url.searchParams.set('emoji', '1'); url.searchParams.set('emoji', '1');

View file

@ -181,6 +181,10 @@ export const meta = {
type: 'string', type: 'string',
optional: false, nullable: true, optional: false, nullable: true,
}, },
mediaProxy: {
type: 'string',
optional: false, nullable: false,
},
features: { features: {
type: 'object', type: 'object',
optional: true, nullable: false, optional: true, nullable: false,
@ -307,6 +311,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
policies: { ...DEFAULT_POLICIES, ...instance.policies }, policies: { ...DEFAULT_POLICIES, ...instance.policies },
mediaProxy: this.config.mediaProxy,
...(ps.detail ? { ...(ps.detail ? {
pinnedPages: instance.pinnedPages, pinnedPages: instance.pinnedPages,
pinnedClipId: instance.pinnedClipId, pinnedClipId: instance.pinnedClipId,

View file

@ -1,6 +1,6 @@
import { Not } from 'typeorm'; import { Not } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, BlockingsRepository, PollsRepository, PollVotesRepository } from '@/models/index.js'; import type { UsersRepository, PollsRepository, PollVotesRepository } from '@/models/index.js';
import type { IRemoteUser } from '@/models/entities/User.js'; import type { IRemoteUser } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
@ -11,6 +11,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { CreateNotificationService } from '@/core/CreateNotificationService.js'; import { CreateNotificationService } from '@/core/CreateNotificationService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -77,9 +78,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.pollsRepository) @Inject(DI.pollsRepository)
private pollsRepository: PollsRepository, private pollsRepository: PollsRepository,
@ -93,6 +91,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private apRendererService: ApRendererService, private apRendererService: ApRendererService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private createNotificationService: CreateNotificationService, private createNotificationService: CreateNotificationService,
private userBlockingService: UserBlockingService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const createdAt = new Date(); const createdAt = new Date();
@ -109,11 +108,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
// Check blocking // Check blocking
if (note.userId !== me.id) { if (note.userId !== me.id) {
const block = await this.blockingsRepository.findOneBy({ const blocked = await this.userBlockingService.checkBlocked(note.userId, me.id);
blockerId: note.userId, if (blocked) {
blockeeId: me.id,
});
if (block) {
throw new ApiError(meta.errors.youHaveBeenBlocked); throw new ApiError(meta.errors.youHaveBeenBlocked);
} }
} }

View file

@ -25,6 +25,8 @@ export interface InternalStreamTypes {
remoteUserUpdated: { id: User['id']; }; remoteUserUpdated: { id: User['id']; };
follow: { followerId: User['id']; followeeId: User['id']; }; follow: { followerId: User['id']; followeeId: User['id']; };
unfollow: { followerId: User['id']; followeeId: User['id']; }; unfollow: { followerId: User['id']; followeeId: User['id']; };
blockingCreated: { blockerId: User['id']; blockeeId: User['id']; };
blockingDeleted: { blockerId: User['id']; blockeeId: User['id']; };
policiesUpdated: Role['policies']; policiesUpdated: Role['policies'];
roleCreated: Role; roleCreated: Role;
roleDeleted: Role; roleDeleted: Role;

View file

@ -33,7 +33,7 @@ export class UrlPreviewService {
private wrap(url?: string): string | null { private wrap(url?: string): string | null {
return url != null return url != null
? url.match(/^https?:\/\//) ? url.match(/^https?:\/\//)
? `${this.config.url}/proxy/preview.webp?${query({ ? `${this.config.mediaProxy}/preview.webp?${query({
url, url,
preview: '1', preview: '1',
})}` })}`
@ -73,6 +73,14 @@ export class UrlPreviewService {
}); });
this.logger.succ(`Got preview of ${url}: ${summary.title}`); this.logger.succ(`Got preview of ${url}: ${summary.title}`);
if (summary.url && !(summary.url.startsWith('http://') || summary.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
if (summary.player?.url && !(summary.player.url.startsWith('http://') || summary.player.url.startsWith('https://'))) {
throw new Error('unsupported schema included');
}
summary.icon = this.wrap(summary.icon); summary.icon = this.wrap(summary.icon);
summary.thumbnail = this.wrap(summary.thumbnail); summary.thumbnail = this.wrap(summary.thumbnail);

View file

@ -1,7 +1,8 @@
<template> <template>
<div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> <div v-if="playerEnabled" :class="$style.player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`">
<button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button> <button :class="$style.disablePlayer" :title="i18n.ts.disablePlayer" @click="playerEnabled = false"><i class="ti ti-x"></i></button>
<iframe :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> <iframe v-if="player.url.startsWith('http://') || player.url.startsWith('https://')" :class="$style.playerIframe" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
<span v-else>invalid url</span>
</div> </div>
<div v-else-if="tweetId && tweetExpanded" ref="twitter" :class="$style.twitter"> <div v-else-if="tweetId && tweetExpanded" ref="twitter" :class="$style.twitter">
<iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&amp;hideCard=false&amp;hideThread=false&amp;lang=en&amp;theme=${$store.state.darkMode ? 'dark' : 'light'}&amp;id=${tweetId}`"></iframe>

View file

@ -7,9 +7,10 @@
<div class="poamfof"> <div class="poamfof">
<Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> <Transition :name="$store.state.animation ? 'fade' : ''" mode="out-in">
<div v-if="player.url" class="player"> <div v-if="player.url && (player.url.startsWith('http://') || player.url.startsWith('https://'))" class="player">
<iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> <iframe v-if="!fetching" :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/>
</div> </div>
<span v-else>invalid url</span>
</Transition> </Transition>
<MkLoading v-if="fetching"/> <MkLoading v-if="fetching"/>
<MkError v-else-if="!player.url" @retry="ytFetch()"/> <MkError v-else-if="!player.url" @retry="ytFetch()"/>

View file

@ -1,8 +1,9 @@
import { query, appendQuery } from '@/scripts/url'; import { query, appendQuery } from '@/scripts/url';
import { url } from '@/config'; import { url } from '@/config';
import { instance } from '@/instance';
export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string { export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
if (imageUrl.startsWith(`${url}/proxy/`) || imageUrl.startsWith('/proxy/')) { if (imageUrl.startsWith(instance.mediaProxy + '/') || imageUrl.startsWith('/proxy/')) {
// もう既にproxyっぽそうだったらsearchParams付けるだけ // もう既にproxyっぽそうだったらsearchParams付けるだけ
return appendQuery(imageUrl, query({ return appendQuery(imageUrl, query({
fallback: '1', fallback: '1',
@ -10,7 +11,7 @@ export function getProxiedImageUrl(imageUrl: string, type?: 'preview'): string {
})); }));
} }
return `${url}/proxy/image.webp?${query({ return `${instance.mediaProxy}/image.webp?${query({
url: imageUrl, url: imageUrl,
fallback: '1', fallback: '1',
...(type ? { [type]: '1' } : {}), ...(type ? { [type]: '1' } : {}),
@ -25,22 +26,19 @@ export function getProxiedImageUrlNullable(imageUrl: string | null | undefined,
export function getStaticImageUrl(baseUrl: string): string { export function getStaticImageUrl(baseUrl: string): string {
const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, url); const u = baseUrl.startsWith('http') ? new URL(baseUrl) : new URL(baseUrl, url);
if (u.href.startsWith(`${url}/proxy/`)) {
// もう既にproxyっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1');
return u.href;
}
if (u.href.startsWith(`${url}/emoji/`)) { if (u.href.startsWith(`${url}/emoji/`)) {
// もう既にemojiっぽそうだったらsearchParams付けるだけ // もう既にemojiっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1'); u.searchParams.set('static', '1');
return u.href; return u.href;
} }
// 拡張子がないとキャッシュしてくれないCDNがあるのでダミーの名前を指定する if (u.href.startsWith(instance.mediaProxy + '/')) {
const dummy = `${encodeURIComponent(`${u.host}${u.pathname}`)}.webp`; // もう既にproxyっぽそうだったらsearchParams付けるだけ
u.searchParams.set('static', '1');
return u.href;
}
return `${url}/proxy/${dummy}?${query({ return `${instance.mediaProxy}/static.webp?${query({
url: u.href, url: u.href,
static: '1', static: '1',
})}`; })}`;