diff options
| author | dakkar <dakkar@thenautilus.net> | 2024-08-30 12:08:31 +0100 |
|---|---|---|
| committer | dakkar <dakkar@thenautilus.net> | 2024-08-30 12:08:31 +0100 |
| commit | 6151099f5ba07d14a651aea8f816dd280d74209d (patch) | |
| tree | 4b014a2f4b198facc8bdd92c70b49fec2801d51a /packages | |
| parent | merge: thunk the min/max promises (!603) (diff) | |
| parent | Merge pull request #14391 from misskey-dev/develop (diff) | |
| download | sharkey-6151099f5ba07d14a651aea8f816dd280d74209d.tar.gz sharkey-6151099f5ba07d14a651aea8f816dd280d74209d.tar.bz2 sharkey-6151099f5ba07d14a651aea8f816dd280d74209d.zip | |
Merge remote-tracking branch 'misskey/master' into feature/misskey-2024.8
Diffstat (limited to 'packages')
68 files changed, 1105 insertions, 334 deletions
diff --git a/packages/backend/src/core/DeleteAccountService.ts b/packages/backend/src/core/DeleteAccountService.ts index 79b614edba..7f1b8f3efb 100644 --- a/packages/backend/src/core/DeleteAccountService.ts +++ b/packages/backend/src/core/DeleteAccountService.ts @@ -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'); + if (moderator != null) { + this.moderationLogService.log(moderator, 'deleteAccount', { + userId: user.id, + userUsername: _user.username, + userHost: user.host, + }); + } + // 物理削除する前にDelete activityを送信する - await this.userSuspendService.doPostSuspend(user).catch(e => {}); + if (this.userEntityService.isLocalUser(user)) { + // 知り得る全SharedInboxにDelete配信 + const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user)); - this.queueService.createDeleteAccountJob(user, { - soft: false, - }); + 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, diff --git a/packages/backend/src/core/FileInfoService.ts b/packages/backend/src/core/FileInfoService.ts index 9a9eae3cae..cc66e9fe3a 100644 --- a/packages/backend/src/core/FileInfoService.ts +++ b/packages/backend/src/core/FileInfoService.ts @@ -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); } diff --git a/packages/backend/src/core/ModerationLogService.ts b/packages/backend/src/core/ModerationLogService.ts index 6c155c9a62..2c02af217d 100644 --- a/packages/backend/src/core/ModerationLogService.ts +++ b/packages/backend/src/core/ModerationLogService.ts @@ -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 { diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a7f2bf5a5c..ef9ea0ee99 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -707,7 +707,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); } diff --git a/packages/backend/src/core/NoteDeleteService.ts b/packages/backend/src/core/NoteDeleteService.ts index af65750a01..7ce6d7c605 100644 --- a/packages/backend/src/core/NoteDeleteService.ts +++ b/packages/backend/src/core/NoteDeleteService.ts @@ -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; diff --git a/packages/backend/src/core/ReversiService.ts b/packages/backend/src/core/ReversiService.ts index 75fdb82ca0..8c0a8f6cc7 100644 --- a/packages/backend/src/core/ReversiService.ts +++ b/packages/backend/src/core/ReversiService.ts @@ -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, diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts index d594a223f4..7920e58e36 100644 --- a/packages/backend/src/core/UserSuspendService.ts +++ b/packages/backend/src/core/UserSuspendService.ts @@ -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); + } } diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts index fd8d65445a..918e43ae9c 100644 --- a/packages/backend/src/core/activitypub/ApRequestService.ts +++ b/packages/backend/src/core/activitypub/ApRequestService.ts @@ -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,29 @@ 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(); + const document = window.document; + 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); + } + } + } + //#endregion + + validateContentTypeSetAsActivityPub(res); + const finalUrl = res.url; // redirects may have been involved const activity = await res.json() as IObject; diff --git a/packages/backend/src/core/activitypub/models/ApPersonService.ts b/packages/backend/src/core/activitypub/models/ApPersonService.ts index bf8565d402..10b1fc0bf4 100644 --- a/packages/backend/src/core/activitypub/models/ApPersonService.ts +++ b/packages/backend/src/core/activitypub/models/ApPersonService.ts @@ -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; + } } diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index c92e2b805f..2f58825de1 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -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']; diff --git a/packages/backend/src/core/entities/FlashEntityService.ts b/packages/backend/src/core/entities/FlashEntityService.ts index d110f7afc6..4aa7104c1e 100644 --- a/packages/backend/src/core/entities/FlashEntityService.ts +++ b/packages/backend/src/core/entities/FlashEntityService.ts @@ -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, }); diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index e1ef9985bc..2b1c4d5c63 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -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; diff --git a/packages/backend/src/misc/cache.ts b/packages/backend/src/misc/cache.ts index d968069ca3..f9692ce5d5 100644 --- a/packages/backend/src/misc/cache.ts +++ b/packages/backend/src/misc/cache.ts @@ -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> { diff --git a/packages/backend/src/misc/json-value.ts b/packages/backend/src/misc/json-value.ts index 7994441791..bd7fe12058 100644 --- a/packages/backend/src/misc/json-value.ts +++ b/packages/backend/src/misc/json-value.ts @@ -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); +} diff --git a/packages/backend/src/models/json-schema/flash.ts b/packages/backend/src/models/json-schema/flash.ts index 952df649ad..42b2172409 100644 --- a/packages/backend/src/models/json-schema/flash.ts +++ b/packages/backend/src/models/json-schema/flash.ts @@ -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, diff --git a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts index 4074e416b8..01dea703a3 100644 --- a/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts +++ b/packages/backend/src/server/api/endpoints/admin/accounts/delete.ts @@ -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); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts index d7209965db..5cf49670be 100644 --- a/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts +++ b/packages/backend/src/server/api/endpoints/admin/roles/update-default-policies.ts @@ -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, + }); }); } } diff --git a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts index 8a946405cc..bea1bdc4ed 100644 --- a/packages/backend/src/server/api/endpoints/admin/suspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/suspend-user.ts @@ -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 => {}); - })(); - }); - } - - @bindThis - private async unFollowAll(follower: MiUser) { - const followings = await this.followingsRepository.find({ - where: { - followerId: follower.id, - followeeId: Not(IsNull()), - }, + await this.userSuspendService.suspend(user, me); }); - - 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); } } diff --git a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts index 2c2b1bf6f5..b52c638cdb 100644 --- a/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/unsuspend-user.ts @@ -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); }); } } diff --git a/packages/backend/src/server/api/endpoints/flash/delete.ts b/packages/backend/src/server/api/endpoints/flash/delete.ts index d3d47e5deb..6912450abf 100644 --- a/packages/backend/src/server/api/endpoints/flash/delete.ts +++ b/packages/backend/src/server/api/endpoints/flash/delete.ts @@ -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, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts index 527e3fb52d..b6b94db161 100644 --- a/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts +++ b/packages/backend/src/server/api/endpoints/gallery/posts/delete.ts @@ -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, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/pages/delete.ts b/packages/backend/src/server/api/endpoints/pages/delete.ts index aa2ba75a41..f2bc946788 100644 --- a/packages/backend/src/server/api/endpoints/pages/delete.ts +++ b/packages/backend/src/server/api/endpoints/pages/delete.ts @@ -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, + }); + } }); } } diff --git a/packages/backend/src/server/api/endpoints/users/followers.ts b/packages/backend/src/server/api/endpoints/users/followers.ts index 7ce7734f53..a8b4319a61 100644 --- a/packages/backend/src/server/api/endpoints/users/followers.ts +++ b/packages/backend/src/server/api/endpoints/users/followers.ts @@ -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,22 +95,24 @@ 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); + } } } } diff --git a/packages/backend/src/server/api/endpoints/users/following.ts b/packages/backend/src/server/api/endpoints/users/following.ts index 6b3389f0b2..feda5bb353 100644 --- a/packages/backend/src/server/api/endpoints/users/following.ts +++ b/packages/backend/src/server/api/endpoints/users/following.ts @@ -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,22 +104,24 @@ 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); + } } } } diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 9378b6c62b..3cacb77620 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -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'; @@ -23,6 +24,8 @@ import type Logger from '@/logger.js'; const MAX_CHANNELS_PER_CONNECTION = 32; +const MAX_CHANNELS_PER_CONNECTION = 32; + /** * Main stream connection */ @@ -149,8 +152,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 +192,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 +205,7 @@ export default class Connection { } @bindThis - private onReadNotification(payload: JsonObject) { + private onReadNotification(payload: JsonValue | undefined) { this.notificationService.readAllNotification(this.user!.id); } @@ -211,7 +213,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 +230,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 +269,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 +283,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 +356,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; diff --git a/packages/backend/src/server/api/stream/channels/queue-stats.ts b/packages/backend/src/server/api/stream/channels/queue-stats.ts index ff7e740226..91b62255b4 100644 --- a/packages/backend/src/server/api/stream/channels/queue-stats.ts +++ b/packages/backend/src/server/api/stream/channels/queue-stats.ts @@ -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 => { diff --git a/packages/backend/src/server/api/stream/channels/reversi-game.ts b/packages/backend/src/server/api/stream/channels/reversi-game.ts index 17823a164a..7597a1cfa3 100644 --- a/packages/backend/src/server/api/stream/channels/reversi-game.ts +++ b/packages/backend/src/server/api/stream/channels/reversi-game.ts @@ -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); diff --git a/packages/backend/src/server/api/stream/channels/server-stats.ts b/packages/backend/src/server/api/stream/channels/server-stats.ts index 6258afba35..ec5352d12d 100644 --- a/packages/backend/src/server/api/stream/channels/server-stats.ts +++ b/packages/backend/src/server/api/stream/channels/server-stats.ts @@ -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); }); diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug index 4007632291..36cec20c85 100644 --- a/packages/backend/src/server/web/views/base.pug +++ b/packages/backend/src/server/web/views/base.pug @@ -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') diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 3545aecb46..d83d414096 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -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> = { diff --git a/packages/backend/test/unit/activitypub.ts b/packages/backend/test/unit/activitypub.ts index 2e80b64a9f..889a6ac53f 100644 --- a/packages/backend/test/unit/activitypub.ts +++ b/packages/backend/test/unit/activitypub.ts @@ -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', { diff --git a/packages/frontend/.storybook/fakes.ts b/packages/frontend/.storybook/fakes.ts index d43e73eeba..758827c196 100644 --- a/packages/frontend/.storybook/fakes.ts +++ b/packages/frontend/.storybook/fakes.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +import { AISCRIPT_VERSION } from '@syuilo/aiscript'; import type { entities } from 'misskey-js' export function abuseUserReport() { @@ -114,6 +115,40 @@ export function file(isSensitive = false) { }; } +const script = `/// @ ${AISCRIPT_VERSION} + +var name = "" + +Ui:render([ + Ui:C:textInput({ + label: "Your name" + onInput: @(v) { name = v } + }) + Ui:C:button({ + text: "Hello" + onClick: @() { + Mk:dialog(null, \`Hello, {name}!\`) + } + }) +]) +`; + +export function flash(): entities.Flash { + return { + id: 'someflashid', + createdAt: '2016-12-28T22:49:51.000Z', + updatedAt: '2016-12-28T22:49:51.000Z', + userId: 'someuserid', + user: userLite(), + title: 'Some Play title', + summary: 'Some Play summary', + script, + visibility: 'public', + likedCount: 0, + isLiked: false, + }; +} + export function folder(id = 'somefolderid', name = 'Some Folder', parentId: string | null = null): entities.DriveFolder { return { id, diff --git a/packages/frontend/.storybook/generate.tsx b/packages/frontend/.storybook/generate.tsx index 52c01aaf70..490a441b70 100644 --- a/packages/frontend/.storybook/generate.tsx +++ b/packages/frontend/.storybook/generate.tsx @@ -398,6 +398,7 @@ function toStories(component: string): Promise<string> { glob('src/components/global/Mk*.vue'), glob('src/components/global/RouterView.vue'), glob('src/components/Mk[A-E]*.vue'), + glob('src/components/MkFlashPreview.vue'), glob('src/components/MkGalleryPostPreview.vue'), glob('src/components/MkSignupServerRules.vue'), glob('src/components/MkUserSetupDialog.vue'), diff --git a/packages/frontend/package.json b/packages/frontend/package.json index 1660ad45dd..35cb900d3b 100644 --- a/packages/frontend/package.json +++ b/packages/frontend/package.json @@ -29,7 +29,7 @@ "@syuilo/aiscript": "0.19.0", "@twemoji/parser": "15.1.1", "@vitejs/plugin-vue": "5.1.0", - "@vue/compiler-sfc": "3.4.34", + "@vue/compiler-sfc": "3.4.37", "aiscript-vscode": "github:aiscript-dev/aiscript-vscode#v0.1.11", "astring": "1.8.6", "broadcast-channel": "7.0.0", @@ -73,7 +73,7 @@ "uuid": "10.0.0", "v-code-diff": "1.12.0", "vite": "5.3.5", - "vue": "3.4.34", + "vue": "3.4.37", "vuedraggable": "next" }, "devDependencies": { @@ -112,7 +112,7 @@ "@typescript-eslint/eslint-plugin": "7.17.0", "@typescript-eslint/parser": "7.17.0", "@vitest/coverage-v8": "1.6.0", - "@vue/runtime-core": "3.4.34", + "@vue/runtime-core": "3.4.37", "acorn": "8.12.1", "cross-env": "7.0.3", "cypress": "13.13.1", diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index 0b1202f286..c10930a038 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -230,17 +230,18 @@ export async function mainBoot() { claimAchievement('client60min'); }, 1000 * 60 * 60); - const lastUsed = miLocalStorage.getItem('lastUsed'); - if (lastUsed) { - const lastUsedDate = parseInt(lastUsed, 10); - // 二時間以上前なら - if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { - toast(i18n.tsx.welcomeBackWithName({ - name: $i.name || $i.username, - }), true); - } - } - miLocalStorage.setItem('lastUsed', Date.now().toString()); + // 邪魔 + //const lastUsed = miLocalStorage.getItem('lastUsed'); + //if (lastUsed) { + // const lastUsedDate = parseInt(lastUsed, 10); + // // 二時間以上前なら + // if (Date.now() - lastUsedDate > 1000 * 60 * 60 * 2) { + // toast(i18n.tsx.welcomeBackWithName({ + // name: $i.name || $i.username, + // }), true); + // } + //} + //miLocalStorage.setItem('lastUsed', Date.now().toString()); const latestDonationInfoShownAt = miLocalStorage.getItem('latestDonationInfoShownAt'); const neverShowDonationInfo = miLocalStorage.getItem('neverShowDonationInfo'); diff --git a/packages/frontend/src/components/MkAbuseReportWindow.vue b/packages/frontend/src/components/MkAbuseReportWindow.vue index b09c7bb3fb..a634a748e9 100644 --- a/packages/frontend/src/components/MkAbuseReportWindow.vue +++ b/packages/frontend/src/components/MkAbuseReportWindow.vue @@ -39,7 +39,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; const props = defineProps<{ - user: Misskey.entities.UserDetailed; + user: Misskey.entities.UserLite; initialComment?: string; }>(); diff --git a/packages/frontend/src/components/MkFlashPreview.stories.impl.ts b/packages/frontend/src/components/MkFlashPreview.stories.impl.ts new file mode 100644 index 0000000000..fa5288b73d --- /dev/null +++ b/packages/frontend/src/components/MkFlashPreview.stories.impl.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { StoryObj } from '@storybook/vue3'; +import MkFlashPreview from './MkFlashPreview.vue'; +import { flash } from './../../.storybook/fakes.js'; +export const Public = { + render(args) { + return { + components: { + MkFlashPreview, + }, + setup() { + return { + args, + }; + }, + computed: { + props() { + return { + ...this.args, + }; + }, + }, + template: '<MkFlashPreview v-bind="props" />', + }; + }, + args: { + flash: { + ...flash(), + visibility: 'public', + }, + }, + parameters: { + layout: 'fullscreen', + }, + decorators: [ + () => ({ + template: '<div style="display: flex; align-items: center; justify-content: center; height: 100vh"><div style="max-width: 700px; width: 100%; margin: 3rem"><story/></div></div>', + }), + ], +} satisfies StoryObj<typeof MkFlashPreview>; +export const Private = { + ...Public, + args: { + flash: { + ...flash(), + visibility: 'private', + }, + }, +} satisfies StoryObj<typeof MkFlashPreview>; diff --git a/packages/frontend/src/components/MkFlashPreview.vue b/packages/frontend/src/components/MkFlashPreview.vue index 6783804cc5..8a2a438624 100644 --- a/packages/frontend/src/components/MkFlashPreview.vue +++ b/packages/frontend/src/components/MkFlashPreview.vue @@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel"> +<MkA :to="`/play/${flash.id}`" class="vhpxefrk _panel" :class="[{ gray: flash.visibility === 'private' }]"> <article> <header> <h1 :title="flash.title">{{ flash.title }}</h1> @@ -22,11 +22,11 @@ SPDX-License-Identifier: AGPL-3.0-only <script lang="ts" setup> import { } from 'vue'; +import * as Misskey from 'misskey-js'; import { userName } from '@/filters/user.js'; const props = defineProps<{ - //flash: Misskey.entities.Flash; - flash: any; + flash: Misskey.entities.Flash; }>(); </script> @@ -91,6 +91,12 @@ const props = defineProps<{ } } + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + @media (max-width: 700px) { } diff --git a/packages/frontend/src/components/MkNote.vue b/packages/frontend/src/components/MkNote.vue index b33a0ba694..e2f0a4e492 100644 --- a/packages/frontend/src/components/MkNote.vue +++ b/packages/frontend/src/components/MkNote.vue @@ -234,6 +234,7 @@ import { host } from '@/config.js'; import { isEnabledUrlPreview } from '@/instance.js'; import { type Keymap } from '@/scripts/hotkey.js'; import { focusPrev, focusNext } from '@/scripts/focus.js'; +import { getAppearNote } from '@/scripts/get-appear-note.js'; const props = withDefaults(defineProps<{ note: Misskey.entities.Note; @@ -285,14 +286,7 @@ if (noteViewInterruptors.length > 0) { }); } -const isRenote = ( - note.value.renote != null && - note.value.reply == null && - note.value.text == null && - note.value.cw == null && - note.value.fileIds && note.value.fileIds.length === 0 && - note.value.poll == null -); +const isRenote = Misskey.note.isPureRenote(note.value); const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); @@ -303,7 +297,7 @@ const reactButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); diff --git a/packages/frontend/src/components/MkNoteDetailed.vue b/packages/frontend/src/components/MkNoteDetailed.vue index 33fc6e06d6..64559ef265 100644 --- a/packages/frontend/src/components/MkNoteDetailed.vue +++ b/packages/frontend/src/components/MkNoteDetailed.vue @@ -266,6 +266,7 @@ import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkButton from '@/components/MkButton.vue'; import { boostMenuItems, type Visibility } from '@/scripts/boost-quote.js'; import { isEnabledUrlPreview } from '@/instance.js'; +import { getAppearNote } from '@/scripts/get-appear-note.js'; import { type Keymap } from '@/scripts/hotkey.js'; const props = withDefaults(defineProps<{ @@ -299,14 +300,7 @@ if (noteViewInterruptors.length > 0) { }); } -const isRenote = ( - note.value.renote != null && - note.value.reply == null && - note.value.text == null && - note.value.cw == null && - note.value.fileIds && note.value.fileIds.length === 0 && - note.value.poll == null -); +const isRenote = Misskey.note.isPureRenote(note.value); const rootEl = shallowRef<HTMLElement>(); const menuButton = shallowRef<HTMLElement>(); @@ -317,7 +311,7 @@ const reactButton = shallowRef<HTMLElement>(); const quoteButton = shallowRef<HTMLElement>(); const clipButton = shallowRef<HTMLElement>(); const likeButton = shallowRef<HTMLElement>(); -const appearNote = computed(() => isRenote ? note.value.renote as Misskey.entities.Note : note.value); +const appearNote = computed(() => getAppearNote(note.value)); const galleryEl = shallowRef<InstanceType<typeof MkMediaList>>(); const isMyRenote = $i && ($i.id === note.value.userId); const showContent = ref(defaultStore.state.uncollapseCW); diff --git a/packages/frontend/src/components/MkPreview.vue b/packages/frontend/src/components/MkPreview.vue index d950d66c6e..649dee2fdb 100644 --- a/packages/frontend/src/components/MkPreview.vue +++ b/packages/frontend/src/components/MkPreview.vue @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only <template> <div :class="$style.preview"> - <div :class="$style.preview__content1"> + <div> <MkInput v-model="text"> <template #label>Text</template> </MkInput> diff --git a/packages/frontend/src/components/MkRolePreview.vue b/packages/frontend/src/components/MkRolePreview.vue index c1b922198f..ce17ae08e0 100644 --- a/packages/frontend/src/components/MkRolePreview.vue +++ b/packages/frontend/src/components/MkRolePreview.vue @@ -4,25 +4,32 @@ SPDX-License-Identifier: AGPL-3.0-only --> <template> -<MkA v-adaptive-bg :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" class="_panel" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> - <div :class="$style.title"> - <span :class="$style.icon"> - <template v-if="role.iconUrl"> - <img :class="$style.badge" :src="role.iconUrl"/> - </template> - <template v-else> - <i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i> - <i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i> - <i v-else class="ti ti-user" style="opacity: 0.7;"></i> +<MkA :to="forModeration ? `/admin/roles/${role.id}` : `/roles/${role.id}`" :class="$style.root" tabindex="-1" :style="{ '--color': role.color }"> + <template v-if="forModeration"> + <i v-if="role.isPublic" class="ti ti-world" :class="$style.icon" style="color: var(--success)"></i> + <i v-else class="ti ti-lock" :class="$style.icon" style="color: var(--warn)"></i> + </template> + + <div v-adaptive-bg class="_panel" :class="$style.body"> + <div :class="$style.bodyTitle"> + <span :class="$style.bodyIcon"> + <template v-if="role.iconUrl"> + <img :class="$style.bodyBadge" :src="role.iconUrl"/> + </template> + <template v-else> + <i v-if="role.isAdministrator" class="ti ti-crown" style="color: var(--accent);"></i> + <i v-else-if="role.isModerator" class="ti ti-shield" style="color: var(--accent);"></i> + <i v-else class="ti ti-user" style="opacity: 0.7;"></i> + </template> + </span> + <span :class="$style.bodyName">{{ role.name }}</span> + <template v-if="detailed"> + <span v-if="role.target === 'manual'" :class="$style.bodyUsers">{{ role.usersCount }} users</span> + <span v-else-if="role.target === 'conditional'" :class="$style.bodyUsers">? users</span> </template> - </span> - <span :class="$style.name">{{ role.name }}</span> - <template v-if="detailed"> - <span v-if="role.target === 'manual'" :class="$style.users">{{ role.usersCount }} users</span> - <span v-else-if="role.target === 'conditional'" :class="$style.users">({{ i18n.ts._role.conditional }})</span> - </template> + </div> + <div :class="$style.bodyDescription">{{ role.description }}</div> </div> - <div :class="$style.description">{{ role.description }}</div> </MkA> </template> @@ -42,34 +49,44 @@ const props = withDefaults(defineProps<{ <style lang="scss" module> .root { + display: flex; + align-items: center; +} + +.icon { + margin: 0 12px; +} + +.body { display: block; padding: 16px 20px; + flex: 1; border-left: solid 6px var(--color); } -.title { +.bodyTitle { display: flex; } -.icon { +.bodyIcon { margin-right: 8px; } -.badge { +.bodyBadge { height: 1.3em; vertical-align: -20%; } -.name { +.bodyName { font-weight: bold; } -.users { +.bodyUsers { margin-left: auto; opacity: 0.7; } -.description { +.bodyDescription { opacity: 0.7; font-size: 85%; } diff --git a/packages/frontend/src/components/global/MkAcct.vue b/packages/frontend/src/components/global/MkAcct.vue index 8cb082585b..bbcb070803 100644 --- a/packages/frontend/src/components/global/MkAcct.vue +++ b/packages/frontend/src/components/global/MkAcct.vue @@ -21,7 +21,7 @@ import { host as hostRaw } from '@/config.js'; import { defaultStore } from '@/store.js'; defineProps<{ - user: Misskey.entities.User; + user: Misskey.entities.UserLite; detail?: boolean; }>(); diff --git a/packages/frontend/src/pages/admin-user.vue b/packages/frontend/src/pages/admin-user.vue index af98967fe2..187ec66b42 100644 --- a/packages/frontend/src/pages/admin-user.vue +++ b/packages/frontend/src/pages/admin-user.vue @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </div> - <MkInfo v-if="user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo> + <MkInfo v-if="['instance.actor', 'relay.actor'].includes(user.username)">{{ i18n.ts.isSystemAccount }}</MkInfo> <FormLink v-if="user.host" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> diff --git a/packages/frontend/src/pages/admin/index.vue b/packages/frontend/src/pages/admin/index.vue index 794669d6b5..f547bedacb 100644 --- a/packages/frontend/src/pages/admin/index.vue +++ b/packages/frontend/src/pages/admin/index.vue @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> <div v-if="!narrow || currentPage?.route.name == null" class="nav"> <MkSpacer :contentMax="700" :marginMin="16"> - <div class="lxpfedzu"> + <div class="lxpfedzu _gaps"> <div class="banner"> <img :src="instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> @@ -62,10 +62,10 @@ const narrow = ref(false); const view = ref(null); const el = ref<HTMLDivElement | null>(null); const pageProps = ref({}); -let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); -let noBotProtection = !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableMcaptcha && !instance.enableTurnstile; -let noEmailServer = !instance.enableEmail; -let noInquiryUrl = isEmpty(instance.inquiryUrl); +const noMaintainerInformation = computed(() => isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail)); +const noBotProtection = computed(() => !instance.disableRegistration && !instance.enableHcaptcha && !instance.enableRecaptcha && !instance.enableTurnstile && !instance.enableMcaptcha); +const noEmailServer = computed(() => !instance.enableEmail); +const noInquiryUrl = computed(() => isEmpty(instance.inquiryUrl)); const thereIsUnresolvedAbuseReport = ref(false); const pendingUserApprovals = ref(false); const currentPage = computed(() => router.currentRef.value.child); @@ -250,25 +250,22 @@ const menuDef = computed(() => [{ }], }]); -watch(narrow.value, () => { - if (currentPage.value?.route.name == null && !narrow.value) { - router.push('/admin/overview'); - } -}); - onMounted(() => { - ro.observe(el.value); - - narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; + if (el.value != null) { + ro.observe(el.value); + narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; + } if (currentPage.value?.route.name == null && !narrow.value) { - router.push('/admin/overview'); + router.replace('/admin/overview'); } }); onActivated(() => { - narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; + if (el.value != null) { + narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; + } if (currentPage.value?.route.name == null && !narrow.value) { - router.push('/admin/overview'); + router.replace('/admin/overview'); } }); diff --git a/packages/frontend/src/pages/admin/modlog.ModLog.vue b/packages/frontend/src/pages/admin/modlog.ModLog.vue index 4cddca3a7d..f6f276de53 100644 --- a/packages/frontend/src/pages/admin/modlog.ModLog.vue +++ b/packages/frontend/src/pages/admin/modlog.ModLog.vue @@ -21,13 +21,13 @@ SPDX-License-Identifier: AGPL-3.0-only ].includes(log.type), [$style.logYellow]: [ 'markSensitiveDriveFile', - 'resetPassword' + 'resetPassword', + 'suspendRemoteInstance', ].includes(log.type), [$style.logRed]: [ 'suspend', 'approve', 'deleteRole', - 'suspendRemoteInstance', 'deleteGlobalAnnouncement', 'deleteUserAnnouncement', 'deleteCustomEmoji', @@ -37,6 +37,10 @@ SPDX-License-Identifier: AGPL-3.0-only 'deleteAvatarDecoration', 'deleteSystemWebhook', 'deleteAbuseReportNotificationRecipient', + 'deleteAccount', + 'deletePage', + 'deleteFlash', + 'deleteGalleryPost', ].includes(log.type) }" >{{ i18n.ts._moderationLogTypes[log.type] }}</b> @@ -74,6 +78,10 @@ SPDX-License-Identifier: AGPL-3.0-only <span v-else-if="log.type === 'createAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> <span v-else-if="log.type === 'updateAbuseReportNotificationRecipient'">: {{ log.info.before.name }}</span> <span v-else-if="log.type === 'deleteAbuseReportNotificationRecipient'">: {{ log.info.recipient.name }}</span> + <span v-else-if="log.type === 'deleteAccount'">: @{{ log.info.userUsername }}{{ log.info.userHost ? '@' + log.info.userHost : '' }}</span> + <span v-else-if="log.type === 'deletePage'">: @{{ log.info.pageUserUsername }}</span> + <span v-else-if="log.type === 'deleteFlash'">: @{{ log.info.flashUserUsername }}</span> + <span v-else-if="log.type === 'deleteGalleryPost'">: @{{ log.info.postUserUsername }}</span> </template> <template #icon> <MkAvatar :user="log.user" :class="$style.avatar"/> @@ -148,7 +156,6 @@ SPDX-License-Identifier: AGPL-3.0-only </div> </template> <template v-else-if="log.type === 'updateRemoteInstanceNote'"> - <div>{{ i18n.ts.user }}: {{ log.info.userId }}</div> <div :class="$style.diff"> <CodeDiff :context="5" :hideHeader="true" :oldString="log.info.before ?? ''" :newString="log.info.after ?? ''" maxHeight="300px"/> </div> diff --git a/packages/frontend/src/pages/admin/users.vue b/packages/frontend/src/pages/admin/users.vue index f8ed2fe34a..e99dcfa489 100644 --- a/packages/frontend/src/pages/admin/users.vue +++ b/packages/frontend/src/pages/admin/users.vue @@ -34,11 +34,11 @@ SPDX-License-Identifier: AGPL-3.0-only </MkSelect> </div> <div :class="$style.inputs"> - <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false"> <template #prefix>@</template> <template #label>{{ i18n.ts.username }}</template> </MkInput> - <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'"> <template #prefix>@</template> <template #label>{{ i18n.ts.host }}</template> </MkInput> diff --git a/packages/frontend/src/pages/flash/flash-edit.vue b/packages/frontend/src/pages/flash/flash-edit.vue index 0b9f4dfe58..d282ed4810 100644 --- a/packages/frontend/src/pages/flash/flash-edit.vue +++ b/packages/frontend/src/pages/flash/flash-edit.vue @@ -369,7 +369,6 @@ const props = defineProps<{ }>(); const flash = ref<Misskey.entities.Flash | null>(null); -const visibility = ref<'private' | 'public'>('public'); if (props.id) { flash.value = await misskeyApi('flash/show', { @@ -380,6 +379,7 @@ if (props.id) { const title = ref(flash.value?.title ?? 'New Play'); const summary = ref(flash.value?.summary ?? ''); const permissions = ref(flash.value?.permissions ?? []); +const visibility = ref<'private' | 'public'>(flash.value?.visibility ?? 'public'); const script = ref(flash.value?.script ?? PRESET_DEFAULT); function selectPreset(ev: MouseEvent) { diff --git a/packages/frontend/src/pages/flash/flash.vue b/packages/frontend/src/pages/flash/flash.vue index dde6660dbc..1b277c936a 100644 --- a/packages/frontend/src/pages/flash/flash.vue +++ b/packages/frontend/src/pages/flash/flash.vue @@ -23,6 +23,7 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else v-tooltip="i18n.ts.like" asLike class="button" rounded @click="like()"><i class="ti ti-heart"></i><span v-if="flash?.likedCount && flash.likedCount > 0" style="margin-left: 6px;">{{ flash.likedCount }}</span></MkButton> <MkButton v-tooltip="i18n.ts.copyLink" class="button" rounded @click="copyLink"><i class="ti ti-link ti-fw"></i></MkButton> <MkButton v-tooltip="i18n.ts.share" class="button" rounded @click="share"><i class="ti ti-share ti-fw"></i></MkButton> + <MkButton v-if="$i && $i.id !== flash.user.id" class="button" rounded @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></MkButton> </div> </div> </div> @@ -61,7 +62,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef } from 'vue'; +import { computed, onDeactivated, onUnmounted, Ref, ref, watch, shallowRef, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import { Interpreter, Parser, values } from '@syuilo/aiscript'; import MkButton from '@/components/MkButton.vue'; @@ -79,6 +80,7 @@ import { defaultStore } from '@/store.js'; import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { MenuItem } from '@/types/menu'; import { pleaseLogin } from '@/scripts/please-login.js'; const props = defineProps<{ @@ -229,6 +231,53 @@ async function run() { } } +function reportAbuse() { + if (!flash.value) return; + + const pageUrl = `${url}/play/${flash.value.id}`; + + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: flash.value.user, + initialComment: `Play: ${pageUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); +} + +function showMenu(ev: MouseEvent) { + if (!flash.value) return; + + const menu: MenuItem[] = [ + ...($i && $i.id !== flash.value.userId ? [ + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }, + ...($i.isModerator || $i.isAdmin ? [ + { + type: 'divider' as const, + }, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: () => os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled || !flash.value) return; + + os.apiWithDialog('flash/delete', { flashId: flash.value.id }); + }), + }, + ] : []), + ] : []), + ]; + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + function reset() { if (aiscript.value) aiscript.value.abort(); started.value = false; diff --git a/packages/frontend/src/pages/gallery/post.vue b/packages/frontend/src/pages/gallery/post.vue index 6d5a7d5ac4..913758ba7e 100644 --- a/packages/frontend/src/pages/gallery/post.vue +++ b/packages/frontend/src/pages/gallery/post.vue @@ -31,6 +31,7 @@ SPDX-License-Identifier: AGPL-3.0-only <button v-tooltip="i18n.ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="ti ti-repeat ti-fw"></i></button> <button v-tooltip="i18n.ts.copyLink" v-click-anime class="_button" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-if="isSupportShare()" v-tooltip="i18n.ts.share" v-click-anime class="_button" @click="share"><i class="ti ti-share ti-fw"></i></button> + <button v-if="$i && $i.id !== post.user.id" v-click-anime class="_button" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button> </div> </div> <div class="user"> @@ -62,7 +63,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import MkButton from '@/components/MkButton.vue'; import * as os from '@/os.js'; @@ -79,6 +80,7 @@ import { $i } from '@/account.js'; import { isSupportShare } from '@/scripts/navigator.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; import { useRouter } from '@/router/supplier.js'; +import { MenuItem } from '@/types/menu'; const router = useRouter(); @@ -153,13 +155,56 @@ function edit() { router.push(`/gallery/${post.value.id}/edit`); } +function reportAbuse() { + if (!post.value) return; + + const pageUrl = `${url}/gallery/${post.value.id}`; + + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: post.value.user, + initialComment: `Post: ${pageUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); +} + +function showMenu(ev: MouseEvent) { + if (!post.value) return; + + const menu: MenuItem[] = [ + ...($i && $i.id !== post.value.userId ? [ + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }, + ...($i.isModerator || $i.isAdmin ? [ + { + type: 'divider' as const, + }, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: () => os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled || !post.value) return; + + os.apiWithDialog('gallery/posts/delete', { postId: post.value.id }); + }), + }, + ] : []), + ] : []), + ]; + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + watch(() => props.postId, fetchPost, { immediate: true }); -const headerActions = computed(() => [{ - icon: 'ti ti-pencil', - text: i18n.ts.edit, - handler: edit, -}]); +const headerActions = computed(() => []); const headerTabs = computed(() => []); diff --git a/packages/frontend/src/pages/page-editor/common.ts b/packages/frontend/src/pages/page-editor/common.ts new file mode 100644 index 0000000000..420c8fc967 --- /dev/null +++ b/packages/frontend/src/pages/page-editor/common.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { i18n } from '@/i18n.js'; + +export function getPageBlockList() { + return [ + { value: 'section', text: i18n.ts._pages.blocks.section }, + { value: 'text', text: i18n.ts._pages.blocks.text }, + { value: 'image', text: i18n.ts._pages.blocks.image }, + { value: 'note', text: i18n.ts._pages.blocks.note }, + ]; +} diff --git a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue index 47e9c08c2c..0f8dc33143 100644 --- a/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/frontend/src/pages/page-editor/els/page-editor.el.section.vue @@ -29,6 +29,7 @@ import * as os from '@/os.js'; import { i18n } from '@/i18n.js'; import { deepClone } from '@/scripts/clone.js'; import MkButton from '@/components/MkButton.vue'; +import { getPageBlockList } from '@/pages/page-editor/common.js'; const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); @@ -53,11 +54,9 @@ watch(children, () => { deep: true, }); -const getPageBlockList = inject<(any) => any>('getPageBlockList'); - async function rename() { const { canceled, result: title } = await os.inputText({ - title: 'Enter title', + title: i18n.ts._pages.enterSectionTitle, default: props.modelValue.title, }); if (canceled) return; diff --git a/packages/frontend/src/pages/page-editor/page-editor.vue b/packages/frontend/src/pages/page-editor/page-editor.vue index 80c822a204..6bc3c0908a 100644 --- a/packages/frontend/src/pages/page-editor/page-editor.vue +++ b/packages/frontend/src/pages/page-editor/page-editor.vue @@ -76,6 +76,7 @@ import { i18n } from '@/i18n.js'; import { definePageMetadata } from '@/scripts/page-metadata.js'; import { $i } from '@/account.js'; import { mainRouter } from '@/router/main.js'; +import { getPageBlockList } from '@/pages/page-editor/common.js'; const props = defineProps<{ initPageId?: string; @@ -100,7 +101,6 @@ const alignCenter = ref(false); const hideTitleWhenPinned = ref(false); provide('readonly', readonly.value); -provide('getPageBlockList', getPageBlockList); watch(eyeCatchingImageId, async () => { if (eyeCatchingImageId.value == null) { @@ -215,15 +215,6 @@ async function add() { content.value.push({ id, type }); } -function getPageBlockList() { - return [ - { value: 'section', text: i18n.ts._pages.blocks.section }, - { value: 'text', text: i18n.ts._pages.blocks.text }, - { value: 'image', text: i18n.ts._pages.blocks.image }, - { value: 'note', text: i18n.ts._pages.blocks.note }, - ]; -} - function setEyeCatchingImage(img) { selectFile(img.currentTarget ?? img.target, null).then(file => { eyeCatchingImageId.value = file.id; diff --git a/packages/frontend/src/pages/page.vue b/packages/frontend/src/pages/page.vue index 7080802a4d..fc8627a772 100644 --- a/packages/frontend/src/pages/page.vue +++ b/packages/frontend/src/pages/page.vue @@ -62,8 +62,10 @@ SPDX-License-Identifier: AGPL-3.0-only <MkButton v-else v-tooltip="i18n.ts._pages.like" class="button" asLike @click="like()"><i class="ti ti-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> </div> <div :class="$style.other"> + <MkA v-if="page.userId === $i?.id" v-tooltip="i18n.ts._pages.editThisPage" :to="`/pages/edit/${page.id}`" class="_button" :class="$style.generalActionButton"><i class="ti ti-pencil ti-fw"></i></MkA> <button v-tooltip="i18n.ts.copyLink" class="_button" :class="$style.generalActionButton" @click="copyLink"><i class="ti ti-link ti-fw"></i></button> <button v-tooltip="i18n.ts.share" class="_button" :class="$style.generalActionButton" @click="share"><i class="ti ti-share ti-fw"></i></button> + <button v-if="$i" v-click-anime class="_button" :class="$style.generalActionButton" @mousedown="showMenu"><i class="ti ti-dots ti-fw"></i></button> </div> </div> <div :class="$style.pageUser"> @@ -78,14 +80,6 @@ SPDX-License-Identifier: AGPL-3.0-only <div><i class="ti ti-clock"></i> {{ i18n.ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> <div v-if="page.createdAt != page.updatedAt"><i class="ti ti-clock-edit"></i> {{ i18n.ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> </div> - <div :class="$style.pageLinks"> - <MkA v-if="!$i || $i.id !== page.userId" :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ i18n.ts._pages.viewSource }}</MkA> - <template v-if="$i && $i.id === page.userId"> - <MkA :to="`/pages/edit/${page.id}`" class="link">{{ i18n.ts._pages.editThisPage }}</MkA> - <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ i18n.ts.unpin }}</button> - <button v-else class="link _textButton" @click="pin(true)">{{ i18n.ts.pin }}</button> - </template> - </div> </div> <MkAd :prefer="['horizontal', 'horizontal-big']"/> <MkContainer :max-height="300" :foldable="true" class="other"> @@ -104,7 +98,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, ref } from 'vue'; +import { computed, watch, ref, defineAsyncComponent } from 'vue'; import * as Misskey from 'misskey-js'; import XPage from '@/components/page/page.vue'; import MkButton from '@/components/MkButton.vue'; @@ -126,6 +120,10 @@ import { isSupportShare } from '@/scripts/navigator.js'; import { instance } from '@/instance.js'; import { getStaticImageUrl } from '@/scripts/media-proxy.js'; import { copyToClipboard } from '@/scripts/copy-to-clipboard.js'; +import { useRouter } from '@/router/supplier.js'; +import { MenuItem } from '@/types/menu'; + +const router = useRouter(); const props = defineProps<{ pageName: string; @@ -242,6 +240,69 @@ function pin(pin) { }); } +function reportAbuse() { + if (!page.value) return; + + const pageUrl = `${url}/@${props.username}/pages/${props.pageName}`; + + const { dispose } = os.popup(defineAsyncComponent(() => import('@/components/MkAbuseReportWindow.vue')), { + user: page.value.user, + initialComment: `Page: ${pageUrl}\n-----\n`, + }, { + closed: () => dispose(), + }); +} + +function showMenu(ev: MouseEvent) { + if (!page.value) return; + + const menu: MenuItem[] = [ + ...($i && $i.id === page.value.userId ? [ + { + icon: 'ti ti-code', + text: i18n.ts._pages.viewSource, + action: () => router.push(`/@${props.username}/pages/${props.pageName}/view-source`), + }, + ...($i.pinnedPageId === page.value.id ? [{ + icon: 'ti ti-pinned-off', + text: i18n.ts.unpin, + action: () => pin(false), + }] : [{ + icon: 'ti ti-pin', + text: i18n.ts.pin, + action: () => pin(true), + }]), + ] : []), + ...($i && $i.id !== page.value.userId ? [ + { + icon: 'ti ti-exclamation-circle', + text: i18n.ts.reportAbuse, + action: reportAbuse, + }, + ...($i.isModerator || $i.isAdmin ? [ + { + type: 'divider' as const, + }, + { + icon: 'ti ti-trash', + text: i18n.ts.delete, + danger: true, + action: () => os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }).then(({ canceled }) => { + if (canceled || !page.value) return; + + os.apiWithDialog('pages/delete', { pageId: page.value.id }); + }), + }, + ] : []), + ] : []), + ]; + + os.popupMenu(menu, ev.currentTarget ?? ev.target); +} + watch(() => path.value, fetchPage, { immediate: true }); const headerActions = computed(() => []); diff --git a/packages/frontend/src/pages/settings/index.vue b/packages/frontend/src/pages/settings/index.vue index 67df43e15b..f6f2ffc553 100644 --- a/packages/frontend/src/pages/settings/index.vue +++ b/packages/frontend/src/pages/settings/index.vue @@ -197,9 +197,6 @@ const menuDef = computed(() => [{ }], }]); -watch(narrow, () => { -}); - onMounted(() => { ro.observe(el.value); diff --git a/packages/frontend/src/pages/timeline.vue b/packages/frontend/src/pages/timeline.vue index d93ceadf56..20d8abccf6 100644 --- a/packages/frontend/src/pages/timeline.vue +++ b/packages/frontend/src/pages/timeline.vue @@ -35,7 +35,7 @@ SPDX-License-Identifier: AGPL-3.0-only </template> <script lang="ts" setup> -import { computed, watch, provide, shallowRef, ref } from 'vue'; +import { computed, watch, provide, shallowRef, ref, onMounted, onActivated } from 'vue'; import type { Tab } from '@/components/global/MkPageHeader.tabs.vue'; import MkTimeline from '@/components/MkTimeline.vue'; import MkInfo from '@/components/MkInfo.vue'; @@ -54,15 +54,18 @@ import { deepMerge } from '@/scripts/merge.js'; import { MenuItem } from '@/types/menu.js'; import { miLocalStorage } from '@/local-storage.js'; import { availableBasicTimelines, hasWithReplies, isAvailableBasicTimeline, isBasicTimeline, basicTimelineIconClass } from '@/timelines.js'; +import type { BasicTimelineType } from '@/timelines.js'; provide('shouldOmitHeaderTitle', true); const tlComponent = shallowRef<InstanceType<typeof MkTimeline>>(); const rootEl = shallowRef<HTMLElement>(); +type TimelinePageSrc = BasicTimelineType | `list:${string}`; + const queue = ref(0); const srcWhenNotSignin = ref<'local' | 'global'>(isAvailableBasicTimeline('local') ? 'local' : 'global'); -const src = computed<'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`>({ +const src = computed<TimelinePageSrc>({ get: () => ($i ? defaultStore.reactiveState.tl.value.src : srcWhenNotSignin.value), set: (x) => saveSrc(x), }); @@ -201,7 +204,7 @@ async function chooseChannel(ev: MouseEvent): Promise<void> { os.popupMenu(items, ev.currentTarget ?? ev.target); } -function saveSrc(newSrc: 'home' | 'local' | 'social' | 'global' | 'bubble' | `list:${string}`): void { +function saveSrc(newSrc: TimelinePageSrc): void { const out = deepMerge({ src: newSrc }, defaultStore.state.tl); if (newSrc.startsWith('userList:')) { @@ -242,6 +245,19 @@ function closeTutorial(): void { defaultStore.set('timelineTutorials', before); } +function switchTlIfNeeded() { + if (isBasicTimeline(src.value) && !isAvailableBasicTimeline(src.value)) { + src.value = availableBasicTimelines()[0]; + } +} + +onMounted(() => { + switchTlIfNeeded(); +}); +onActivated(() => { + switchTlIfNeeded(); +}); + const headerActions = computed(() => { const tmp = [ { diff --git a/packages/frontend/src/scripts/focus-trap.ts b/packages/frontend/src/scripts/focus-trap.ts index a5df36f520..fb7caea830 100644 --- a/packages/frontend/src/scripts/focus-trap.ts +++ b/packages/frontend/src/scripts/focus-trap.ts @@ -16,21 +16,57 @@ function containsFocusTrappedElements(el: HTMLElement): boolean { }); } +function getZIndex(el: HTMLElement): number { + const zIndex = parseInt(window.getComputedStyle(el).zIndex || '0', 10); + if (isNaN(zIndex)) { + return 0; + } + return zIndex; +} + +function getHighestZIndexElement(): { el: HTMLElement; zIndex: number; } | null { + let highestZIndexElement: HTMLElement | null = null; + let highestZIndex = -Infinity; + + focusTrapElements.forEach((el) => { + const zIndex = getZIndex(el); + if (zIndex > highestZIndex) { + highestZIndex = zIndex; + highestZIndexElement = el; + } + }); + + return highestZIndexElement == null ? null : { + el: highestZIndexElement, + zIndex: highestZIndex, + }; +} + function releaseFocusTrap(el: HTMLElement): void { focusTrapElements.delete(el); if (el.inert === true) { el.inert = false; } + + const highestZIndexElement = getHighestZIndexElement(); + if (el.parentElement != null && el !== document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); if (!siblingEl) return; - if (siblingEl !== el && (focusTrapElements.has(siblingEl) || containsFocusTrappedElements(siblingEl) || focusTrapElements.size === 0)) { + if ( + siblingEl !== el && + ( + highestZIndexElement == null || + siblingEl === highestZIndexElement.el || + siblingEl.contains(highestZIndexElement.el) + ) + ) { siblingEl.inert = false; } else if ( - focusTrapElements.size > 0 && - !containsFocusTrappedElements(siblingEl) && - !focusTrapElements.has(siblingEl) && + highestZIndexElement != null && + siblingEl !== highestZIndexElement.el && + !siblingEl.contains(highestZIndexElement.el) && !ignoreElements.includes(siblingEl.tagName.toLowerCase()) ) { siblingEl.inert = true; @@ -45,9 +81,29 @@ function releaseFocusTrap(el: HTMLElement): void { export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls: boolean, parent: true): void; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls?: boolean, parent?: false): { release: () => void; }; export function focusTrap(el: HTMLElement, hasInteractionWithOtherFocusTrappedEls = false, parent = false): { release: () => void; } | void { + const highestZIndexElement = getHighestZIndexElement(); + + const highestZIndex = highestZIndexElement == null ? -Infinity : highestZIndexElement.zIndex; + const zIndex = getZIndex(el); + + // If the element has a lower z-index than the highest z-index element, focus trap the highest z-index element instead + // Focus trapping for this element will be done in the release function + if (!parent && zIndex < highestZIndex) { + focusTrapElements.add(el); + if (highestZIndexElement) { + focusTrap(highestZIndexElement.el, hasInteractionWithOtherFocusTrappedEls); + } + return { + release: () => { + releaseFocusTrap(el); + }, + }; + } + if (el.inert === true) { el.inert = false; } + if (el.parentElement != null && el !== document.body) { el.parentElement.childNodes.forEach((siblingNode) => { const siblingEl = getHTMLElementOrNull(siblingNode); diff --git a/packages/frontend/src/scripts/get-appear-note.ts b/packages/frontend/src/scripts/get-appear-note.ts new file mode 100644 index 0000000000..40ce80eac9 --- /dev/null +++ b/packages/frontend/src/scripts/get-appear-note.ts @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import * as Misskey from 'misskey-js'; + +export function getAppearNote(note: Misskey.entities.Note) { + return Misskey.note.isPureRenote(note) ? note.renote : note; +} diff --git a/packages/frontend/src/scripts/get-note-menu.ts b/packages/frontend/src/scripts/get-note-menu.ts index 76a9400daa..a7ec4ce6d7 100644 --- a/packages/frontend/src/scripts/get-note-menu.ts +++ b/packages/frontend/src/scripts/get-note-menu.ts @@ -20,6 +20,7 @@ import { clipsCache, favoritedChannelsCache } from '@/cache.js'; import { MenuItem } from '@/types/menu.js'; import MkRippleEffect from '@/components/MkRippleEffect.vue'; import { isSupportShare } from '@/scripts/navigator.js'; +import { getAppearNote } from '@/scripts/get-appear-note.js'; export async function getNoteClipMenu(props: { note: Misskey.entities.Note; @@ -34,14 +35,7 @@ export async function getNoteClipMenu(props: { } } - const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds.length === 0 && - props.note.poll == null - ); - - const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + const appearNote = getAppearNote(props.note); const clips = await clipsCache.fetch(); const menu: MenuItem[] = [...clips.map(clip => ({ @@ -175,14 +169,7 @@ export function getNoteMenu(props: { isDeleted: Ref<boolean>; currentClip?: Misskey.entities.Clip; }) { - const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds.length === 0 && - props.note.poll == null - ); - - const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + const appearNote = getAppearNote(props.note); const cleanups = [] as (() => void)[]; @@ -270,6 +257,7 @@ export function getNoteMenu(props: { } async function unclip(): Promise<void> { + if (!props.currentClip) return; os.apiWithDialog('clips/remove-note', { clipId: props.currentClip.id, noteId: appearNote.id }); props.isDeleted.value = true; } @@ -289,8 +277,8 @@ export function getNoteMenu(props: { function share(): void { navigator.share({ - title: i18n.tsx.noteOf({ user: appearNote.user.name }), - text: appearNote.text, + title: i18n.tsx.noteOf({ user: appearNote.user.name ?? appearNote.user.username }), + text: appearNote.text ?? '', url: `${url}/notes/${appearNote.id}`, }); } @@ -543,14 +531,7 @@ export function getRenoteMenu(props: { renoteButton: ShallowRef<HTMLElement | undefined>; mock?: boolean; }) { - const isRenote = ( - props.note.renote != null && - props.note.text == null && - props.note.fileIds.length === 0 && - props.note.poll == null - ); - - const appearNote = isRenote ? props.note.renote as Misskey.entities.Note : props.note; + const appearNote = getAppearNote(props.note); const channelRenoteItems: MenuItem[] = []; const normalRenoteItems: MenuItem[] = []; diff --git a/packages/frontend/src/scripts/isFfVisibleForMe.ts b/packages/frontend/src/scripts/isFfVisibleForMe.ts index 406404c462..e28e5725bc 100644 --- a/packages/frontend/src/scripts/isFfVisibleForMe.ts +++ b/packages/frontend/src/scripts/isFfVisibleForMe.ts @@ -7,7 +7,7 @@ import * as Misskey from 'misskey-js'; import { $i } from '@/account.js'; export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): boolean { - if ($i && $i.id === user.id) return true; + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; if (user.followingVisibility === 'private') return false; if (user.followingVisibility === 'followers' && !user.isFollowing) return false; @@ -15,7 +15,7 @@ export function isFollowingVisibleForMe(user: Misskey.entities.UserDetailed): bo return true; } export function isFollowersVisibleForMe(user: Misskey.entities.UserDetailed): boolean { - if ($i && $i.id === user.id) return true; + if ($i && ($i.id === user.id || $i.isAdmin || $i.isModerator)) return true; if (user.followersVisibility === 'private') return false; if (user.followersVisibility === 'followers' && !user.isFollowing) return false; diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 99aeed0273..331c8cc95f 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1203,6 +1203,7 @@ declare namespace entities { export { ID, DateString, + PureRenote, PageEvent, ModerationLog, ServerStats, @@ -2330,6 +2331,9 @@ type ISigninHistoryRequest = operations['i___signin-history']['requestBody']['co type ISigninHistoryResponse = operations['i___signin-history']['responses']['200']['content']['application/json']; // @public (undocumented) +function isPureRenote(note: Note): note is PureRenote; + +// @public (undocumented) type IUnpinRequest = operations['i___unpin']['requestBody']['content']['application/json']; // @public (undocumented) @@ -2516,6 +2520,9 @@ type ModerationLog = { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; } | { + type: 'unsetUserBanner'; + info: ModerationLogPayloads['unsetUserBanner']; +} | { type: 'createSystemWebhook'; info: ModerationLogPayloads['createSystemWebhook']; } | { @@ -2533,10 +2540,22 @@ type ModerationLog = { } | { type: 'deleteAbuseReportNotificationRecipient'; info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; +} | { + type: 'deleteAccount'; + info: ModerationLogPayloads['deleteAccount']; +} | { + type: 'deletePage'; + info: ModerationLogPayloads['deletePage']; +} | { + type: 'deleteFlash'; + info: ModerationLogPayloads['deleteFlash']; +} | { + type: 'deleteGalleryPost'; + info: ModerationLogPayloads['deleteGalleryPost']; }); // @public (undocumented) -export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient"]; +export const moderationLogTypes: readonly ["updateServerSettings", "suspend", "approve", "unsuspend", "updateUserNote", "addCustomEmoji", "updateCustomEmoji", "deleteCustomEmoji", "assignRole", "unassignRole", "createRole", "updateRole", "deleteRole", "clearQueue", "promoteQueue", "deleteDriveFile", "deleteNote", "createGlobalAnnouncement", "createUserAnnouncement", "updateGlobalAnnouncement", "updateUserAnnouncement", "deleteGlobalAnnouncement", "deleteUserAnnouncement", "resetPassword", "suspendRemoteInstance", "unsuspendRemoteInstance", "updateRemoteInstanceNote", "markSensitiveDriveFile", "unmarkSensitiveDriveFile", "resolveAbuseReport", "createInvitation", "createAd", "updateAd", "deleteAd", "createAvatarDecoration", "updateAvatarDecoration", "deleteAvatarDecoration", "unsetUserAvatar", "unsetUserBanner", "createSystemWebhook", "updateSystemWebhook", "deleteSystemWebhook", "createAbuseReportNotificationRecipient", "updateAbuseReportNotificationRecipient", "deleteAbuseReportNotificationRecipient"], "deleteAccount", "deletePage", "deleteFlash", "deleteGalleryPost"]; // @public (undocumented) type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; @@ -2565,6 +2584,13 @@ type MyAppsResponse = operations['my___apps']['responses']['200']['content']['ap // @public (undocumented) type Note = components['schemas']['Note']; +declare namespace note { + export { + isPureRenote + } +} +export { note } + // @public (undocumented) type NoteFavorite = components['schemas']['NoteFavorite']; @@ -2826,6 +2852,15 @@ type PinnedUsersResponse = operations['pinned-users']['responses']['200']['conte // @public (undocumented) type PromoReadRequest = operations['promo___read']['requestBody']['content']['application/json']; +// Warning: (ae-forgotten-export) The symbol "AllNullRecord" needs to be exported by the entry point index.d.ts +// Warning: (ae-forgotten-export) The symbol "NonNullableRecord" needs to be exported by the entry point index.d.ts +// +// @public (undocumented) +type PureRenote = Omit<Note, 'renote' | 'renoteId' | 'reply' | 'replyId' | 'text' | 'cw' | 'files' | 'fileIds' | 'poll'> & AllNullRecord<Pick<Note, 'reply' | 'replyId' | 'text' | 'cw' | 'poll'>> & { + files: []; + fileIds: []; +} & NonNullableRecord<Pick<Note, 'renote' | 'renoteId'>>; + // @public (undocumented) type QueueCount = components['schemas']['QueueCount']; @@ -2906,6 +2941,9 @@ type ReversiShowGameResponse = operations['reversi___show-game']['responses']['2 type ReversiSurrenderRequest = operations['reversi___surrender']['requestBody']['content']['application/json']; // @public (undocumented) +export const reversiUpdateKeys: ["map", "bw", "isLlotheo", "canPutEverywhere", "loopedBoard", "timeLimitForEachTurn"]; + +// @public (undocumented) type ReversiVerifyRequest = operations['reversi___verify']['requestBody']['content']['application/json']; // @public (undocumented) @@ -3305,9 +3343,6 @@ type UsersUpdateMemoRequest = operations['users___update-memo']['requestBody'][' // Warnings were encountered during analysis: // -// src/entities.ts:35:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:236:4 - (ae-forgotten-export) The symbol "ReversiUpdateKey" needs to be exported by the entry point index.d.ts -// src/streaming.types.ts:246:4 - (ae-forgotten-export) The symbol "ReversiUpdateSettings" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/package.json b/packages/misskey-js/package.json index f0e8733e67..39e687d4af 100644 --- a/packages/misskey-js/package.json +++ b/packages/misskey-js/package.json @@ -1,7 +1,7 @@ { "type": "module", "name": "misskey-js", - "version": "2024.7.0", + "version": "2024.8.0", "description": "Misskey SDK for JavaScript", "license": "MIT", "main": "./built/index.js", diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index ec2b8410da..562c498190 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4816,6 +4816,8 @@ export type components = { title: string; summary: string; script: string; + /** @enum {string} */ + visibility: 'private' | 'public'; likedCount: number | null; isLiked?: boolean; }; diff --git a/packages/misskey-js/src/consts.ts b/packages/misskey-js/src/consts.ts index 4b269605ac..890b43639d 100644 --- a/packages/misskey-js/src/consts.ts +++ b/packages/misskey-js/src/consts.ts @@ -1,11 +1,19 @@ import type { operations } from './autogen/types.js'; import type { - AbuseReportNotificationRecipient, Ad, + AbuseReportNotificationRecipient, + Ad, Announcement, - EmojiDetailed, InviteCode, + EmojiDetailed, + Flash, + GalleryPost, + InviteCode, MetaDetailed, Note, - Role, SystemWebhook, UserLite, + Page, + Role, + ReversiGameDetailed, + SystemWebhook, + UserLite, } from './autogen/models.js'; export const notificationTypes = ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'roleAssigned', 'achievementEarned', 'edited'] as const; @@ -155,6 +163,10 @@ export const moderationLogTypes = [ 'createAbuseReportNotificationRecipient', 'updateAbuseReportNotificationRecipient', 'deleteAbuseReportNotificationRecipient', + 'deleteAccount', + 'deletePage', + 'deleteFlash', + 'deleteGalleryPost', ] as const; // See: packages/backend/src/core/ReversiService.ts@L410 @@ -165,7 +177,7 @@ export const reversiUpdateKeys = [ 'canPutEverywhere', 'loopedBoard', 'timeLimitForEachTurn', -] as const; +] as const satisfies (keyof ReversiGameDetailed)[]; export type ReversiUpdateKey = typeof reversiUpdateKeys[number]; @@ -398,4 +410,27 @@ export type ModerationLogPayloads = { recipientId: string; recipient: AbuseReportNotificationRecipient; }; + deleteAccount: { + userId: string; + userUsername: string; + userHost: string | null; + }; + deletePage: { + pageId: string; + pageUserId: string; + pageUserUsername: string; + page: Page; + }; + deleteFlash: { + flashId: string; + flashUserId: string; + flashUserUsername: string; + flash: Flash; + }; + deleteGalleryPost: { + postId: string; + postUserId: string; + postUserUsername: string; + post: GalleryPost; + }; }; diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index 94f6ac5f83..6a405f81a5 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -3,6 +3,7 @@ import { Announcement, EmojiDetailed, MeDetailed, + Note, Page, Role, RolePolicies, @@ -16,6 +17,19 @@ export * from './autogen/models.js'; export type ID = string; export type DateString = string; +type NonNullableRecord<T> = { + [P in keyof T]-?: NonNullable<T[P]>; +}; +type AllNullRecord<T> = { + [P in keyof T]: null; +}; + +export type PureRenote = + Omit<Note, 'renote' | 'renoteId' | 'reply' | 'replyId' | 'text' | 'cw' | 'files' | 'fileIds' | 'poll'> + & AllNullRecord<Pick<Note, 'reply' | 'replyId' | 'text' | 'cw' | 'poll'>> + & { files: []; fileIds: []; } + & NonNullableRecord<Pick<Note, 'renote' | 'renoteId'>>; + export type PageEvent = { pageId: Page['id']; event: string; @@ -145,6 +159,9 @@ export type ModerationLog = { type: 'unsetUserAvatar'; info: ModerationLogPayloads['unsetUserAvatar']; } | { + type: 'unsetUserBanner'; + info: ModerationLogPayloads['unsetUserBanner']; +} | { type: 'createSystemWebhook'; info: ModerationLogPayloads['createSystemWebhook']; } | { @@ -162,6 +179,18 @@ export type ModerationLog = { } | { type: 'deleteAbuseReportNotificationRecipient'; info: ModerationLogPayloads['deleteAbuseReportNotificationRecipient']; +} | { + type: 'deleteAccount'; + info: ModerationLogPayloads['deleteAccount']; +} | { + type: 'deletePage'; + info: ModerationLogPayloads['deletePage']; +} | { + type: 'deleteFlash'; + info: ModerationLogPayloads['deleteFlash']; +} | { + type: 'deleteGalleryPost'; + info: ModerationLogPayloads['deleteGalleryPost']; }); export type ServerStats = { diff --git a/packages/misskey-js/src/index.ts b/packages/misskey-js/src/index.ts index 28007a8ade..ace9738e6a 100644 --- a/packages/misskey-js/src/index.ts +++ b/packages/misskey-js/src/index.ts @@ -22,6 +22,7 @@ export const mutedNoteReasons = consts.mutedNoteReasons; export const followingVisibilities = consts.followingVisibilities; export const followersVisibilities = consts.followersVisibilities; export const moderationLogTypes = consts.moderationLogTypes; +export const reversiUpdateKeys = consts.reversiUpdateKeys; // api extractor not supported yet //export * as api from './api.js'; @@ -29,4 +30,5 @@ export const moderationLogTypes = consts.moderationLogTypes; import * as api from './api.js'; import * as entities from './entities.js'; import * as acct from './acct.js'; -export { api, entities, acct }; +import * as note from './note.js'; +export { api, entities, acct, note }; diff --git a/packages/misskey-js/src/note.ts b/packages/misskey-js/src/note.ts new file mode 100644 index 0000000000..5c8298c7e4 --- /dev/null +++ b/packages/misskey-js/src/note.ts @@ -0,0 +1,12 @@ +import type { Note, PureRenote } from './entities.js'; + +export function isPureRenote(note: Note): note is PureRenote { + return ( + note.renote != null && + note.reply == null && + note.text == null && + note.cw == null && + (note.fileIds == null || note.fileIds.length === 0) && + note.poll == null + ); +} diff --git a/packages/misskey-js/test-d/api.ts b/packages/misskey-js/test-d/api.ts index 4b72ff4e9d..ca6d8dcb88 100644 --- a/packages/misskey-js/test-d/api.ts +++ b/packages/misskey-js/test-d/api.ts @@ -11,7 +11,7 @@ describe('API', () => { expectType<Misskey.entities.MetaResponse>(res); }); - test('conditional respose type (meta)', async () => { + test('conditional response type (meta)', async () => { const cli = new Misskey.api.APIClient({ origin: 'https://misskey.test', credential: 'TOKEN' @@ -30,7 +30,7 @@ describe('API', () => { expectType<Misskey.entities.MetaResponse>(res4); }); - test('conditional respose type (users/show)', async () => { + test('conditional response type (users/show)', async () => { const cli = new Misskey.api.APIClient({ origin: 'https://misskey.test', credential: 'TOKEN' |