diff --git a/.config/example.yml b/.config/example.yml index ef91c86f52..8b9d9b4823 100644 --- a/.config/example.yml +++ b/.config/example.yml @@ -57,6 +57,7 @@ db: redis: host: localhost port: 6379 + #family: 0 # 0=Both, 4=IPv4, 6=IPv6 #pass: example-pass #prefix: example-prefix #db: 1 diff --git a/CHANGELOG.md b/CHANGELOG.md index a36662182f..9390b0e276 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,14 +12,24 @@ You should also include the user name that made the change. ## 12.x.x (unreleased) ### Improvements +- Server: Allow GET method for some endpoints @syuilo - Server: Add rate limit to i/notifications @tamaina -- Client: Improve files page of control panel @syuilo +- Client: Improve control panel @syuilo +- Client: Show warning in control panel when there is an unresolved abuse report @syuilo +- Make possible to delete an account by admin @syuilo +- Improve player detection in URL preview @mei23 +- Add Badge Image to Push Notification #8012 @tamaina +- Client: Removing entries from a clip @futchitwo +- Server: Supports IPv6 on Redis transport. @mei23 + IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`. - Migrate to Yarn Berry (v3.2.1) @ThatOneCalculator - You may have to `yarn run clean-all` and `yarn set version berry` before running `yarn install` if you're still on yarn classic ### Bugfixes - Server: Fix GenerateVideoThumbnail failed @mei23 - Server: Ensure temp directory cleanup @Johann150 +- favicons of federated instances not showing @syuilo +- Admin: The checkbox for blocking an instance works again @Johann150 ## 12.111.1 (2022/06/13) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 43ab7f2d69..f813389225 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -643,6 +643,8 @@ clip: "クリップ" createNew: "新規作成" optional: "任意" createNewClip: "新しいクリップを作成" +unclip: "クリップ解除" +confirmToUnclipAlreadyClippedNote: "このノートはすでにクリップ「{name}」に含まれています。ノートをこのクリップから除外しますか?" public: "パブリック" i18nInfo: "Misskeyは有志によって様々な言語に翻訳されています。{link}で翻訳に協力できます。" manageAccessTokens: "アクセストークンの管理" @@ -845,6 +847,16 @@ failedToFetchAccountInformation: "アカウント情報の取得に失敗しま rateLimitExceeded: "レート制限を超えました" cropImage: "画像のクロップ" cropImageAsk: "画像をクロップしますか?" +file: "ファイル" +recentNHours: "直近{n}時間" +recentNDays: "直近{n}日" +noEmailServerWarning: "メールサーバーの設定がされていません。" +thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。" +recommended: "推奨" +check: "チェック" +isSystemAccount: "システムにより自動で作成・管理されているアカウントです。" +typeToConfirm: "この操作を行うには {x} と入力してください" +deleteAccount: "アカウント削除" _emailUnavailable: used: "既に使用されています" diff --git a/package.json b/package.json index 69847c8477..a791d90326 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "12.111.1", + "version": "12.112.0-beta.6", "codename": "indigo", "repository": { "type": "git", diff --git a/packages/backend/.mocharc.json b/packages/backend/.mocharc.json index 87c571cfd6..f836f9e900 100644 --- a/packages/backend/.mocharc.json +++ b/packages/backend/.mocharc.json @@ -5,6 +5,6 @@ "loader=./test/loader.js" ], "slow": 1000, - "timeout": 10000, + "timeout": 30000, "exit": true } diff --git a/packages/backend/assets/notification-badges/LICENSE b/packages/backend/assets/notification-badges/LICENSE new file mode 100644 index 0000000000..841c4c682b --- /dev/null +++ b/packages/backend/assets/notification-badges/LICENSE @@ -0,0 +1,5 @@ +Font Awesome Icons +------------------------- + +Ⓒ Font Awesome +CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/) diff --git a/packages/backend/assets/notification-badges/at.png b/packages/backend/assets/notification-badges/at.png new file mode 100644 index 0000000000..d1492856de Binary files /dev/null and b/packages/backend/assets/notification-badges/at.png differ diff --git a/packages/backend/assets/notification-badges/check.png b/packages/backend/assets/notification-badges/check.png new file mode 100644 index 0000000000..baeb76babf Binary files /dev/null and b/packages/backend/assets/notification-badges/check.png differ diff --git a/packages/backend/assets/notification-badges/clipboard-check-solid.png b/packages/backend/assets/notification-badges/clipboard-check-solid.png new file mode 100644 index 0000000000..d8cdfa9da4 Binary files /dev/null and b/packages/backend/assets/notification-badges/clipboard-check-solid.png differ diff --git a/packages/backend/assets/notification-badges/clock.png b/packages/backend/assets/notification-badges/clock.png new file mode 100644 index 0000000000..9323f8f307 Binary files /dev/null and b/packages/backend/assets/notification-badges/clock.png differ diff --git a/packages/backend/assets/notification-badges/comments.png b/packages/backend/assets/notification-badges/comments.png new file mode 100644 index 0000000000..bc8a1c35b4 Binary files /dev/null and b/packages/backend/assets/notification-badges/comments.png differ diff --git a/packages/backend/assets/notification-badges/id-card-alt.png b/packages/backend/assets/notification-badges/id-card-alt.png new file mode 100644 index 0000000000..67e1410e34 Binary files /dev/null and b/packages/backend/assets/notification-badges/id-card-alt.png differ diff --git a/packages/backend/assets/notification-badges/null.png b/packages/backend/assets/notification-badges/null.png new file mode 100644 index 0000000000..be1384df13 Binary files /dev/null and b/packages/backend/assets/notification-badges/null.png differ diff --git a/packages/backend/assets/notification-badges/plus.png b/packages/backend/assets/notification-badges/plus.png new file mode 100644 index 0000000000..05362c122b Binary files /dev/null and b/packages/backend/assets/notification-badges/plus.png differ diff --git a/packages/backend/assets/notification-badges/poll-h.png b/packages/backend/assets/notification-badges/poll-h.png new file mode 100644 index 0000000000..3b7ded6659 Binary files /dev/null and b/packages/backend/assets/notification-badges/poll-h.png differ diff --git a/packages/backend/assets/notification-badges/quote-right.png b/packages/backend/assets/notification-badges/quote-right.png new file mode 100644 index 0000000000..0fa4837654 Binary files /dev/null and b/packages/backend/assets/notification-badges/quote-right.png differ diff --git a/packages/backend/assets/notification-badges/reply.png b/packages/backend/assets/notification-badges/reply.png new file mode 100644 index 0000000000..77021f71a7 Binary files /dev/null and b/packages/backend/assets/notification-badges/reply.png differ diff --git a/packages/backend/assets/notification-badges/retweet.png b/packages/backend/assets/notification-badges/retweet.png new file mode 100644 index 0000000000..dc61060481 Binary files /dev/null and b/packages/backend/assets/notification-badges/retweet.png differ diff --git a/packages/backend/assets/notification-badges/user-plus.png b/packages/backend/assets/notification-badges/user-plus.png new file mode 100644 index 0000000000..9d376d04d6 Binary files /dev/null and b/packages/backend/assets/notification-badges/user-plus.png differ diff --git a/packages/backend/package.json b/packages/backend/package.json index 8010502d32..cf10ece1a6 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -53,6 +53,7 @@ "fluent-ffmpeg": "2.1.2", "got": "12.1.0", "hpagent": "0.1.2", + "ioredis": "4.28.5", "ip-cidr": "3.0.10", "is-svg": "4.3.2", "js-yaml": "4.1.0", @@ -60,7 +61,7 @@ "json5": "2.2.1", "json5-loader": "4.0.1", "jsonld": "6.0.0", - "jsrsasign": "10.5.24", + "jsrsasign": "10.5.25", "koa": "2.13.4", "koa-bodyparser": "4.3.0", "koa-favicon": "2.1.0", @@ -93,7 +94,6 @@ "random-seed": "0.3.0", "ratelimiter": "3.4.1", "re2": "1.17.4", - "redis": "3.1.2", "redis-lock": "0.1.4", "reflect-metadata": "0.1.13", "rename": "1.0.4", @@ -107,7 +107,7 @@ "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "style-loader": "3.3.1", - "summaly": "2.5.1", + "summaly": "2.6.0", "syslog-pro": "1.0.0", "systeminformation": "5.11.16", "tinycolor2": "1.4.2", diff --git a/packages/backend/src/config/types.ts b/packages/backend/src/config/types.ts index 948545db7a..78510c8377 100644 --- a/packages/backend/src/config/types.ts +++ b/packages/backend/src/config/types.ts @@ -19,6 +19,7 @@ export type Source = { redis: { host: string; port: number; + family?: number; pass: string; db?: number; prefix?: string; diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 298f6713ea..904bbb8b7c 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -192,12 +192,13 @@ export const db = new DataSource({ synchronize: process.env.NODE_ENV === 'test', dropSchema: process.env.NODE_ENV === 'test', cache: !config.db.disableCache ? { - type: 'redis', + type: 'ioredis', options: { host: config.redis.host, port: config.redis.port, + family: config.redis.family == null ? 0 : config.redis.family, password: config.redis.pass, - prefix: `${config.redis.prefix}:query:`, + keyPrefix: `${config.redis.prefix}:query:`, db: config.redis.db || 0, }, } : false, @@ -226,7 +227,7 @@ export async function initDb(force = false) { export async function resetDb() { const reset = async () => { - await redisClient.FLUSHDB(); + await redisClient.flushdb(); const tables = await db.query(`SELECT relname AS "table" FROM pg_class C LEFT JOIN pg_namespace N ON (N.oid = C.relnamespace) WHERE nspname NOT IN ('pg_catalog', 'information_schema') diff --git a/packages/backend/src/db/redis.ts b/packages/backend/src/db/redis.ts index 9346041456..49f5bb2ba8 100644 --- a/packages/backend/src/db/redis.ts +++ b/packages/backend/src/db/redis.ts @@ -1,16 +1,15 @@ -import * as redis from 'redis'; +import Redis from 'ioredis'; import config from '@/config/index.js'; export function createConnection() { - return redis.createClient( - config.redis.port, - config.redis.host, - { - password: config.redis.pass, - prefix: config.redis.prefix, - db: config.redis.db || 0, - } - ); + return new Redis({ + port: config.redis.port, + host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, + password: config.redis.pass, + keyPrefix: `${config.redis.prefix}:`, + db: config.redis.db || 0, + }); } export const subsdcriber = createConnection(); diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index 588dc79e55..d7662820af 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -16,11 +16,13 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (me && (note.userId === me.id)) return false; if (mutedWords.length > 0) { - if (note.text == null) return false; + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; const matched = mutedWords.some(filter => { if (Array.isArray(filter)) { - return filter.every(keyword => note.text!.includes(keyword)); + return filter.every(keyword => text.includes(keyword)); } else { // represents RegExp const regexp = filter.match(/^\/(.+)\/(.*)$/); @@ -29,7 +31,7 @@ export async function checkWordMute(note: NoteLike, me: UserLike | null | undefi if (!regexp) return false; try { - return new RE2(regexp[1], regexp[2]).test(note.text!); + return new RE2(regexp[1], regexp[2]).test(text); } catch (err) { // This should never happen due to input sanitisation. return false; diff --git a/packages/backend/src/misc/is-blocker-user-related.ts b/packages/backend/src/misc/is-blocker-user-related.ts deleted file mode 100644 index 8c0ebfad9b..0000000000 --- a/packages/backend/src/misc/is-blocker-user-related.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function isBlockerUserRelated(note: any, blockerUserIds: Set): boolean { - if (blockerUserIds.has(note.userId)) { - return true; - } - - if (note.reply != null && blockerUserIds.has(note.reply.userId)) { - return true; - } - - if (note.renote != null && blockerUserIds.has(note.renote.userId)) { - return true; - } - - return false; -} diff --git a/packages/backend/src/misc/is-mime-image.ts b/packages/backend/src/misc/is-mime-image.ts new file mode 100644 index 0000000000..8993ede33a --- /dev/null +++ b/packages/backend/src/misc/is-mime-image.ts @@ -0,0 +1,8 @@ +import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; + +const dictionary = { + 'safe-file': FILE_TYPE_BROWSERSAFE, + 'sharp-convertible-image': ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'], +}; + +export const isMimeImage = (mime: string, type: keyof typeof dictionary): boolean => dictionary[type].includes(mime); diff --git a/packages/backend/src/misc/is-muted-user-related.ts b/packages/backend/src/misc/is-muted-user-related.ts deleted file mode 100644 index 2caa743f95..0000000000 --- a/packages/backend/src/misc/is-muted-user-related.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function isMutedUserRelated(note: any, mutedUserIds: Set): boolean { - if (mutedUserIds.has(note.userId)) { - return true; - } - - if (note.reply != null && mutedUserIds.has(note.reply.userId)) { - return true; - } - - if (note.renote != null && mutedUserIds.has(note.renote.userId)) { - return true; - } - - return false; -} diff --git a/packages/backend/src/misc/is-user-related.ts b/packages/backend/src/misc/is-user-related.ts new file mode 100644 index 0000000000..e6bbdb5d35 --- /dev/null +++ b/packages/backend/src/misc/is-user-related.ts @@ -0,0 +1,15 @@ +export function isUserRelated(note: any, userIds: Set): boolean { + if (userIds.has(note.userId)) { + return true; + } + + if (note.reply != null && userIds.has(note.reply.userId)) { + return true; + } + + if (note.renote != null && userIds.has(note.renote.userId)) { + return true; + } + + return false; +} diff --git a/packages/backend/src/models/repositories/instance.ts b/packages/backend/src/models/repositories/instance.ts index 4ddf717098..5f0fd8d582 100644 --- a/packages/backend/src/models/repositories/instance.ts +++ b/packages/backend/src/models/repositories/instance.ts @@ -1,11 +1,13 @@ import { db } from '@/db/postgre.js'; import { Instance } from '@/models/entities/instance.js'; import { Packed } from '@/misc/schema.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; export const InstanceRepository = db.getRepository(Instance).extend({ async pack( instance: Instance, ): Promise> { + const meta = await fetchMeta(); return { id: instance.id, caughtAt: instance.caughtAt.toISOString(), @@ -18,6 +20,7 @@ export const InstanceRepository = db.getRepository(Instance).extend({ lastCommunicatedAt: instance.lastCommunicatedAt.toISOString(), isNotResponding: instance.isNotResponding, isSuspended: instance.isSuspended, + isBlocked: meta.blockedHosts.includes(instance.host), softwareName: instance.softwareName, softwareVersion: instance.softwareVersion, openRegistrations: instance.openRegistrations, @@ -26,6 +29,8 @@ export const InstanceRepository = db.getRepository(Instance).extend({ maintainerName: instance.maintainerName, maintainerEmail: instance.maintainerEmail, iconUrl: instance.iconUrl, + faviconUrl: instance.faviconUrl, + themeColor: instance.themeColor, infoUpdatedAt: instance.infoUpdatedAt ? instance.infoUpdatedAt.toISOString() : null, }; }, diff --git a/packages/backend/src/models/schema/federation-instance.ts b/packages/backend/src/models/schema/federation-instance.ts index c4e7b3f18b..93327304f3 100644 --- a/packages/backend/src/models/schema/federation-instance.ts +++ b/packages/backend/src/models/schema/federation-instance.ts @@ -52,6 +52,10 @@ export const packedFederationInstanceSchema = { type: 'boolean', optional: false, nullable: false, }, + isBlocked: { + type: 'boolean', + optional: false, nullable: false, + }, softwareName: { type: 'string', optional: false, nullable: true, @@ -88,6 +92,15 @@ export const packedFederationInstanceSchema = { optional: false, nullable: true, format: 'url', }, + faviconUrl: { + type: 'string', + optional: false, nullable: true, + format: 'url', + }, + themeColor: { + type: 'string', + optional: false, nullable: true, + }, infoUpdatedAt: { type: 'string', optional: false, nullable: true, diff --git a/packages/backend/src/queue/initialize.ts b/packages/backend/src/queue/initialize.ts index 1db118ca9f..eef4080af3 100644 --- a/packages/backend/src/queue/initialize.ts +++ b/packages/backend/src/queue/initialize.ts @@ -6,6 +6,7 @@ export function initialize(name: string, limitPerSec = -1) { redis: { port: config.redis.port, host: config.redis.host, + family: config.redis.family == null ? 0 : config.redis.family, password: config.redis.pass, db: config.redis.db || 0, }, diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts index 5d00481b75..de7eb0ed83 100644 --- a/packages/backend/src/remote/activitypub/type.ts +++ b/packages/backend/src/remote/activitypub/type.ts @@ -201,7 +201,7 @@ export interface IApMention extends IObject { href: string; } -export const isMention = (object: IObject): object is IApMention=> +export const isMention = (object: IObject): object is IApMention => getApType(object) === 'Mention' && typeof object.href === 'string'; diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts index f97c3dd397..c22c868c80 100644 --- a/packages/backend/src/server/api/api-handler.ts +++ b/packages/backend/src/server/api/api-handler.ts @@ -6,7 +6,11 @@ import call from './call.js'; import { ApiError } from './error.js'; export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { - const body = ctx.request.body; + const body = ctx.is('multipart/form-data') + ? (ctx.request as any).body + : ctx.method === 'GET' + ? ctx.query + : ctx.request.body; const reply = (x?: any, y?: ApiError) => { if (x == null) { @@ -33,6 +37,9 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res authenticate(body['i']).then(([user, app]) => { // API invoking call(endpoint.name, user, app, body, ctx).then((res: any) => { + if (ctx.method === 'GET' && endpoint.meta.cacheSec && !body['i'] && !user) { + ctx.set('Cache-Control', `public, max-age=${endpoint.meta.cacheSec}`); + } reply(res); }).catch((e: ApiError) => { reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts index 46afde4e47..75bbc9f908 100644 --- a/packages/backend/src/server/api/call.ts +++ b/packages/backend/src/server/api/call.ts @@ -94,7 +94,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi } // Cast non JSON input - if (ep.meta.requireFile && ep.params.properties) { + if ((ep.meta.requireFile || ctx?.method === 'GET') && ep.params.properties) { for (const k of Object.keys(ep.params.properties)) { const param = ep.params.properties![k]; if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') { diff --git a/packages/backend/src/server/api/common/generate-muted-instance-query.ts b/packages/backend/src/server/api/common/generate-muted-instance-query.ts deleted file mode 100644 index 72a6fec68f..0000000000 --- a/packages/backend/src/server/api/common/generate-muted-instance-query.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { User } from '@/models/entities/user.js'; -import { id } from '@/models/id.js'; -import { UserProfiles } from '@/models/index.js'; -import { SelectQueryBuilder, Brackets } from 'typeorm'; - -function createMutesQuery(id: string) { - return UserProfiles.createQueryBuilder('user_profile') - .select('user_profile.mutedInstances') - .where('user_profile.userId = :muterId', { muterId: id }); -} - -export function generateMutedInstanceQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { - const mutingQuery = createMutesQuery(me.id); - - q - .andWhere(new Brackets(qb => { qb - .andWhere('note.userHost IS NULL') - .orWhere(`NOT((${ mutingQuery.getQuery() })::jsonb ? note.userHost)`); - })) - .andWhere(new Brackets(qb => { qb - .where(`note.replyUserHost IS NULL`) - .orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.replyUserHost)`); - })) - .andWhere(new Brackets(qb => { qb - .where(`note.renoteUserHost IS NULL`) - .orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.renoteUserHost)`); - })); - q.setParameters(mutingQuery.getParameters()); -} - -export function generateMutedInstanceNotificationQuery(q: SelectQueryBuilder, me: { id: User['id'] }) { - const mutingQuery = createMutesQuery(me.id); - - q.andWhere(new Brackets(qb => { qb - .andWhere('notifier.host IS NULL') - .orWhere(`NOT (( ${mutingQuery.getQuery()} )::jsonb ? notifier.host)`); - })); - - q.setParameters(mutingQuery.getParameters()); -} diff --git a/packages/backend/src/server/api/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts index 79cb3ff894..e276ff2bd5 100644 --- a/packages/backend/src/server/api/common/generate-muted-user-query.ts +++ b/packages/backend/src/server/api/common/generate-muted-user-query.ts @@ -1,6 +1,6 @@ -import { User } from '@/models/entities/user.js'; -import { Mutings } from '@/models/index.js'; import { SelectQueryBuilder, Brackets } from 'typeorm'; +import { User } from '@/models/entities/user.js'; +import { Mutings, UserProfiles } from '@/models/index.js'; export function generateMutedUserQuery(q: SelectQueryBuilder, me: { id: User['id'] }, exclude?: User) { const mutingQuery = Mutings.createQueryBuilder('muting') @@ -11,21 +11,39 @@ export function generateMutedUserQuery(q: SelectQueryBuilder, me: { id: Use mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id }); } + const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + // 投稿の作者をミュートしていない かつ // 投稿の返信先の作者をミュートしていない かつ // 投稿の引用元の作者をミュートしていない q .andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`) .andWhere(new Brackets(qb => { qb - .where(`note.replyUserId IS NULL`) + .where('note.replyUserId IS NULL') .orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`); })) .andWhere(new Brackets(qb => { qb - .where(`note.renoteUserId IS NULL`) + .where('note.renoteUserId IS NULL') .orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`); + })) + // mute instances + .andWhere(new Brackets(qb => { qb + .andWhere('note.userHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); })); q.setParameters(mutingQuery.getParameters()); + q.setParameters(mutingInstanceQuery.getParameters()); } export function generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { id: User['id'] }) { @@ -33,8 +51,26 @@ export function generateMutedUserQueryForUsers(q: SelectQueryBuilder, me: { .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: me.id }); + const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: me.id }); + q - .andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); + .andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`) + // mute instances + .andWhere(new Brackets(qb => { qb + .andWhere('note.userHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.replyUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`); + })) + .andWhere(new Brackets(qb => { qb + .where('note.renoteUserHost IS NULL') + .orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`); + })); q.setParameters(mutingQuery.getParameters()); + q.setParameters(mutingInstanceQuery.getParameters()); } diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts index 1529894341..47dcb44ea8 100644 --- a/packages/backend/src/server/api/define.ts +++ b/packages/backend/src/server/api/define.ts @@ -21,7 +21,6 @@ ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/); export default function (meta: T, paramDef: Ps, cb: executor) : (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => Promise { - const validate = ajv.compile(paramDef); return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => { diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 1e7afd8cdd..1a3fc199dc 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -59,6 +59,7 @@ import * as ep___admin_unsilenceUser from './endpoints/admin/unsilence-user.js'; import * as ep___admin_unsuspendUser from './endpoints/admin/unsuspend-user.js'; import * as ep___admin_updateMeta from './endpoints/admin/update-meta.js'; import * as ep___admin_vacuum from './endpoints/admin/vacuum.js'; +import * as ep___admin_deleteAccount from './endpoints/admin/delete-account.js'; import * as ep___announcements from './endpoints/announcements.js'; import * as ep___antennas_create from './endpoints/antennas/create.js'; import * as ep___antennas_delete from './endpoints/antennas/delete.js'; @@ -99,6 +100,7 @@ import * as ep___charts_user_notes from './endpoints/charts/user/notes.js'; import * as ep___charts_user_reactions from './endpoints/charts/user/reactions.js'; import * as ep___charts_users from './endpoints/charts/users.js'; import * as ep___clips_addNote from './endpoints/clips/add-note.js'; +import * as ep___clips_removeNote from './endpoints/clips/remove-note.js'; import * as ep___clips_create from './endpoints/clips/create.js'; import * as ep___clips_delete from './endpoints/clips/delete.js'; import * as ep___clips_list from './endpoints/clips/list.js'; @@ -133,6 +135,7 @@ import * as ep___federation_instances from './endpoints/federation/instances.js' import * as ep___federation_showInstance from './endpoints/federation/show-instance.js'; import * as ep___federation_updateRemoteUser from './endpoints/federation/update-remote-user.js'; import * as ep___federation_users from './endpoints/federation/users.js'; +import * as ep___federation_stats from './endpoints/federation/stats.js'; import * as ep___following_create from './endpoints/following/create.js'; import * as ep___following_delete from './endpoints/following/delete.js'; import * as ep___following_invalidate from './endpoints/following/invalidate.js'; @@ -369,6 +372,7 @@ const eps = [ ['admin/unsuspend-user', ep___admin_unsuspendUser], ['admin/update-meta', ep___admin_updateMeta], ['admin/vacuum', ep___admin_vacuum], + ['admin/delete-account', ep___admin_deleteAccount], ['announcements', ep___announcements], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], @@ -409,6 +413,7 @@ const eps = [ ['charts/user/reactions', ep___charts_user_reactions], ['charts/users', ep___charts_users], ['clips/add-note', ep___clips_addNote], + ['clips/remove-note', ep___clips_removeNote], ['clips/create', ep___clips_create], ['clips/delete', ep___clips_delete], ['clips/list', ep___clips_list], @@ -443,6 +448,7 @@ const eps = [ ['federation/show-instance', ep___federation_showInstance], ['federation/update-remote-user', ep___federation_updateRemoteUser], ['federation/users', ep___federation_users], + ['federation/stats', ep___federation_stats], ['following/create', ep___following_create], ['following/delete', ep___following_delete], ['following/invalidate', ep___following_invalidate], @@ -699,6 +705,16 @@ export interface IEndpointMeta { readonly kind?: string; readonly description?: string; + + /** + * GETでのリクエストを許容するか否か + */ + readonly allowGet?: boolean; + + /** + * 正常応答をキャッシュ (Cache-Control: public) する秒数 + */ + readonly cacheSec?: number; } export interface IEndpoint { diff --git a/packages/backend/src/server/api/endpoints/admin/delete-account.ts b/packages/backend/src/server/api/endpoints/admin/delete-account.ts new file mode 100644 index 0000000000..2d7ef2f236 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/delete-account.ts @@ -0,0 +1,31 @@ +import { Users } from '@/models/index.js'; +import { deleteAccount } from '@/services/delete-account.js'; +import define from '../../define.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireAdmin: true, + + res: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const user = await Users.findOneByOrFail({ id: ps.userId }); + if (user.isDeleted) { + return; + } + + await deleteAccount(user); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/drive/files.ts b/packages/backend/src/server/api/endpoints/admin/drive/files.ts index 119c4db19b..ba32aac431 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/files.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/files.ts @@ -1,5 +1,5 @@ -import define from '../../../define.js'; import { DriveFiles } from '@/models/index.js'; +import define from '../../../define.js'; import { makePaginationQuery } from '../../../common/make-pagination-query.js'; export const meta = { @@ -25,8 +25,9 @@ export const paramDef = { limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, sinceId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' }, + userId: { type: 'string', format: 'misskey:id', nullable: true }, type: { type: 'string', nullable: true, pattern: /^[a-zA-Z0-9\/\-*]+$/.toString().slice(1, -1) }, - origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: "local" }, + origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, hostname: { type: 'string', nullable: true, @@ -41,14 +42,18 @@ export const paramDef = { export default define(meta, paramDef, async (ps, me) => { const query = makePaginationQuery(DriveFiles.createQueryBuilder('file'), ps.sinceId, ps.untilId); - if (ps.origin === 'local') { - query.andWhere('file.userHost IS NULL'); - } else if (ps.origin === 'remote') { - query.andWhere('file.userHost IS NOT NULL'); - } + if (ps.userId) { + query.andWhere('file.userId = :userId', { userId: ps.userId }); + } else { + if (ps.origin === 'local') { + query.andWhere('file.userHost IS NULL'); + } else if (ps.origin === 'remote') { + query.andWhere('file.userHost IS NOT NULL'); + } - if (ps.hostname) { - query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + if (ps.hostname) { + query.andWhere('file.userHost = :hostname', { hostname: ps.hostname }); + } } if (ps.type) { diff --git a/packages/backend/src/server/api/endpoints/admin/server-info.ts b/packages/backend/src/server/api/endpoints/admin/server-info.ts index 9c150420b1..85c6fb82e7 100644 --- a/packages/backend/src/server/api/endpoints/admin/server-info.ts +++ b/packages/backend/src/server/api/endpoints/admin/server-info.ts @@ -99,12 +99,16 @@ export default define(meta, paramDef, async () => { const fsStats = await si.fsSize(); const netInterface = await si.networkInterfaceDefault(); + const redisServerInfo = await redisClient.info('Server'); + const m = redisServerInfo.match(new RegExp('^redis_version:(.*)', 'm')); + const redis_version = m?.[1]; + return { machine: os.hostname(), os: os.platform(), node: process.version, psql: await db.query('SHOW server_version').then(x => x[0].server_version), - redis: redisClient.server_info.redis_version, + redis: redis_version, cpu: { model: os.cpus()[0].model, cores: os.cpus().length, diff --git a/packages/backend/src/server/api/endpoints/ap/show.ts b/packages/backend/src/server/api/endpoints/ap/show.ts index 3c0c0642e3..6442a1412c 100644 --- a/packages/backend/src/server/api/endpoints/ap/show.ts +++ b/packages/backend/src/server/api/endpoints/ap/show.ts @@ -2,12 +2,13 @@ import define from '../../define.js'; import config from '@/config/index.js'; import { createPerson } from '@/remote/activitypub/models/person.js'; import { createNote } from '@/remote/activitypub/models/note.js'; +import DbResolver from '@/remote/activitypub/db-resolver.js'; import Resolver from '@/remote/activitypub/resolver.js'; import { ApiError } from '../../error.js'; import { extractDbHost } from '@/misc/convert-host.js'; import { Users, Notes } from '@/models/index.js'; import { Note } from '@/models/entities/note.js'; -import { User } from '@/models/entities/user.js'; +import { CacheableLocalUser, User } from '@/models/entities/user.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { isActor, isPost, getApId } from '@/remote/activitypub/type.js'; import ms from 'ms'; @@ -77,8 +78,8 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps) => { - const object = await fetchAny(ps.uri); +export default define(meta, paramDef, async (ps, me) => { + const object = await fetchAny(ps.uri, me); if (object) { return object; } else { @@ -89,48 +90,18 @@ export default define(meta, paramDef, async (ps) => { /*** * URIからUserかNoteを解決する */ -async function fetchAny(uri: string): Promise | null> { - // URIがこのサーバーを指しているなら、ローカルユーザーIDとしてDBからフェッチ - if (uri.startsWith(config.url + '/')) { - const parts = uri.split('/'); - const id = parts.pop(); - const type = parts.pop(); - - if (type === 'notes') { - const note = await Notes.findOneBy({ id }); - - if (note) { - return { - type: 'Note', - object: await Notes.pack(note, null, { detail: true }), - }; - } - } else if (type === 'users') { - const user = await Users.findOneBy({ id }); - - if (user) { - return { - type: 'User', - object: await Users.pack(user, null, { detail: true }), - }; - } - } - } - +async function fetchAny(uri: string, me: CacheableLocalUser | null | undefined): Promise | null> { // ブロックしてたら中断 const fetchedMeta = await fetchMeta(); if (fetchedMeta.blockedHosts.includes(extractDbHost(uri))) return null; - // URI(AP Object id)としてDB検索 - { - const [user, note] = await Promise.all([ - Users.findOneBy({ uri: uri }), - Notes.findOneBy({ uri: uri }), - ]); + const dbResolver = new DbResolver(); - const packed = await mergePack(user, note); - if (packed !== null) return packed; - } + let local = await mergePack(me, ...await Promise.all([ + dbResolver.getUserFromApId(uri), + dbResolver.getNoteFromApId(uri), + ])); + if (local != null) return local; // リモートから一旦オブジェクトフェッチ const resolver = new Resolver(); @@ -139,74 +110,37 @@ async function fetchAny(uri: string): Promise | n // /@user のような正規id以外で取得できるURIが指定されていた場合、ここで初めて正規URIが確定する // これはDBに存在する可能性があるため再度DB検索 if (uri !== object.id) { - if (object.id.startsWith(config.url + '/')) { - const parts = object.id.split('/'); - const id = parts.pop(); - const type = parts.pop(); - - if (type === 'notes') { - const note = await Notes.findOneBy({ id }); - - if (note) { - return { - type: 'Note', - object: await Notes.pack(note, null, { detail: true }), - }; - } - } else if (type === 'users') { - const user = await Users.findOneBy({ id }); - - if (user) { - return { - type: 'User', - object: await Users.pack(user, null, { detail: true }), - }; - } - } - } - - const [user, note] = await Promise.all([ - Users.findOneBy({ uri: object.id }), - Notes.findOneBy({ uri: object.id }), - ]); - - const packed = await mergePack(user, note); - if (packed !== null) return packed; + local = await mergePack(me, ...await Promise.all([ + dbResolver.getUserFromApId(object.id), + dbResolver.getNoteFromApId(object.id), + ])); + if (local != null) return local; } - // それでもみつからなければ新規であるため登録 - if (isActor(object)) { - const user = await createPerson(getApId(object)); - return { - type: 'User', - object: await Users.pack(user, null, { detail: true }), - }; - } - - if (isPost(object)) { - const note = await createNote(getApId(object), undefined, true); - return { - type: 'Note', - object: await Notes.pack(note!, null, { detail: true }), - }; - } - - return null; + return await mergePack( + me, + isActor(object) ? await createPerson(getApId(object)) : null, + isPost(object) ? await createNote(getApId(object), undefined, true) : null, + ); } -async function mergePack(user: User | null | undefined, note: Note | null | undefined): Promise | null> { +async function mergePack(me: CacheableLocalUser | null | undefined, user: User | null | undefined, note: Note | null | undefined): Promise | null> { if (user != null) { return { type: 'User', - object: await Users.pack(user, null, { detail: true }), + object: await Users.pack(user, me, { detail: true }), }; - } + } else if (note != null) { + try { + const object = await Notes.pack(note, me, { detail: true }); - if (note != null) { - return { - type: 'Note', - object: await Notes.pack(note, null, { detail: true }), - }; + return { + type: 'Note', + object, + }; + } catch (e) { + return null; + } } return null; diff --git a/packages/backend/src/server/api/endpoints/charts/active-users.ts b/packages/backend/src/server/api/endpoints/charts/active-users.ts index 97f7885dbe..ea23794296 100644 --- a/packages/backend/src/server/api/endpoints/charts/active-users.ts +++ b/packages/backend/src/server/api/endpoints/charts/active-users.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { activeUsersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'users'], res: getJsonSchema(activeUsersChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/ap-request.ts b/packages/backend/src/server/api/endpoints/charts/ap-request.ts index 4477bfc987..06dee250ee 100644 --- a/packages/backend/src/server/api/endpoints/charts/ap-request.ts +++ b/packages/backend/src/server/api/endpoints/charts/ap-request.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { apRequestChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts'], res: getJsonSchema(apRequestChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/drive.ts b/packages/backend/src/server/api/endpoints/charts/drive.ts index fd6033392f..dd2c2d6838 100644 --- a/packages/backend/src/server/api/endpoints/charts/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/drive.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { driveChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'drive'], res: getJsonSchema(driveChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/federation.ts b/packages/backend/src/server/api/endpoints/charts/federation.ts index f842f574ec..8c35b3c46d 100644 --- a/packages/backend/src/server/api/endpoints/charts/federation.ts +++ b/packages/backend/src/server/api/endpoints/charts/federation.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { federationChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts'], res: getJsonSchema(federationChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/hashtag.ts b/packages/backend/src/server/api/endpoints/charts/hashtag.ts index 01407defdd..77e24a62c3 100644 --- a/packages/backend/src/server/api/endpoints/charts/hashtag.ts +++ b/packages/backend/src/server/api/endpoints/charts/hashtag.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { hashtagChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'hashtags'], res: getJsonSchema(hashtagChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/instance.ts b/packages/backend/src/server/api/endpoints/charts/instance.ts index 2d12951c6c..817d51ad01 100644 --- a/packages/backend/src/server/api/endpoints/charts/instance.ts +++ b/packages/backend/src/server/api/endpoints/charts/instance.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { instanceChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts'], res: getJsonSchema(instanceChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/notes.ts b/packages/backend/src/server/api/endpoints/charts/notes.ts index b6089f67ef..951adf5408 100644 --- a/packages/backend/src/server/api/endpoints/charts/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/notes.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { notesChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'notes'], res: getJsonSchema(notesChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/user/drive.ts b/packages/backend/src/server/api/endpoints/charts/user/drive.ts index e5db7131a8..f165b40224 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/drive.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/drive.ts @@ -1,11 +1,14 @@ -import define from '../../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { perUserDriveChart } from '@/services/chart/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['charts', 'drive', 'users'], res: getJsonSchema(perUserDriveChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/user/following.ts b/packages/backend/src/server/api/endpoints/charts/user/following.ts index 9b72de745d..f5d42e21c2 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/following.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/following.ts @@ -6,6 +6,9 @@ export const meta = { tags: ['charts', 'users', 'following'], res: getJsonSchema(perUserFollowingChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/user/notes.ts b/packages/backend/src/server/api/endpoints/charts/user/notes.ts index 7cc6cbf316..aefe550d43 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/notes.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/notes.ts @@ -1,11 +1,14 @@ -import define from '../../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { perUserNotesChart } from '@/services/chart/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['charts', 'users', 'notes'], res: getJsonSchema(perUserNotesChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts index 5c58a7f152..6bc6b56bf0 100644 --- a/packages/backend/src/server/api/endpoints/charts/user/reactions.ts +++ b/packages/backend/src/server/api/endpoints/charts/user/reactions.ts @@ -1,11 +1,14 @@ -import define from '../../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { perUserReactionsChart } from '@/services/chart/index.js'; +import define from '../../../define.js'; export const meta = { tags: ['charts', 'users', 'reactions'], res: getJsonSchema(perUserReactionsChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/charts/users.ts b/packages/backend/src/server/api/endpoints/charts/users.ts index 49c762b2e4..338e8fd338 100644 --- a/packages/backend/src/server/api/endpoints/charts/users.ts +++ b/packages/backend/src/server/api/endpoints/charts/users.ts @@ -1,11 +1,14 @@ -import define from '../../define.js'; import { getJsonSchema } from '@/services/chart/core.js'; import { usersChart } from '@/services/chart/index.js'; +import define from '../../define.js'; export const meta = { tags: ['charts', 'users'], res: getJsonSchema(usersChart.schema), + + allowGet: true, + cacheSec: 60 * 60, } as const; export const paramDef = { diff --git a/packages/backend/src/server/api/endpoints/clips/remove-note.ts b/packages/backend/src/server/api/endpoints/clips/remove-note.ts new file mode 100644 index 0000000000..8b90e31f65 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/clips/remove-note.ts @@ -0,0 +1,57 @@ +import define from '../../define.js'; +import { ClipNotes, Clips } from '@/models/index.js'; +import { ApiError } from '../../error.js'; +import { getNote } from '../../common/getters.js'; + +export const meta = { + tags: ['account', 'notes', 'clips'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + noSuchClip: { + message: 'No such clip.', + code: 'NO_SUCH_CLIP', + id: 'b80525c6-97f7-49d7-a42d-ebccd49cfd52', + }, + + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'aff017de-190e-434b-893e-33a9ff5049d8', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + clipId: { type: 'string', format: 'misskey:id' }, + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['clipId', 'noteId'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const clip = await Clips.findOneBy({ + id: ps.clipId, + userId: user.id, + }); + + if (clip == null) { + throw new ApiError(meta.errors.noSuchClip); + } + + const note = await getNote(ps.noteId).catch(e => { + if (e.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); + throw e; + }); + + await ClipNotes.delete({ + noteId: note.id, + clipId: clip.id, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts new file mode 100644 index 0000000000..d3c2659088 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -0,0 +1,64 @@ +import { IsNull, MoreThan, Not } from 'typeorm'; +import { Followings, Instances } from '@/models/index.js'; +import { awaitAll } from '@/prelude/await-all.js'; +import define from '../../define.js'; + +export const meta = { + tags: ['federation'], + + requireCredential: false, + + allowGet: true, + cacheSec: 60 * 60, +} as const; + +export const paramDef = { + type: 'object', + properties: { + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const [topSubInstances, topPubInstances, allSubCount, allPubCount] = await Promise.all([ + Instances.find({ + where: { + followersCount: MoreThan(0), + }, + order: { + followersCount: 'DESC', + }, + take: 10, + }), + Instances.find({ + where: { + followingCount: MoreThan(0), + }, + order: { + followingCount: 'DESC', + }, + take: 10, + }), + Followings.count({ + where: { + followeeHost: Not(IsNull()), + }, + }), + Followings.count({ + where: { + followerHost: Not(IsNull()), + }, + }), + ]); + + const gotSubCount = topSubInstances.map(x => x.followersCount).reduce((a, b) => a + b, 0); + const gotPubCount = topSubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); + + return await awaitAll({ + topSubInstances: Instances.packMany(topSubInstances), + otherFollowersCount: Math.max(0, allSubCount - gotSubCount), + topPubInstances: Instances.packMany(topPubInstances), + otherFollowingCount: Math.max(0, allPubCount - gotPubCount), + }); +}); diff --git a/packages/backend/src/server/api/endpoints/i/delete-account.ts b/packages/backend/src/server/api/endpoints/i/delete-account.ts index 184005eb53..ede4a9d03b 100644 --- a/packages/backend/src/server/api/endpoints/i/delete-account.ts +++ b/packages/backend/src/server/api/endpoints/i/delete-account.ts @@ -1,9 +1,7 @@ import bcrypt from 'bcryptjs'; -import define from '../../define.js'; import { UserProfiles, Users } from '@/models/index.js'; -import { doPostSuspend } from '@/services/suspend-user.js'; -import { publishUserEvent } from '@/services/stream.js'; -import { createDeleteAccountJob } from '@/queue/index.js'; +import { deleteAccount } from '@/services/delete-account.js'; +import define from '../../define.js'; export const meta = { requireCredential: true, @@ -34,17 +32,5 @@ export default define(meta, paramDef, async (ps, user) => { throw new Error('incorrect password'); } - // 物理削除する前にDelete activityを送信する - await doPostSuspend(user).catch(e => {}); - - createDeleteAccountJob(user, { - soft: false, - }); - - await Users.update(user.id, { - isDeleted: true, - }); - - // Terminate streaming - publishUserEvent(user.id, 'terminate', {}); + await deleteAccount(user); }); diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 1c31ce7a68..a2249803ee 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -1,11 +1,10 @@ import { Brackets } from 'typeorm'; -import { Notifications, Followings, Mutings, Users } from '@/models/index.js'; +import { Notifications, Followings, Mutings, Users, UserProfiles } from '@/models/index.js'; import { notificationTypes } from '@/types.js'; import read from '@/services/note/read.js'; import { readNotification } from '../../common/read-notification.js'; import define from '../../define.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js'; -import { generateMutedInstanceNotificationQuery } from '../../common/generate-muted-instance-query.js'; export const meta = { tags: ['account', 'notifications'], @@ -67,6 +66,10 @@ export default define(meta, paramDef, async (ps, user) => { .select('muting.muteeId') .where('muting.muterId = :muterId', { muterId: user.id }); + const mutingInstanceQuery = UserProfiles.createQueryBuilder('user_profile') + .select('user_profile.mutedInstances') + .where('user_profile.userId = :muterId', { muterId: user.id }); + const suspendedQuery = Users.createQueryBuilder('users') .select('users.id') .where('users.isSuspended = TRUE'); @@ -89,14 +92,21 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar') .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); + // muted users query.andWhere(new Brackets(qb => { qb .where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`) .orWhere('notification.notifierId IS NULL'); })); query.setParameters(mutingQuery.getParameters()); - generateMutedInstanceNotificationQuery(query, user); + // muted instances + query.andWhere(new Brackets(qb => { qb + .andWhere('notifier.host IS NULL') + .orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`); + })); + query.setParameters(mutingInstanceQuery.getParameters()); + // suspended users query.andWhere(new Brackets(qb => { qb .where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`) .orWhere('notification.notifierId IS NULL'); diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index 50ba293a58..efc109105c 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -5,7 +5,6 @@ import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; -import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js'; export const meta = { tags: ['notes'], @@ -61,9 +60,10 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); generateVisibilityQuery(query, user); - if (user) generateMutedUserQuery(query, user); - if (user) generateBlockedUserQuery(query, user); - if (user) generateMutedInstanceQuery(query, user); + if (user) { + generateMutedUserQuery(query, user); + generateBlockedUserQuery(query, user); + } const notes = await query.take(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index 418fc62c31..925318f544 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -1,11 +1,10 @@ import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Notes, Users } from '@/models/index.js'; +import { Notes } from '@/models/index.js'; import { activeUsersChart } from '@/services/chart/index.js'; import define from '../../define.js'; import { ApiError } from '../../error.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; @@ -76,10 +75,11 @@ export default define(meta, paramDef, async (ps, user) => { .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); generateRepliesQuery(query, user); - if (user) generateMutedUserQuery(query, user); - if (user) generateMutedNoteQuery(query, user); - if (user) generateBlockedUserQuery(query, user); - if (user) generateMutedInstanceQuery(query, user); + if (user) { + generateMutedUserQuery(query, user); + generateMutedNoteQuery(query, user); + generateBlockedUserQuery(query, user); + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 52ee817997..2dc98c4c9f 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -1,13 +1,12 @@ import { Brackets } from 'typeorm'; import { fetchMeta } from '@/misc/fetch-meta.js'; -import { Followings, Notes, Users } from '@/models/index.js'; +import { Followings, Notes } from '@/models/index.js'; import { activeUsersChart } from '@/services/chart/index.js'; import define from '../../define.js'; import { ApiError } from '../../error.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js'; @@ -92,7 +91,6 @@ export default define(meta, paramDef, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); generateMutedUserQuery(query, user); - generateMutedInstanceQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); @@ -134,9 +132,7 @@ export default define(meta, paramDef, async (ps, user) => { const timeline = await query.take(ps.limit).getMany(); process.nextTick(() => { - if (user) { - activeUsersChart.read(user); - } + activeUsersChart.read(user); }); return await Notes.packMany(timeline, user); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index d80940e950..22f4925175 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,6 @@ import define from '../../define.js'; import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; -import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js'; import { generateRepliesQuery } from '../../common/generate-replies-query.js'; import { generateMutedNoteQuery } from '../../common/generate-muted-note-query.js'; import { generateChannelQuery } from '../../common/generate-channel-query.js'; @@ -84,7 +83,6 @@ export default define(meta, paramDef, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); generateMutedUserQuery(query, user); - generateMutedInstanceQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); @@ -126,9 +124,7 @@ export default define(meta, paramDef, async (ps, user) => { const timeline = await query.take(ps.limit).getMany(); process.nextTick(() => { - if (user) { - activeUsersChart.read(user); - } + activeUsersChart.read(user); }); return await Notes.packMany(timeline, user); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index aec5c0ea99..9fa56fe83a 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -7,7 +7,6 @@ import { makePaginationQuery } from '../../common/make-pagination-query.js'; import { generateVisibilityQuery } from '../../common/generate-visibility-query.js'; import { generateMutedUserQuery } from '../../common/generate-muted-user-query.js'; import { generateBlockedUserQuery } from '../../common/generate-block-query.js'; -import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query.js'; export const meta = { tags: ['users', 'notes'], @@ -77,9 +76,10 @@ export default define(meta, paramDef, async (ps, me) => { .leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner'); generateVisibilityQuery(query, me); - if (me) generateMutedUserQuery(query, me, user); - if (me) generateBlockedUserQuery(query, me); - if (me) generateMutedInstanceQuery(query, me); + if (me) { + generateMutedUserQuery(query, me, user); + generateBlockedUserQuery(query, me); + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/index.ts b/packages/backend/src/server/api/index.ts index 02bec31b17..83ece51f51 100644 --- a/packages/backend/src/server/api/index.ts +++ b/packages/backend/src/server/api/index.ts @@ -8,6 +8,8 @@ import multer from '@koa/multer'; import bodyParser from 'koa-bodyparser'; import cors from '@koa/cors'; +import { Instances, AccessTokens, Users } from '@/models/index.js'; +import config from '@/config/index.js'; import endpoints from './endpoints.js'; import handler from './api-handler.js'; import signup from './private/signup.js'; @@ -16,8 +18,6 @@ import signupPending from './private/signup-pending.js'; import discord from './service/discord.js'; import github from './service/github.js'; import twitter from './service/twitter.js'; -import { Instances, AccessTokens, Users } from '@/models/index.js'; -import config from '@/config/index.js'; // Init app const app = new Koa(); @@ -56,11 +56,24 @@ for (const endpoint of endpoints) { if (endpoint.meta.requireFile) { router.post(`/${endpoint.name}`, upload.single('file'), handler.bind(null, endpoint)); } else { + // 後方互換性のため if (endpoint.name.includes('-')) { - // 後方互換性のため router.post(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name.replace(/-/g, '_')}`, handler.bind(null, endpoint)); + } else { + router.get(`/${endpoint.name.replace(/-/g, '_')}`, async ctx => { ctx.status = 405; }); + } } + router.post(`/${endpoint.name}`, handler.bind(null, endpoint)); + + if (endpoint.meta.allowGet) { + router.get(`/${endpoint.name}`, handler.bind(null, endpoint)); + } else { + router.get(`/${endpoint.name}`, async ctx => { ctx.status = 405; }); + } } } diff --git a/packages/backend/src/server/api/limiter.ts b/packages/backend/src/server/api/limiter.ts index 6ca3ebf18f..9a7751716e 100644 --- a/packages/backend/src/server/api/limiter.ts +++ b/packages/backend/src/server/api/limiter.ts @@ -7,6 +7,8 @@ import { IEndpointMeta } from './endpoints.js'; const logger = new Logger('limiter'); export const limiter = (limitation: IEndpointMeta['limit'] & { key: NonNullable }, actor: string) => new Promise((ok, reject) => { + if (process.env.NODE_ENV === 'test') ok(); + const hasShortTermLimit = typeof limitation.minInterval === 'number'; const hasLongTermLimit = diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index afd14946e1..d28320d928 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -1,7 +1,6 @@ import Channel from '../channel.js'; import { Notes } from '@/models/index.js'; -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { StreamMessages } from '../types.js'; export default class extends Channel { @@ -27,9 +26,9 @@ export default class extends Channel { const note = await Notes.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 16ad809395..5148cfd055 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,7 +1,6 @@ import Channel from '../channel.js'; import { Notes, Users } from '@/models/index.js'; -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { User } from '@/models/entities/user.js'; import { StreamMessages } from '../types.js'; import { Packed } from '@/misc/schema.js'; @@ -45,9 +44,9 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 1c7e038ab2..5b4ae850ec 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -1,10 +1,9 @@ -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import Channel from '../channel.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { Packed } from '@/misc/schema.js'; export default class extends Channel { @@ -55,9 +54,9 @@ export default class extends Channel { if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return; // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 1b7a58022f..741db447e6 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -1,8 +1,7 @@ -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import Channel from '../channel.js'; import { Notes } from '@/models/index.js'; import { normalizeForSearch } from '@/misc/normalize-for-search.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { Packed } from '@/misc/schema.js'; export default class extends Channel { @@ -38,9 +37,9 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index 3a8e55202a..075a242ef0 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -1,8 +1,7 @@ -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import Channel from '../channel.js'; import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { Packed } from '@/misc/schema.js'; @@ -63,9 +62,9 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index f3ceeffa1a..f5dedf77ce 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -1,9 +1,8 @@ -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import Channel from '../channel.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { Packed } from '@/misc/schema.js'; @@ -71,9 +70,9 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 4e198482a0..8bb9279878 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -1,9 +1,8 @@ -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import Channel from '../channel.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { Notes } from '@/models/index.js'; import { checkWordMute } from '@/misc/check-word-mute.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { Packed } from '@/misc/schema.js'; export default class extends Channel { @@ -52,9 +51,9 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (iUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index d8034e83fe..97ad2983c5 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -1,8 +1,7 @@ import Channel from '../channel.js'; import { Notes, UserListJoinings, UserLists } from '@/models/index.js'; -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; import { User } from '@/models/entities/user.js'; -import { isBlockerUserRelated } from '@/misc/is-blocker-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { Packed } from '@/misc/schema.js'; export default class extends Channel { @@ -76,9 +75,9 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (isMutedUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する - if (isBlockerUserRelated(note, this.blocking)) return; + if (isUserRelated(note, this.blocking)) return; this.send('note', note); } diff --git a/packages/backend/src/server/proxy/proxy-media.ts b/packages/backend/src/server/proxy/proxy-media.ts index 48887bf12f..ca036e8fdf 100644 --- a/packages/backend/src/server/proxy/proxy-media.ts +++ b/packages/backend/src/server/proxy/proxy-media.ts @@ -1,13 +1,16 @@ import * as fs from 'node:fs'; import Koa from 'koa'; -import { serverLogger } from '../index.js'; +import sharp from 'sharp'; import { IImage, convertToWebp } from '@/services/drive/image-processor.js'; import { createTemp } from '@/misc/create-temp.js'; import { downloadUrl } from '@/misc/download-url.js'; import { detectType } from '@/misc/get-file-info.js'; import { StatusError } from '@/misc/fetch.js'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; +import { serverLogger } from '../index.js'; +import { isMimeImage } from '@/misc/is-mime-image.js'; +// eslint-disable-next-line @typescript-eslint/explicit-function-return-type export async function proxyMedia(ctx: Koa.Context) { const url = 'url' in ctx.query ? ctx.query.url : 'https://' + ctx.params.url; @@ -23,14 +26,50 @@ export async function proxyMedia(ctx: Koa.Context) { await downloadUrl(url, path); const { mime, ext } = await detectType(path); + const isConvertibleImage = isMimeImage(mime, 'sharp-convertible-image'); let image: IImage; - if ('static' in ctx.query && ['image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/webp', 'image/svg+xml'].includes(mime)) { + if ('static' in ctx.query && isConvertibleImage) { image = await convertToWebp(path, 498, 280); - } else if ('preview' in ctx.query && ['image/jpeg', 'image/png', 'image/gif', 'image/apng', 'image/vnd.mozilla.apng', 'image/svg+xml'].includes(mime)) { + } else if ('preview' in ctx.query && isConvertibleImage) { image = await convertToWebp(path, 200, 200); - } else if (['image/svg+xml'].includes(mime)) { + } else if ('badge' in ctx.query) { + if (!isConvertibleImage) { + // 画像でないなら404でお茶を濁す + throw new StatusError('Unexpected mime', 404); + } + + const mask = sharp(path) + .resize(96, 96, { + fit: 'inside', + withoutEnlargement: false, + }) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .toColorspace('b-w'); + + const stats = await mask.clone().stats(); + + if (stats.entropy < 0.1) { + // エントロピーがあまりない場合は404にする + throw new StatusError('Skip to provide badge', 404); + } + + const data = sharp({ + create: { width: 96, height: 96, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(await mask.png().toBuffer(), 'eor'); + + image = { + data: await data.png().toBuffer(), + ext: 'png', + type: 'image/png', + }; + } else if (mime === 'image/svg+xml') { image = await convertToWebp(path, 2048, 2048, 1); } else if (!mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(mime)) { throw new StatusError('Rejected type', 403, 'Rejected type'); @@ -48,7 +87,7 @@ export async function proxyMedia(ctx: Koa.Context) { } catch (e) { serverLogger.error(`${e}`); - if (e instanceof StatusError && e.isClientError) { + if (e instanceof StatusError && (e.statusCode === 302 || e.isClientError)) { ctx.status = e.statusCode; } else { ctx.status = 500; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js index 94329e11c9..9fc536555a 100644 --- a/packages/backend/src/server/web/boot.js +++ b/packages/backend/src/server/web/boot.js @@ -14,10 +14,10 @@ // ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので (async () => { window.onerror = (e) => { - renderError('SOMETHING_HAPPENED', e.toString()); + renderError('SOMETHING_HAPPENED', e); }; window.onunhandledrejection = (e) => { - renderError('SOMETHING_HAPPENED_IN_PROMISE', e.toString()); + renderError('SOMETHING_HAPPENED_IN_PROMISE', e); }; const v = localStorage.getItem('v') || VERSION; @@ -57,7 +57,7 @@ import(`/assets/${CLIENT_ENTRY}`) .catch(async e => { await checkUpdate(); - renderError('APP_FETCH_FAILED', JSON.stringify(e)); + renderError('APP_FETCH_FAILED', e); }) //#endregion @@ -104,20 +104,27 @@ // eslint-disable-next-line no-inner-declarations function renderError(code, details) { - document.documentElement.innerHTML = ` -

⚠エラーが発生しました

-

問題が解決しない場合は管理者までお問い合わせください。以下のオプションを試すこともできます:

+ let errorsElement = document.getElementById('errors'); + if (!errorsElement) { + document.documentElement.innerHTML = ` +

⚠ An error has occurred. ⚠

+

If the problem persists, please contact the administrator. You may also try the following options:


- ERROR CODE: ${code} -
- ${details} -
- `; +
+ `; + + errorsElement = document.getElementById('errors'); + } + + const detailsElement = document.createElement('details'); + detailsElement.innerHTML = `ERROR CODE: ${code}${JSON.stringify(details)}`; + + errorsElement.appendChild(detailsElement); } // eslint-disable-next-line no-inner-declarations diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts index 2feee72be7..be95becb68 100644 --- a/packages/backend/src/server/web/index.ts +++ b/packages/backend/src/server/web/index.ts @@ -11,6 +11,7 @@ import Router from '@koa/router'; import send from 'koa-send'; import favicon from 'koa-favicon'; import views from 'koa-views'; +import sharp from 'sharp'; import { createBullBoard } from '@bull-board/api'; import { BullAdapter } from '@bull-board/api/bullAdapter.js'; import { KoaAdapter } from '@bull-board/koa'; @@ -140,6 +141,49 @@ router.get('/twemoji/(.*)', async ctx => { }); }); +router.get('/twemoji-badge/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji-badge/', ''); + + if (!path.match(/^[0-9a-f-]+\.png$/)) { + ctx.status = 404; + return; + } + + const mask = await sharp( + `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/${path.replace('.png', '')}.svg`, + { density: 1000 }, + ) + .resize(488, 488) + .greyscale() + .normalise() + .linear(1.75, -(128 * 1.75) + 128) // 1.75x contrast + .flatten({ background: '#000' }) + .extend({ + top: 12, + bottom: 12, + left: 12, + right: 12, + background: '#000', + }) + .toColorspace('b-w') + .png() + .toBuffer(); + + const buffer = await sharp({ + create: { width: 512, height: 512, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, + }) + .pipelineColorspace('b-w') + .boolean(mask, 'eor') + .resize(96, 96) + .png() + .toBuffer(); + + ctx.set('Content-Security-Policy', 'default-src \'none\'; style-src \'unsafe-inline\''); + ctx.set('Cache-Control', 'max-age=2592000'); + ctx.set('Content-Type', 'image/png'); + ctx.body = buffer; +}); + // ServiceWorker router.get(`/sw.js`, async ctx => { await send(ctx as any, `/sw.js`, { diff --git a/packages/backend/src/services/add-note-to-antenna.ts b/packages/backend/src/services/add-note-to-antenna.ts index f86f394f80..1f344222e1 100644 --- a/packages/backend/src/services/add-note-to-antenna.ts +++ b/packages/backend/src/services/add-note-to-antenna.ts @@ -2,7 +2,7 @@ import { Antenna } from '@/models/entities/antenna.js'; import { Note } from '@/models/entities/note.js'; import { AntennaNotes, Mutings, Notes } from '@/models/index.js'; import { genId } from '@/misc/gen-id.js'; -import { isMutedUserRelated } from '@/misc/is-muted-user-related.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import { publishAntennaStream, publishMainStream } from '@/services/stream.js'; import { User } from '@/models/entities/user.js'; @@ -39,7 +39,7 @@ export async function addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { _note.renote = await Notes.findOneByOrFail({ id: note.renoteId }); } - if (isMutedUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { + if (isUserRelated(_note, new Set(mutings.map(x => x.muteeId)))) { return; } diff --git a/packages/backend/src/services/delete-account.ts b/packages/backend/src/services/delete-account.ts new file mode 100644 index 0000000000..0fdceb671b --- /dev/null +++ b/packages/backend/src/services/delete-account.ts @@ -0,0 +1,23 @@ +import { Users } from '@/models/index.js'; +import { createDeleteAccountJob } from '@/queue/index.js'; +import { publishUserEvent } from './stream.js'; +import { doPostSuspend } from './suspend-user.js'; + +export async function deleteAccount(user: { + id: string; + host: string | null; +}): Promise { + // 物理削除する前にDelete activityを送信する + await doPostSuspend(user).catch(e => {}); + + createDeleteAccountJob(user, { + soft: false, + }); + + await Users.update(user.id, { + isDeleted: true, + }); + + // Terminate streaming + publishUserEvent(user.id, 'terminate', {}); +} diff --git a/packages/backend/src/services/note/polls/update.ts b/packages/backend/src/services/note/polls/update.ts index 43ca3eff4d..68cbb9835a 100644 --- a/packages/backend/src/services/note/polls/update.ts +++ b/packages/backend/src/services/note/polls/update.ts @@ -14,7 +14,6 @@ export async function deliverQuestionUpdate(noteId: Note['id']) { if (user == null) throw new Error('note not found'); if (Users.isLocalUser(user)) { - const content = renderActivity(renderUpdate(await renderNote(note, false), user)); deliverToFollowers(user, content); deliverToRelays(user, content); diff --git a/packages/backend/test/mute.ts b/packages/backend/test/mute.ts index 2be70f2b65..465633973c 100644 --- a/packages/backend/test/mute.ts +++ b/packages/backend/test/mute.ts @@ -2,7 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { async, signup, request, post, react, connectStream, startServer, shutdownServer } from './utils.js'; +import { async, signup, request, post, react, startServer, shutdownServer, waitFire } from './utils.js'; describe('Mute', () => { let p: childProcess.ChildProcess; @@ -55,48 +55,24 @@ describe('Mute', () => { assert.strictEqual(res.body.hasUnreadMentions, false); })); - it('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', () => new Promise(async done => { + it('ミュートしているユーザーからメンションされても、ストリームに unreadMention イベントが流れてこない', async () => { // 状態リセット await request('/i/read-all-unread-notes', {}, alice); - let fired = false; + const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadMention'); - const ws = await connectStream(alice, 'main', ({ type }) => { - if (type == 'unreadMention') { - fired = true; - } - }); + assert.strictEqual(fired, false); + }); - post(carol, { text: '@alice hi' }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 5000); - })); - - it('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', () => new Promise(async done => { + it('ミュートしているユーザーからメンションされても、ストリームに unreadNotification イベントが流れてこない', async () => { // 状態リセット await request('/i/read-all-unread-notes', {}, alice); await request('/notifications/mark-all-as-read', {}, alice); - let fired = false; + const fired = await waitFire(alice, 'main', () => post(carol, { text: '@alice hi' }), msg => msg.type === 'unreadNotification'); - const ws = await connectStream(alice, 'main', ({ type }) => { - if (type == 'unreadNotification') { - fired = true; - } - }); - - post(carol, { text: '@alice hi' }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 5000); - })); + assert.strictEqual(fired, false); + }); describe('Timeline', () => { it('タイムラインにミュートしているユーザーの投稿が含まれない', async(async () => { diff --git a/packages/backend/test/note.ts b/packages/backend/test/note.ts index 1183e9e4f1..b495d8b7bb 100644 --- a/packages/backend/test/note.ts +++ b/packages/backend/test/note.ts @@ -3,7 +3,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; import { Note } from '../src/models/entities/note.js'; -import { async, signup, request, post, uploadFile, startServer, shutdownServer, initTestDb } from './utils.js'; +import { async, signup, request, post, uploadUrl, startServer, shutdownServer, initTestDb, api } from './utils.js'; describe('Note', () => { let p: childProcess.ChildProcess; @@ -37,7 +37,7 @@ describe('Note', () => { })); it('ファイルを添付できる', async(async () => { - const file = await uploadFile(alice); + const file = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const res = await request('/notes/create', { fileIds: [file.id], @@ -49,7 +49,7 @@ describe('Note', () => { })); it('他人のファイルは無視', async(async () => { - const file = await uploadFile(bob); + const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); const res = await request('/notes/create', { text: 'test', @@ -72,11 +72,13 @@ describe('Note', () => { assert.deepStrictEqual(res.body.createdNote.fileIds, []); })); - it('不正なファイルIDで怒られる', async(async () => { + it('不正なファイルIDは無視', async(async () => { const res = await request('/notes/create', { fileIds: ['kyoppie'], }, alice); - assert.strictEqual(res.status, 400); + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.deepStrictEqual(res.body.createdNote.fileIds, []); })); it('返信できる', async(async () => { @@ -136,7 +138,7 @@ describe('Note', () => { it('文字数ぎりぎりで怒られない', async(async () => { const post = { - text: '!'.repeat(500), + text: '!'.repeat(3000), }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 200); @@ -144,7 +146,7 @@ describe('Note', () => { it('文字数オーバーで怒られる', async(async () => { const post = { - text: '!'.repeat(501), + text: '!'.repeat(3001), }; const res = await request('/notes/create', post, alice); assert.strictEqual(res.status, 400); @@ -207,7 +209,7 @@ describe('Note', () => { assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); assert.strictEqual(res.body.createdNote.text, post.text); - const noteDoc = await Notes.findOne(res.body.createdNote.id); + const noteDoc = await Notes.findOneBy({ id: res.body.createdNote.id }); assert.deepStrictEqual(noteDoc.mentions, [bob.id]); })); @@ -336,32 +338,32 @@ describe('Note', () => { describe('notes/delete', () => { it('delete a reply', async(async () => { - const mainNoteRes = await request('/notes/create', { + const mainNoteRes = await api('notes/create', { text: 'main post', }, alice); - const replyOneRes = await request('/notes/create', { + const replyOneRes = await api('notes/create', { text: 'reply one', replyId: mainNoteRes.body.createdNote.id, }, alice); - const replyTwoRes = await request('/notes/create', { + const replyTwoRes = await api('notes/create', { text: 'reply two', replyId: mainNoteRes.body.createdNote.id, }, alice); - const deleteOneRes = await request('/notes/delete', { + const deleteOneRes = await api('notes/delete', { noteId: replyOneRes.body.createdNote.id, }, alice); assert.strictEqual(deleteOneRes.status, 204); - let mainNote = await Notes.findOne({ id: mainNoteRes.body.createdNote.id }); + let mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); assert.strictEqual(mainNote.repliesCount, 1); - const deleteTwoRes = await request('/notes/delete', { + const deleteTwoRes = await api('notes/delete', { noteId: replyTwoRes.body.createdNote.id, }, alice); assert.strictEqual(deleteTwoRes.status, 204); - mainNote = await Notes.findOne({ id: mainNoteRes.body.createdNote.id }); + mainNote = await Notes.findOneBy({ id: mainNoteRes.body.createdNote.id }); assert.strictEqual(mainNote.repliesCount, 0); })); }); diff --git a/packages/backend/test/user-notes.ts b/packages/backend/test/user-notes.ts index 5b7933da67..4447754d66 100644 --- a/packages/backend/test/user-notes.ts +++ b/packages/backend/test/user-notes.ts @@ -2,12 +2,7 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; -import { dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { async, signup, request, post, uploadFile, startServer, shutdownServer } from './utils.js'; - -const _filename = fileURLToPath(import.meta.url); -const _dirname = dirname(_filename); +import { async, signup, request, post, uploadUrl, startServer, shutdownServer } from './utils.js'; describe('users/notes', () => { let p: childProcess.ChildProcess; @@ -20,8 +15,8 @@ describe('users/notes', () => { before(async () => { p = await startServer(); alice = await signup({ username: 'alice' }); - const jpg = await uploadFile(alice, _dirname + '/resources/Lenna.jpg'); - const png = await uploadFile(alice, _dirname + '/resources/Lenna.png'); + const jpg = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.jpg'); + const png = await uploadUrl(alice, 'https://raw.githubusercontent.com/misskey-dev/misskey/develop/packages/backend/test/resources/Lenna.png'); jpgNote = await post(alice, { fileIds: [jpg.id], }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 5eb4ed3b01..0ee15067d1 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -1,16 +1,18 @@ import * as fs from 'node:fs'; +import * as path from 'node:path'; import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; import * as childProcess from 'child_process'; import * as http from 'node:http'; import { SIGKILL } from 'constants'; -import * as WebSocket from 'ws'; +import WebSocket from 'ws'; import * as misskey from 'misskey-js'; import fetch from 'node-fetch'; import FormData from 'form-data'; import { DataSource } from 'typeorm'; import loadConfig from '../src/config/load.js'; import { entities } from '../src/db/postgre.js'; +import got from 'got'; const _filename = fileURLToPath(import.meta.url); const _dirname = dirname(_filename); @@ -26,6 +28,42 @@ export const async = (fn: Function) => (done: Function) => { }); }; +export const api = async (endpoint: string, params: any, me?: any) => { + endpoint = endpoint.replace(/^\//, ''); + + const auth = me ? { + i: me.token + } : {}; + + const res = await got(`http://localhost:${port}/api/${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(Object.assign(auth, params)), + retry: { + limit: 0, + }, + hooks: { + beforeError: [ + error => { + const { response } = error; + if (response && response.body) console.warn(response.body); + return error; + } + ] + }, + }); + + const status = res.statusCode; + const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; + + return { + status, + body + }; +}; + export const request = async (endpoint: string, params: any, me?: any): Promise<{ body: any, status: number }> => { const auth = me ? { i: me.token, @@ -53,7 +91,7 @@ export const signup = async (params?: any): Promise => { password: 'test', }, params); - const res = await request('/signup', q); + const res = await api('signup', q); return res.body; }; @@ -63,34 +101,62 @@ export const post = async (user: any, params?: misskey.Endpoints['notes/create'] text: 'test', }, params); - const res = await request('/notes/create', q, user); + const res = await api('notes/create', q, user); return res.body ? res.body.createdNote : null; }; export const react = async (user: any, note: any, reaction: string): Promise => { - await request('/notes/reactions/create', { + await api('notes/reactions/create', { noteId: note.id, reaction: reaction, }, user); }; -export const uploadFile = (user: any, path?: string): Promise => { - const formData = new FormData(); - formData.append('i', user.token); - formData.append('file', fs.createReadStream(path || _dirname + '/resources/Lenna.png')); +/** + * Upload file + * @param user User + * @param _path Optional, absolute path or relative from ./resources/ + */ +export const uploadFile = async (user: any, _path?: string): Promise => { + const absPath = _path == null ? `${_dirname}/resources/Lenna.jpg` : path.isAbsolute(_path) ? _path : `${_dirname}/resources/${_path}`; - return fetch(`http://localhost:${port}/api/drive/files/create`, { - method: 'post', + const formData = new FormData() as any; + formData.append('i', user.token); + formData.append('file', fs.createReadStream(absPath)); + formData.append('force', 'true'); + + const res = await got(`http://localhost:${port}/api/drive/files/create`, { + method: 'POST', body: formData, - timeout: 30 * 1000, - }).then(res => { - if (!res.ok) { - throw `${res.status} ${res.statusText}`; - } else { - return res.json(); + retry: { + limit: 0, + }, + }); + + const body = res.statusCode !== 204 ? await JSON.parse(res.body) : null; + + return body; +}; + +export const uploadUrl = async (user: any, url: string) => { + let file: any; + + const ws = await connectStream(user, 'main', (msg) => { + if (msg.type === 'driveFileCreated') { + file = msg.body; } }); + + await api('drive/files/upload-from-url', { + url, + force: true, + }, user); + + await sleep(5000); + ws.close(); + + return file; }; export function connectStream(user: any, channel: string, listener: (message: Record) => any, params?: any): Promise { @@ -120,6 +186,40 @@ export function connectStream(user: any, channel: string, listener: (message: Re }); } +export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean) => { + return new Promise(async (res, rej) => { + let timer: NodeJS.Timeout; + + let ws: WebSocket; + try { + ws = await connectStream(user, channel, msg => { + if (cond(msg)) { + ws.close(); + if (timer) clearTimeout(timer); + res(true); + } + }); + } catch (e) { + rej(e); + } + + if (!ws!) return; + + timer = setTimeout(() => { + ws.close(); + res(false); + }, 5000); + + try { + await trgr(); + } catch (e) { + ws.close(); + if (timer) clearTimeout(timer); + rej(e); + } + }) +}; + export const simpleGet = async (path: string, accept = '*/*'): Promise<{ status?: number, type?: string, location?: string }> => { // node-fetchだと3xxを取れない return await new Promise((resolve, reject) => { @@ -176,7 +276,7 @@ export async function initTestDb(justBorrow = false, initEntities?: any[]) { return db; } -export function startServer(timeout = 30 * 1000): Promise { +export function startServer(timeout = 60 * 1000): Promise { return new Promise((res, rej) => { const t = setTimeout(() => { p.kill(SIGKILL); @@ -214,3 +314,11 @@ export function shutdownServer(p: childProcess.ChildProcess, timeout = 20 * 1000 p.kill(); }); } + +export function sleep(msec: number) { + return new Promise(res => { + setTimeout(() => { + res(); + }, msec); + }); +} diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js index 10f0e5a9cb..9022143486 100644 --- a/packages/client/.eslintrc.js +++ b/packages/client/.eslintrc.js @@ -25,7 +25,6 @@ module.exports = { // data の禁止理由: 抽象的すぎるため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため 'id-denylist': ['error', 'window', 'data', 'e'], - 'eqeqeq': ['error', 'always', { 'null': 'ignore' }], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { 'alphabetical': false, diff --git a/packages/client/package.json b/packages/client/package.json index 2f1cfa4d04..fcf80aee47 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -79,7 +79,6 @@ "vite": "2.9.10", "vue": "3.2.37", "vue-prism-editor": "2.0.0-alpha.2", - "vue-router": "4.0.16", "vuedraggable": "4.0.1", "websocket": "1.0.34", "ws": "8.8.0" diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index ce4af61f18..eb2ba0a1e1 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -1,11 +1,11 @@ -import { del, get, set } from '@/scripts/idb-proxy'; import { defineAsyncComponent, reactive } from 'vue'; import * as misskey from 'misskey-js'; +import { showSuspendedDialog } from './scripts/show-suspended-dialog'; +import { i18n } from './i18n'; +import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; -import { showSuspendedDialog } from './scripts/show-suspended-dialog'; -import { i18n } from './i18n'; // TODO: 他のタブと永続化されたstateを同期 @@ -22,13 +22,9 @@ export async function signout() { waiting(); localStorage.removeItem('account'); - //#region Remove account - const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === $i.id), 1); + await removeAccount($i.id); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); - //#endregion + const accounts = await getAccounts(); //#region Remove service worker registration try { @@ -55,7 +51,7 @@ export async function signout() { } catch (err) {} //#endregion - document.cookie = `igi=; path=/`; + document.cookie = 'igi=; path=/'; if (accounts.length > 0) login(accounts[0].token); else unisonReload('/'); @@ -72,14 +68,22 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } +export async function removeAccount(id: Account['id']) { + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === id), 1); + + if (accounts.length > 0) await set('accounts', accounts); + else await del('accounts'); +} + function fetchAccount(token: string): Promise { return new Promise((done, fail) => { // Fetch user fetch(`${apiUrl}/i`, { method: 'POST', body: JSON.stringify({ - i: token - }) + i: token, + }), }) .then(res => res.json()) .then(res => { @@ -216,13 +220,13 @@ export async function openAccountMenu(opts: { type: 'link', icon: 'fas fa-users', text: i18n.ts.manageAccounts, - to: `/settings/accounts`, + to: '/settings/accounts', }]], ev.currentTarget ?? ev.target, { - align: 'left' + align: 'left', }); } else { popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { - align: 'left' + align: 'left', }); } } diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/abuse-report.vue index a947406f88..2b89eef85a 100644 --- a/packages/client/src/components/abuse-report.vue +++ b/packages/client/src/components/abuse-report.vue @@ -1,13 +1,19 @@ - diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue index 1e4a4506f7..ae708026e0 100644 --- a/packages/client/src/components/autocomplete.vue +++ b/packages/client/src/components/autocomplete.vue @@ -35,6 +35,7 @@ diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue index 07cd565c58..b346585cec 100644 --- a/packages/client/src/components/drive-file-thumbnail.vue +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -57,7 +57,7 @@ const isThumbnailAvailable = computed(() => { .zdjebgpv { position: relative; display: flex; - background: #e1e1e1; + background: var(--panel); border-radius: 8px; overflow: clip; diff --git a/packages/client/src/components/file-list-for-admin.vue b/packages/client/src/components/file-list-for-admin.vue new file mode 100644 index 0000000000..489c017a93 --- /dev/null +++ b/packages/client/src/components/file-list-for-admin.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/packages/client/src/components/form/checkbox.vue b/packages/client/src/components/form/checkbox.vue new file mode 100644 index 0000000000..fadb770aee --- /dev/null +++ b/packages/client/src/components/form/checkbox.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue index 1b960657d7..a9d8bd97b8 100644 --- a/packages/client/src/components/form/folder.vue +++ b/packages/client/src/components/form/folder.vue @@ -9,13 +9,13 @@ - +
-
+ diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue index 7165671af3..5065e28892 100644 --- a/packages/client/src/components/form/input.vue +++ b/packages/client/src/components/form/input.vue @@ -3,7 +3,8 @@
- import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@/components/ui/button.vue'; import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/ui/button.vue'; +import { useInterval } from '@/scripts/use-interval'; export default defineComponent({ components: { @@ -44,45 +46,45 @@ export default defineComponent({ props: { modelValue: { - required: true + required: true, }, type: { type: String, - required: false + required: false, }, required: { type: Boolean, - required: false + required: false, }, readonly: { type: Boolean, - required: false + required: false, }, disabled: { type: Boolean, - required: false + required: false, }, pattern: { type: String, - required: false + required: false, }, placeholder: { type: String, - required: false + required: false, }, autofocus: { type: Boolean, required: false, - default: false + default: false, }, autocomplete: { - required: false + required: false, }, spellcheck: { - required: false + required: false, }, step: { - required: false + required: false, }, datalist: { type: Array, @@ -91,17 +93,17 @@ export default defineComponent({ inline: { type: Boolean, required: false, - default: false + default: false, }, debounce: { type: Boolean, required: false, - default: false + default: false, }, manualSave: { type: Boolean, required: false, - default: false + default: false, }, }, @@ -134,7 +136,7 @@ export default defineComponent({ const updated = () => { changed.value = false; - if (type?.value === 'number') { + if (type.value === 'number') { context.emit('update:modelValue', parseFloat(v.value)); } else { context.emit('update:modelValue', v.value); @@ -159,30 +161,29 @@ export default defineComponent({ invalid.value = inputEl.value.validity.badInput; }); + // このコンポーネントが作成された時、非表示状態である場合がある + // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する + useInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } + }, 100, { + immediate: true, + afterMounted: true, + }); + onMounted(() => { nextTick(() => { if (autofocus.value) { focus(); } - - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); - - onUnmounted(() => { - window.clearInterval(clock); - }); }); }); diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index 07f2c23124..221ad029a7 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -1,7 +1,7 @@ - diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue index 3f2af306e5..a1327e1a1c 100644 --- a/packages/client/src/components/signup.vue +++ b/packages/client/src/components/signup.vue @@ -67,12 +67,12 @@ diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue index f52e5a3f9b..b52dbe31c4 100644 --- a/packages/client/src/components/sparkle.vue +++ b/packages/client/src/components/sparkle.vue @@ -33,7 +33,8 @@ --> - { - const ro = new ResizeObserver((entries, observer) => { + ro = new ResizeObserver((entries, observer) => { width.value = el.value?.offsetWidth + 64; height.value = el.value?.offsetHeight + 64; }); ro.observe(el.value); - let stop = false; const add = () => { if (stop) return; const x = (Math.random() * (width.value - 64)); @@ -104,10 +106,11 @@ export default defineComponent({ }, 500 + (Math.random() * 500)); }; add(); - onUnmounted(() => { - ro.disconnect(); - stop = true; - }); + }); + + onUnmounted(() => { + if (ro) ro.disconnect(); + stop = true; }); return { diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index d2b2ccff7a..bf9be971fc 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -1,6 +1,6 @@
+
@@ -9,12 +9,7 @@
-
-
- -
-
-
+
@@ -28,14 +23,12 @@ import MkModal from './modal.vue'; const props = withDefaults(defineProps<{ withOkButton: boolean; okButtonDisabled: boolean; - padding: boolean; width: number; height: number | null; scroll: boolean; }>(), { withOkButton: false, okButtonDisabled: false, - padding: false, width: 400, height: null, scroll: true, @@ -96,6 +89,7 @@ defineExpose({ display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -108,6 +102,9 @@ defineExpose({ $height-narrow: 42px; display: flex; flex-shrink: 0; + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); box-shadow: 0px 1px var(--divider); > button { @@ -143,6 +140,7 @@ defineExpose({ > .body { overflow: auto; + background: var(--panel); } } diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index 2066cf579d..25f937ef10 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -1,25 +1,20 @@