diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ddc12399..b693d984b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,21 +22,29 @@ You should also include the user name that made the change. - Client: Improve control panel @syuilo - Client: Show warning in control panel when there is an unresolved abuse report @syuilo - Client: Add instance-cloud widget @syuilo -- Client: Add rss-marquee widget @syuilo +- Client: Add rss-ticker widget @syuilo - Client: Removing entries from a clip @futchitwo - Client: Poll highlights in explore page @syuilo +- Client: Improve deck UI @syuilo +- Client: Word mute also checks content warnings @Johann150 +- ユーザーにモデレーションメモを残せる機能 @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 - Server: Improve performance - Server: Supports IPv6 on Redis transport. @mei23 IPv4/IPv6 is used by default. You can tune this behavior via `redis.family`. +- Server: Add possibility to log IP addresses of users @syuilo ### 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 +- Client: Prevent access to user pages when not logged in @pixeldesu @Johann150 +- Client: Disable some hotkeys (e.g. for creating a post) for not logged in users @pixeldesu +- Client: Ask users that are not logged in to log in when trying to vote in a poll @Johann150 +- Instance mutes also apply in antennas etc. @Johann150 ## 12.111.1 (2022/06/13) diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 1f52c2c25..496613400 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -381,6 +381,7 @@ administrator: "管理者" token: "トークン" twoStepAuthentication: "二段階認証" moderator: "モデレーター" +moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" securityKey: "セキュリティキー" securityKeyName: "キーの名前" @@ -854,6 +855,7 @@ noEmailServerWarning: "メールサーバーの設定がされていません。 thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。" recommended: "推奨" check: "チェック" +requireAdminForView: "閲覧するには管理者アカウントでログインしている必要があります。" isSystemAccount: "システムにより自動で作成・管理されているアカウントです。" typeToConfirm: "この操作を行うには {x} と入力してください" deleteAccount: "アカウント削除" @@ -861,6 +863,9 @@ document: "ドキュメント" numberOfPageCache: "ページキャッシュ数" numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" logoutConfirm: "ログアウトしますか?" +lastActiveDate: "最終利用日時" +statusbar: "ステータスバー" +pleaseSelect: "選択してください" _emailUnavailable: used: "既に使用されています" @@ -1246,7 +1251,7 @@ _widgets: trends: "トレンド" clock: "時計" rss: "RSSリーダー" - rssMarquee: "RSSリーダー(マーキー)" + rssTicker: "RSSティッカー" activity: "アクティビティ" photos: "フォト" digitalClock: "デジタル時計" @@ -1716,8 +1721,6 @@ _notification: _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" - columnMargin: "カラム間のマージン" - columnHeaderHeight: "カラムのヘッダー幅" addColumn: "カラムを追加" swapLeft: "左に移動" swapRight: "右に移動" @@ -1726,6 +1729,8 @@ _deck: stackLeft: "左に重ねる" popRight: "右に出す" profile: "プロファイル" + introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" + introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" _columns: main: "メイン" diff --git a/package.json b/package.json index aa5a04927..700cd07c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "12.112.0-beta.11", + "version": "12.112.0-beta.16", "codename": "indigo", "repository": { "type": "git", diff --git a/packages/backend/migration/1655918165614-user-ip.js b/packages/backend/migration/1655918165614-user-ip.js new file mode 100644 index 000000000..2294fbaf1 --- /dev/null +++ b/packages/backend/migration/1655918165614-user-ip.js @@ -0,0 +1,17 @@ +export class userIp1655918165614 { + name = 'userIp1655918165614' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "user_ip" ("id" SERIAL NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "userId" character varying(32) NOT NULL, "ip" character varying(128) NOT NULL, CONSTRAINT "PK_2c44ddfbf7c0464d028dcef325e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_7f7f1c66f48e9a8e18a33bc515" ON "user_ip" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_361b500e06721013c124b7b6c5" ON "user_ip" ("userId", "ip") `); + await queryRunner.query(`ALTER TABLE "user_ip" ADD CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_ip" DROP CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150"`); + await queryRunner.query(`DROP INDEX "public"."IDX_361b500e06721013c124b7b6c5"`); + await queryRunner.query(`DROP INDEX "public"."IDX_7f7f1c66f48e9a8e18a33bc515"`); + await queryRunner.query(`DROP TABLE "user_ip"`); + } +} diff --git a/packages/backend/migration/1656122560740-file-ip.js b/packages/backend/migration/1656122560740-file-ip.js new file mode 100644 index 000000000..b59e7a911 --- /dev/null +++ b/packages/backend/migration/1656122560740-file-ip.js @@ -0,0 +1,13 @@ +export class fileIp1656122560740 { + name = 'fileIp1656122560740' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" ADD "requestHeaders" jsonb DEFAULT '{}'`); + await queryRunner.query(`ALTER TABLE "drive_file" ADD "requestIp" character varying(128)`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "requestIp"`); + await queryRunner.query(`ALTER TABLE "drive_file" DROP COLUMN "requestHeaders"`); + } +} diff --git a/packages/backend/migration/1656328812281-ip-2.js b/packages/backend/migration/1656328812281-ip-2.js new file mode 100644 index 000000000..b0ee1ebfc --- /dev/null +++ b/packages/backend/migration/1656328812281-ip-2.js @@ -0,0 +1,13 @@ +export class ip21656328812281 { + name = 'ip21656328812281' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_ip" DROP CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150"`); + await queryRunner.query(`ALTER TABLE "meta" ADD "enableIpLogging" boolean NOT NULL DEFAULT false`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableIpLogging"`); + await queryRunner.query(`ALTER TABLE "user_ip" ADD CONSTRAINT "FK_7f7f1c66f48e9a8e18a33bc5150" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } +} diff --git a/packages/backend/migration/1656772790599-user-moderation-note.js b/packages/backend/migration/1656772790599-user-moderation-note.js new file mode 100644 index 000000000..133bcffe1 --- /dev/null +++ b/packages/backend/migration/1656772790599-user-moderation-note.js @@ -0,0 +1,11 @@ +export class userModerationNote1656772790599 { + name = 'userModerationNote1656772790599' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" ADD "moderationNote" character varying(8192) NOT NULL DEFAULT ''`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "moderationNote"`); + } +} diff --git a/packages/backend/package.json b/packages/backend/package.json index 0bf47888e..ef3f55458 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -92,6 +92,7 @@ "rename": "1.0.4", "require-all": "3.0.0", "rndstr": "1.0.0", + "rss-parser": "3.12.0", "s-age": "1.1.2", "sanitize-html": "2.7.0", "semver": "7.3.7", diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 904bbb8b7..94d55e431 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -68,9 +68,10 @@ import { RegistryItem } from '@/models/entities/registry-item.js'; import { Ad } from '@/models/entities/ad.js'; import { PasswordResetRequest } from '@/models/entities/password-reset-request.js'; import { UserPending } from '@/models/entities/user-pending.js'; +import { Webhook } from '@/models/entities/webhook.js'; +import { UserIp } from '@/models/entities/user-ip.js'; import { entities as charts } from '@/services/chart/entities.js'; -import { Webhook } from '@/models/entities/webhook.js'; import { envOption } from '../env.js'; import { dbLogger } from './logger.js'; import { redisClient } from './redis.js'; @@ -173,6 +174,7 @@ export const entities = [ PasswordResetRequest, UserPending, Webhook, + UserIp, ...charts, ]; diff --git a/packages/backend/src/models/entities/drive-file.ts b/packages/backend/src/models/entities/drive-file.ts index a636d1d51..32387290d 100644 --- a/packages/backend/src/models/entities/drive-file.ts +++ b/packages/backend/src/models/entities/drive-file.ts @@ -1,7 +1,7 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; import { User } from './user.js'; import { DriveFolder } from './drive-folder.js'; -import { id } from '../id.js'; @Entity() @Index(['userId', 'folderId', 'id']) @@ -165,4 +165,15 @@ export class DriveFile { comment: 'Whether the DriveFile is direct link to remote server.', }) public isLink: boolean; + + @Column('jsonb', { + default: {}, + nullable: true, + }) + public requestHeaders: Record | null; + + @Column('varchar', { + length: 128, nullable: true, + }) + public requestIp: string | null; } diff --git a/packages/backend/src/models/entities/meta.ts b/packages/backend/src/models/entities/meta.ts index 80b5228bc..2be43bdd4 100644 --- a/packages/backend/src/models/entities/meta.ts +++ b/packages/backend/src/models/entities/meta.ts @@ -1,6 +1,6 @@ import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm'; -import { User } from './user.js'; import { id } from '../id.js'; +import { User } from './user.js'; import { Clip } from './clip.js'; @Entity() @@ -427,4 +427,9 @@ export class Meta { default: true, }) public objectStorageS3ForcePathStyle: boolean; + + @Column('boolean', { + default: false, + }) + public enableIpLogging: boolean; } diff --git a/packages/backend/src/models/entities/user-ip.ts b/packages/backend/src/models/entities/user-ip.ts new file mode 100644 index 000000000..543e9e728 --- /dev/null +++ b/packages/backend/src/models/entities/user-ip.ts @@ -0,0 +1,24 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; +import { id } from '../id.js'; +import { Note } from './note.js'; +import { User } from './user.js'; + +@Entity() +@Index(['userId', 'ip'], { unique: true }) +export class UserIp { + @PrimaryGeneratedColumn() + public id: string; + + @Column('timestamp with time zone', { + }) + public createdAt: Date; + + @Index() + @Column(id()) + public userId: User['id']; + + @Column('varchar', { + length: 128, + }) + public ip: string; +} diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index 1778742ea..7dfe13fe1 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -1,8 +1,8 @@ import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm'; +import { ffVisibility, notificationTypes } from '@/types.js'; import { id } from '../id.js'; import { User } from './user.js'; import { Page } from './page.js'; -import { ffVisibility, notificationTypes } from '@/types.js'; // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも // ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン @@ -117,6 +117,11 @@ export class UserProfile { }) public password: string | null; + @Column('varchar', { + length: 8192, default: '', + }) + public moderationNote: string | null; + // TODO: そのうち消す @Column('jsonb', { default: {}, diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 814b37d44..3f7326931 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -65,6 +65,7 @@ import { PasswordResetRequest } from './entities/password-reset-request.js'; import { UserPending } from './entities/user-pending.js'; import { InstanceRepository } from './repositories/instance.js'; import { Webhook } from './entities/webhook.js'; +import { UserIp } from './entities/user-ip.js'; export const Announcements = db.getRepository(Announcement); export const AnnouncementReads = db.getRepository(AnnouncementRead); @@ -90,6 +91,7 @@ export const UserGroups = (UserGroupRepository); export const UserGroupJoinings = db.getRepository(UserGroupJoining); export const UserGroupInvitations = (UserGroupInvitationRepository); export const UserNotePinings = db.getRepository(UserNotePining); +export const UserIps = db.getRepository(UserIp); export const UsedUsernames = db.getRepository(UsedUsername); export const Followings = (FollowingRepository); export const FollowRequests = (FollowRequestRepository); diff --git a/packages/backend/src/queue/index.ts b/packages/backend/src/queue/index.ts index c5fd7de1c..ebb3a77ca 100644 --- a/packages/backend/src/queue/index.ts +++ b/packages/backend/src/queue/index.ts @@ -2,6 +2,9 @@ import httpSignature from '@peertube/http-signature'; import { v4 as uuid } from 'uuid'; import config from '@/config/index.js'; +import { DriveFile } from '@/models/entities/drive-file.js'; +import { IActivity } from '@/remote/activitypub/type.js'; +import { Webhook, webhookEventTypes } from '@/models/entities/webhook.js'; import { envOption } from '../env.js'; import processDeliver from './processors/deliver.js'; @@ -12,18 +15,15 @@ import processSystemQueue from './processors/system/index.js'; import processWebhookDeliver from './processors/webhook-deliver.js'; import { endedPollNotification } from './processors/ended-poll-notification.js'; import { queueLogger } from './logger.js'; -import { DriveFile } from '@/models/entities/drive-file.js'; import { getJobInfo } from './get-job-info.js'; import { systemQueue, dbQueue, deliverQueue, inboxQueue, objectStorageQueue, endedPollNotificationQueue, webhookDeliverQueue } from './queues.js'; import { ThinUser } from './types.js'; -import { IActivity } from '@/remote/activitypub/type.js'; -import { Webhook, webhookEventTypes } from '@/models/entities/webhook.js'; function renderError(e: Error): any { return { - stack: e?.stack, - message: e?.message, - name: e?.name, + stack: e.stack, + message: e.message, + name: e.name, }; } @@ -314,6 +314,12 @@ export default function() { removeOnComplete: true, }); + systemQueue.add('clean', { + }, { + repeat: { cron: '0 0 * * *' }, + removeOnComplete: true, + }); + systemQueue.add('checkExpiredMutings', { }, { repeat: { cron: '*/5 * * * *' }, diff --git a/packages/backend/src/queue/processors/system/clean.ts b/packages/backend/src/queue/processors/system/clean.ts new file mode 100644 index 000000000..c4f978d7c --- /dev/null +++ b/packages/backend/src/queue/processors/system/clean.ts @@ -0,0 +1,18 @@ +import Bull from 'bull'; +import { LessThan } from 'typeorm'; +import { UserIps } from '@/models/index.js'; + +import { queueLogger } from '../../logger.js'; + +const logger = queueLogger.createSubLogger('clean'); + +export async function clean(job: Bull.Job>, done: any): Promise { + logger.info('Cleaning...'); + + UserIps.delete({ + createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), + }); + + logger.succ('Cleaned.'); + done(); +} diff --git a/packages/backend/src/queue/processors/system/index.ts b/packages/backend/src/queue/processors/system/index.ts index f90f6efaf..9527d40b0 100644 --- a/packages/backend/src/queue/processors/system/index.ts +++ b/packages/backend/src/queue/processors/system/index.ts @@ -3,12 +3,14 @@ import { tickCharts } from './tick-charts.js'; import { resyncCharts } from './resync-charts.js'; import { cleanCharts } from './clean-charts.js'; import { checkExpiredMutings } from './check-expired-mutings.js'; +import { clean } from './clean.js'; const jobs = { tickCharts, resyncCharts, cleanCharts, checkExpiredMutings, + clean, } as Record> | Bull.ProcessPromiseFunction>>; export default function(dbQueue: Bull.Queue>) { diff --git a/packages/backend/src/server/api/api-handler.ts b/packages/backend/src/server/api/api-handler.ts index c22c868c8..34ff970b4 100644 --- a/packages/backend/src/server/api/api-handler.ts +++ b/packages/backend/src/server/api/api-handler.ts @@ -1,10 +1,19 @@ import Koa from 'koa'; +import { User } from '@/models/entities/user.js'; +import { UserIps } from '@/models/index.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; import { IEndpoint } from './endpoints.js'; import authenticate, { AuthenticationError } from './authenticate.js'; import call from './call.js'; import { ApiError } from './error.js'; +const userIpHistories = new Map>(); + +setInterval(() => { + userIpHistories.clear(); +}, 1000 * 60 * 60); + export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res) => { const body = ctx.is('multipart/form-data') ? (ctx.request as any).body @@ -44,6 +53,31 @@ export default (endpoint: IEndpoint, ctx: Koa.Context) => new Promise((res }).catch((e: ApiError) => { reply(e.httpStatusCode ? e.httpStatusCode : e.kind === 'client' ? 400 : 500, e); }); + + // Log IP + if (user) { + fetchMeta().then(meta => { + if (!meta.enableIpLogging) return; + const ip = ctx.ip; + const ips = userIpHistories.get(user.id); + if (ips == null || !ips.has(ip)) { + if (ips == null) { + userIpHistories.set(user.id, new Set([ip])); + } else { + ips.add(ip); + } + + try { + UserIps.insert({ + createdAt: new Date(), + userId: user.id, + ip: ip, + }); + } catch { + } + } + }); + } }).catch(e => { if (e instanceof AuthenticationError) { reply(403, new ApiError({ diff --git a/packages/backend/src/server/api/call.ts b/packages/backend/src/server/api/call.ts index 75bbc9f90..aa130459a 100644 --- a/packages/backend/src/server/api/call.ts +++ b/packages/backend/src/server/api/call.ts @@ -116,7 +116,7 @@ export default async (endpoint: string, user: CacheableLocalUser | null | undefi // API invoking const before = performance.now(); - return await ep.exec(data, user, token, ctx?.file).catch((e: Error) => { + return await ep.exec(data, user, token, ctx?.file, ctx?.ip, ctx?.headers).catch((e: Error) => { if (e instanceof ApiError) { throw e; } else { diff --git a/packages/backend/src/server/api/define.ts b/packages/backend/src/server/api/define.ts index 47dcb44ea..c1b56b8a8 100644 --- a/packages/backend/src/server/api/define.ts +++ b/packages/backend/src/server/api/define.ts @@ -1,16 +1,16 @@ import * as fs from 'node:fs'; import Ajv from 'ajv'; import { CacheableLocalUser, ILocalUser } from '@/models/entities/user.js'; -import { IEndpointMeta } from './endpoints.js'; -import { ApiError } from './error.js'; import { Schema, SchemaType } from '@/misc/schema.js'; import { AccessToken } from '@/models/entities/access-token.js'; +import { IEndpointMeta } from './endpoints.js'; +import { ApiError } from './error.js'; export type Response = Record | void; // TODO: paramsの型をT['params']のスキーマ定義から推論する type executor = - (params: SchemaType, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any) => + (params: SchemaType, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, cleanup?: () => any, ip?: string | null, headers?: Record | null) => Promise>>; const ajv = new Ajv({ @@ -20,23 +20,27 @@ const ajv = new Ajv({ 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 { + : (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => Promise { const validate = ajv.compile(paramDef); - return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any) => { - function cleanup() { - fs.unlink(file.path, () => {}); - } + return (params: any, user: T['requireCredential'] extends true ? CacheableLocalUser : CacheableLocalUser | null, token: AccessToken | null, file?: any, ip?: string | null, headers?: Record | null) => { + let cleanup: undefined | (() => void) = undefined; - if (meta.requireFile && file == null) return Promise.reject(new ApiError({ - message: 'File required.', - code: 'FILE_REQUIRED', - id: '4267801e-70d1-416a-b011-4ee502885d8b', - })); + if (meta.requireFile) { + cleanup = () => { + fs.unlink(file.path, () => {}); + }; + + if (file == null) return Promise.reject(new ApiError({ + message: 'File required.', + code: 'FILE_REQUIRED', + id: '4267801e-70d1-416a-b011-4ee502885d8b', + })); + } const valid = validate(params); if (!valid) { - if (file) cleanup(); + if (file) cleanup!(); const errors = validate.errors!; const err = new ApiError({ @@ -50,6 +54,6 @@ export default function (meta: T, pa return Promise.reject(err); } - return cb(params as SchemaType, user, token, file, cleanup); + return cb(params as SchemaType, user, token, file, cleanup, ip, headers); }; } diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 1a3fc199d..4a2ecebd8 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -35,6 +35,7 @@ import * as ep___admin_federation_removeAllFollowing from './endpoints/admin/fed import * as ep___admin_federation_updateInstance from './endpoints/admin/federation/update-instance.js'; import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js'; import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js'; +import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js'; import * as ep___admin_invite from './endpoints/admin/invite.js'; import * as ep___admin_moderators_add from './endpoints/admin/moderators/add.js'; import * as ep___admin_moderators_remove from './endpoints/admin/moderators/remove.js'; @@ -60,6 +61,7 @@ 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___admin_updateUserNote from './endpoints/admin/update-user-note.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'; @@ -311,6 +313,7 @@ import * as ep___users_searchByUsernameAndHost from './endpoints/users/search-by import * as ep___users_search from './endpoints/users/search.js'; import * as ep___users_show from './endpoints/users/show.js'; import * as ep___users_stats from './endpoints/users/stats.js'; +import * as ep___fetchRss from './endpoints/fetch-rss.js'; const eps = [ ['admin/meta', ep___admin_meta], @@ -348,6 +351,7 @@ const eps = [ ['admin/federation/update-instance', ep___admin_federation_updateInstance], ['admin/get-index-stats', ep___admin_getIndexStats], ['admin/get-table-stats', ep___admin_getTableStats], + ['admin/get-user-ips', ep___admin_getUserIps], ['admin/invite', ep___admin_invite], ['admin/moderators/add', ep___admin_moderators_add], ['admin/moderators/remove', ep___admin_moderators_remove], @@ -373,6 +377,7 @@ const eps = [ ['admin/update-meta', ep___admin_updateMeta], ['admin/vacuum', ep___admin_vacuum], ['admin/delete-account', ep___admin_deleteAccount], + ['admin/update-user-note', ep___admin_updateUserNote], ['announcements', ep___announcements], ['antennas/create', ep___antennas_create], ['antennas/delete', ep___antennas_delete], @@ -624,6 +629,7 @@ const eps = [ ['users/search', ep___users_search], ['users/show', ep___users_show], ['users/stats', ep___users_stats], + ['fetch-rss', ep___fetchRss], ]; export interface IEndpointMeta { diff --git a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts index 039df74f1..e9117a23c 100644 --- a/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts +++ b/packages/backend/src/server/api/endpoints/admin/drive/show-file.ts @@ -1,6 +1,6 @@ +import { DriveFiles } from '@/models/index.js'; import define from '../../../define.js'; import { ApiError } from '../../../error.js'; -import { DriveFiles } from '@/models/index.js'; export const meta = { tags: ['admin'], @@ -184,5 +184,10 @@ export default define(meta, paramDef, async (ps, me) => { throw new ApiError(meta.errors.noSuchFile); } + if (!me.isAdmin) { + delete file.requestIp; + delete file.requestHeaders; + } + return file; }); diff --git a/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts new file mode 100644 index 000000000..e8b9cb3b0 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/get-user-ips.ts @@ -0,0 +1,31 @@ +import { UserIps } from '@/models/index.js'; +import define from '../../define.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireAdmin: true, +} 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, me) => { + const ips = await UserIps.find({ + where: { userId: ps.userId }, + order: { createdAt: 'DESC' }, + take: 30, + }); + + return ips.map(x => ({ + ip: x.ip, + createdAt: x.createdAt.toISOString(), + })); +}); diff --git a/packages/backend/src/server/api/endpoints/admin/meta.ts b/packages/backend/src/server/api/endpoints/admin/meta.ts index 8d50486ef..8b7162895 100644 --- a/packages/backend/src/server/api/endpoints/admin/meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/meta.ts @@ -1,7 +1,7 @@ import config from '@/config/index.js'; -import define from '../../define.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; import { MAX_NOTE_TEXT_LENGTH } from '@/const.js'; +import define from '../../define.js'; export const meta = { tags: ['meta'], @@ -304,6 +304,10 @@ export const meta = { type: 'boolean', optional: true, nullable: false, }, + enableIpLogging: { + type: 'boolean', + optional: true, nullable: false, + }, }, }, } as const; @@ -360,7 +364,6 @@ export default define(meta, paramDef, async (ps, me) => { pinnedPages: instance.pinnedPages, pinnedClipId: instance.pinnedClipId, cacheRemoteFiles: instance.cacheRemoteFiles, - useStarForReactionFallback: instance.useStarForReactionFallback, pinnedUsers: instance.pinnedUsers, hiddenTags: instance.hiddenTags, @@ -397,5 +400,6 @@ export default define(meta, paramDef, async (ps, me) => { objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle, deeplAuthKey: instance.deeplAuthKey, deeplIsPro: instance.deeplIsPro, + enableIpLogging: instance.enableIpLogging, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 78033aed5..f04a7a67c 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -25,7 +25,7 @@ export const paramDef = { export default define(meta, paramDef, async (ps, me) => { const [user, profile] = await Promise.all([ Users.findOneBy({ id: ps.userId }), - UserProfiles.findOneBy({ userId: ps.userId }) + UserProfiles.findOneBy({ userId: ps.userId }), ]); if (user == null || profile == null) { @@ -68,6 +68,8 @@ export default define(meta, paramDef, async (ps, me) => { isModerator: user.isModerator, isSilenced: user.isSilenced, isSuspended: user.isSuspended, + lastActiveDate: user.lastActiveDate, + moderationNote: profile.moderationNote, signins, }; }); diff --git a/packages/backend/src/server/api/endpoints/admin/update-meta.ts b/packages/backend/src/server/api/endpoints/admin/update-meta.ts index 09e43301b..4dc4726a2 100644 --- a/packages/backend/src/server/api/endpoints/admin/update-meta.ts +++ b/packages/backend/src/server/api/endpoints/admin/update-meta.ts @@ -1,8 +1,8 @@ -import define from '../../define.js'; import { Meta } from '@/models/entities/meta.js'; import { insertModerationLog } from '@/services/insert-moderation-log.js'; import { DB_MAX_NOTE_TEXT_LENGTH } from '@/misc/hard-limits.js'; import { db } from '@/db/postgre.js'; +import define from '../../define.js'; export const meta = { tags: ['admin'], @@ -96,6 +96,7 @@ export const paramDef = { objectStorageUseProxy: { type: 'boolean' }, objectStorageSetPublicRead: { type: 'boolean' }, objectStorageS3ForcePathStyle: { type: 'boolean' }, + enableIpLogging: { type: 'boolean' }, }, required: [], } as const; @@ -396,6 +397,10 @@ export default define(meta, paramDef, async (ps, me) => { set.deeplIsPro = ps.deeplIsPro; } + if (ps.enableIpLogging !== undefined) { + set.enableIpLogging = ps.enableIpLogging; + } + await db.transaction(async transactionalEntityManager => { const metas = await transactionalEntityManager.find(Meta, { order: { diff --git a/packages/backend/src/server/api/endpoints/admin/update-user-note.ts b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts new file mode 100644 index 000000000..fa21ab783 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/update-user-note.ts @@ -0,0 +1,31 @@ +import { UserProfiles, Users } from '@/models/index.js'; +import define from '../../define.js'; + +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + text: { type: 'string' }, + }, + required: ['userId', 'text'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const user = await Users.findOneBy({ id: ps.userId }); + + if (user == null) { + throw new Error('user not found'); + } + + await UserProfiles.update({ userId: user.id }, { + moderationNote: ps.text, + }); +}); diff --git a/packages/backend/src/server/api/endpoints/drive/files/create.ts b/packages/backend/src/server/api/endpoints/drive/files/create.ts index 7397fd9ce..3a76a5d98 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/create.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/create.ts @@ -1,10 +1,11 @@ import ms from 'ms'; import { addFile } from '@/services/drive/add-file.js'; +import { DriveFiles } from '@/models/index.js'; +import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; +import { fetchMeta } from '@/misc/fetch-meta.js'; import define from '../../../define.js'; import { apiLogger } from '../../../logger.js'; import { ApiError } from '../../../error.js'; -import { DriveFiles } from '@/models/index.js'; -import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; export const meta = { tags: ['drive'], @@ -50,7 +51,7 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user, _, file, cleanup) => { +export default define(meta, paramDef, async (ps, user, _, file, cleanup, ip, headers) => { // Get 'name' parameter let name = ps.name || file.originalname; if (name !== undefined && name !== null) { @@ -66,9 +67,21 @@ export default define(meta, paramDef, async (ps, user, _, file, cleanup) => { name = null; } + const meta = await fetchMeta(); + try { // Create file - const driveFile = await addFile({ user, path: file.path, name, comment: ps.comment, folderId: ps.folderId, force: ps.force, sensitive: ps.isSensitive }); + const driveFile = await addFile({ + user, + path: file.path, + name, + comment: ps.comment, + folderId: ps.folderId, + force: ps.force, + sensitive: ps.isSensitive, + requestIp: meta.enableIpLogging ? ip : null, + requestHeaders: meta.enableIpLogging ? headers : null, + }); return await DriveFiles.pack(driveFile, { self: true }); } catch (e) { if (e instanceof Error || typeof e === 'string') { diff --git a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts index 53f2298f2..eb8071c3c 100644 --- a/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts +++ b/packages/backend/src/server/api/endpoints/drive/files/upload-from-url.ts @@ -1,9 +1,9 @@ import ms from 'ms'; import { uploadFromUrl } from '@/services/drive/upload-from-url.js'; -import define from '../../../define.js'; import { DriveFiles } from '@/models/index.js'; import { publishMainStream } from '@/services/stream.js'; import { DB_MAX_IMAGE_COMMENT_LENGTH } from '@/misc/hard-limits.js'; +import define from '../../../define.js'; export const meta = { tags: ['drive'], @@ -34,8 +34,8 @@ export const paramDef = { } as const; // eslint-disable-next-line import/no-default-export -export default define(meta, paramDef, async (ps, user) => { - uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment }).then(file => { +export default define(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => { + uploadFromUrl({ url: ps.url, user, folderId: ps.folderId, sensitive: ps.isSensitive, force: ps.force, comment: ps.comment, requestIp: ip, requestHeaders: headers }).then(file => { DriveFiles.pack(file, { self: true }).then(packedFile => { publishMainStream(user.id, 'urlUploadFinished', { marker: ps.marker, diff --git a/packages/backend/src/server/api/endpoints/federation/stats.ts b/packages/backend/src/server/api/endpoints/federation/stats.ts index cbe47dc7c..e02c7b97e 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -54,7 +54,7 @@ export default define(meta, paramDef, async (ps) => { ]); 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); + const gotPubCount = topPubInstances.map(x => x.followingCount).reduce((a, b) => a + b, 0); return await awaitAll({ topSubInstances: Instances.packMany(topSubInstances), diff --git a/packages/backend/src/server/api/endpoints/fetch-rss.ts b/packages/backend/src/server/api/endpoints/fetch-rss.ts new file mode 100644 index 000000000..05fa22a9e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/fetch-rss.ts @@ -0,0 +1,39 @@ +import Parser from 'rss-parser'; +import { getResponse } from '@/misc/fetch.js'; +import config from '@/config/index.js'; +import define from '../define.js'; + +const rssParser = new Parser(); + +export const meta = { + tags: ['meta'], + + requireCredential: false, + allowGet: true, + cacheSec: 60 * 3, +} as const; + +export const paramDef = { + type: 'object', + properties: { + url: { type: 'string' }, + }, + required: ['url'], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps) => { + const res = await getResponse({ + url: ps.url, + method: 'GET', + headers: Object.assign({ + 'User-Agent': config.userAgent, + Accept: 'application/rss+xml, */*', + }), + timeout: 5000, + }); + + const text = await res.text(); + + return rssParser.parseString(text); +}); diff --git a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts index 2150efaaf..5a04d68f3 100644 --- a/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts +++ b/packages/backend/src/server/api/endpoints/notes/polls/recommendation.ts @@ -60,12 +60,21 @@ export default define(meta, paramDef, async (ps, user) => { query.setParameters(mutingQuery.getParameters()); //#endregion - const polls = await query.take(ps.limit).skip(ps.offset).getMany(); + const polls = await query + .orderBy('poll.noteId', 'DESC') + .take(ps.limit) + .skip(ps.offset) + .getMany(); if (polls.length === 0) return []; - const notes = await Notes.findBy({ - id: In(polls.map(poll => poll.noteId)), + const notes = await Notes.find({ + where: { + id: In(polls.map(poll => poll.noteId)), + }, + order: { + createdAt: 'DESC', + }, }); return await Notes.packMany(notes, user, { diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts index cfbcb60dd..a25413187 100644 --- a/packages/backend/src/services/drive/add-file.ts +++ b/packages/backend/src/services/drive/add-file.ts @@ -2,26 +2,26 @@ import * as fs from 'node:fs'; import { v4 as uuid } from 'uuid'; +import S3 from 'aws-sdk/clients/s3.js'; +import sharp from 'sharp'; +import { IsNull } from 'typeorm'; import { publishMainStream, publishDriveStream } from '@/services/stream.js'; -import { deleteFile } from './delete-file.js'; import { fetchMeta } from '@/misc/fetch-meta.js'; -import { GenerateVideoThumbnail } from './generate-video-thumbnail.js'; -import { driveLogger } from './logger.js'; -import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; import { contentDisposition } from '@/misc/content-disposition.js'; import { getFileInfo } from '@/misc/get-file-info.js'; import { DriveFiles, DriveFolders, Users, Instances, UserProfiles } from '@/models/index.js'; -import { InternalStorage } from './internal-storage.js'; import { DriveFile } from '@/models/entities/drive-file.js'; import { IRemoteUser, User } from '@/models/entities/user.js'; import { driveChart, perUserDriveChart, instanceChart } from '@/services/chart/index.js'; import { genId } from '@/misc/gen-id.js'; import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js'; -import S3 from 'aws-sdk/clients/s3.js'; -import { getS3 } from './s3.js'; -import sharp from 'sharp'; import { FILE_TYPE_BROWSERSAFE } from '@/const.js'; -import { IsNull } from 'typeorm'; +import { getS3 } from './s3.js'; +import { InternalStorage } from './internal-storage.js'; +import { IImage, convertSharpToJpeg, convertSharpToWebp, convertSharpToPng } from './image-processor.js'; +import { driveLogger } from './logger.js'; +import { GenerateVideoThumbnail } from './generate-video-thumbnail.js'; +import { deleteFile } from './delete-file.js'; const logger = driveLogger.createSubLogger('register', 'yellow'); @@ -171,7 +171,7 @@ export async function generateAlts(path: string, type: string, generateWeb: bool } if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) { - logger.debug(`web image and thumbnail not created (not an required file)`); + logger.debug('web image and thumbnail not created (not an required file)'); return { webpublic: null, thumbnail: null, @@ -212,7 +212,7 @@ export async function generateAlts(path: string, type: string, generateWeb: bool let webpublic: IImage | null = null; if (generateWeb && !satisfyWebpublic) { - logger.info(`creating web image`); + logger.info('creating web image'); try { if (['image/jpeg', 'image/webp'].includes(type)) { @@ -222,14 +222,14 @@ export async function generateAlts(path: string, type: string, generateWeb: bool } else if (['image/svg+xml'].includes(type)) { webpublic = await convertSharpToPng(img, 2048, 2048); } else { - logger.debug(`web image not created (not an required image)`); + logger.debug('web image not created (not an required image)'); } } catch (err) { - logger.warn(`web image not created (an error occured)`, err as Error); + logger.warn('web image not created (an error occured)', err as Error); } } else { - if (satisfyWebpublic) logger.info(`web image not created (original satisfies webpublic)`); - else logger.info(`web image not created (from remote)`); + if (satisfyWebpublic) logger.info('web image not created (original satisfies webpublic)'); + else logger.info('web image not created (from remote)'); } // #endregion webpublic @@ -240,10 +240,10 @@ export async function generateAlts(path: string, type: string, generateWeb: bool if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) { thumbnail = await convertSharpToWebp(img, 498, 280); } else { - logger.debug(`thumbnail not created (not an required file)`); + logger.debug('thumbnail not created (not an required file)'); } } catch (err) { - logger.warn(`thumbnail not created (an error occured)`, err as Error); + logger.warn('thumbnail not created (an error occured)', err as Error); } // #endregion thumbnail @@ -276,7 +276,7 @@ async function upload(key: string, stream: fs.ReadStream | Buffer, type: string, const s3 = getS3(meta); const upload = s3.upload(params, { - partSize: s3.endpoint?.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, + partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024, }); const result = await upload.promise(); @@ -326,6 +326,9 @@ type AddFileArgs = { uri?: string | null; /** Mark file as sensitive */ sensitive?: boolean | null; + + requestIp?: string | null; + requestHeaders?: Record | null; }; /** @@ -342,7 +345,9 @@ export async function addFile({ isLink = false, url = null, uri = null, - sensitive = null + sensitive = null, + requestIp = null, + requestHeaders = null, }: AddFileArgs): Promise { const info = await getFileInfo(path); logger.info(`${JSON.stringify(info)}`); @@ -427,11 +432,13 @@ export async function addFile({ file.properties = properties; file.blurhash = info.blurhash || null; file.isLink = isLink; + file.requestIp = requestIp; + file.requestHeaders = requestHeaders; file.isSensitive = user ? Users.isLocalUser(user) && profile!.alwaysMarkNsfw ? true : - (sensitive !== null && sensitive !== undefined) - ? sensitive - : false + (sensitive !== null && sensitive !== undefined) + ? sensitive + : false : false; if (url !== null) { diff --git a/packages/backend/src/services/drive/upload-from-url.ts b/packages/backend/src/services/drive/upload-from-url.ts index 001fc49ee..3c5e1aa5c 100644 --- a/packages/backend/src/services/drive/upload-from-url.ts +++ b/packages/backend/src/services/drive/upload-from-url.ts @@ -1,12 +1,12 @@ import { URL } from 'node:url'; -import { addFile } from './add-file.js'; import { User } from '@/models/entities/user.js'; -import { driveLogger } from './logger.js'; import { createTemp } from '@/misc/create-temp.js'; import { downloadUrl } from '@/misc/download-url.js'; import { DriveFolder } from '@/models/entities/drive-folder.js'; import { DriveFile } from '@/models/entities/drive-file.js'; import { DriveFiles } from '@/models/index.js'; +import { driveLogger } from './logger.js'; +import { addFile } from './add-file.js'; const logger = driveLogger.createSubLogger('downloader'); @@ -19,6 +19,8 @@ type Args = { force?: boolean; isLink?: boolean; comment?: string | null; + requestIp?: string | null; + requestHeaders?: Record | null; }; export async function uploadFromUrl({ @@ -30,6 +32,8 @@ export async function uploadFromUrl({ force = false, isLink = false, comment = null, + requestIp = null, + requestHeaders = null, }: Args): Promise { let name = new URL(url).pathname.split('/').pop() || null; if (name == null || !DriveFiles.validateFileName(name)) { @@ -49,7 +53,7 @@ export async function uploadFromUrl({ // write content at URL to temp file await downloadUrl(url, path); - const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive }); + const driveFile = await addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders }); logger.succ(`Got: ${driveFile.id}`); return driveFile!; } catch (e) { diff --git a/packages/backend/test/streaming.ts b/packages/backend/test/streaming.ts index f080b71dd..621d07f9c 100644 --- a/packages/backend/test/streaming.ts +++ b/packages/backend/test/streaming.ts @@ -3,22 +3,12 @@ process.env.NODE_ENV = 'test'; import * as assert from 'assert'; import * as childProcess from 'child_process'; import { Following } from '../src/models/entities/following.js'; -import { connectStream, signup, request, post, startServer, shutdownServer, initTestDb } from './utils.js'; +import { connectStream, signup, api, post, startServer, shutdownServer, initTestDb, waitFire } from './utils.js'; describe('Streaming', () => { let p: childProcess.ChildProcess; let Followings: any; - beforeEach(async () => { - p = await startServer(); - const connection = await initTestDb(true); - Followings = connection.getRepository(Following); - }); - - afterEach(async () => { - await shutdownServer(p); - }); - const follow = async (follower: any, followee: any) => { await Followings.save({ id: 'a', @@ -34,871 +24,522 @@ describe('Streaming', () => { }); }; - it('mention event', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); + describe('Streaming', () => { + // Local users + let ayano: any; + let kyoko: any; + let chitose: any; - const ws = await connectStream(bob, 'main', ({ type, body }) => { - if (type == 'mention') { - assert.deepStrictEqual(body.userId, alice.id); - ws.close(); - done(); - } - }); + // Remote users + let akari: any; + let chinatsu: any; - post(alice, { - text: 'foo @bob bar', - }); - })); + let kyokoNote: any; + let list: any; - it('renote event', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - const bobNote = await post(bob, { - text: 'foo', - }); + before(async () => { + p = await startServer(); + const connection = await initTestDb(true); + Followings = connection.getRepository(Following); - const ws = await connectStream(bob, 'main', ({ type, body }) => { - if (type == 'renote') { - assert.deepStrictEqual(body.renoteId, bobNote.id); - ws.close(); - done(); - } - }); + ayano = await signup({ username: 'ayano' }); + kyoko = await signup({ username: 'kyoko' }); + chitose = await signup({ username: 'chitose' }); - post(alice, { - renoteId: bobNote.id, - }); - })); + akari = await signup({ username: 'akari', host: 'example.com' }); + chinatsu = await signup({ username: 'chinatsu', host: 'example.com' }); - describe('Home Timeline', () => { - it('自分の投稿が流れる', () => new Promise(async done => { - const post = { - text: 'foo', - }; + kyokoNote = await post(kyoko, { text: 'foo' }); - const me = await signup(); + // Follow: ayano => kyoko + await api('following/create', { userId: kyoko.id }, ayano); - const ws = await connectStream(me, 'homeTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.text, post.text); - ws.close(); - done(); - } - }); + // Follow: ayano => akari + await follow(ayano, akari); - request('/notes/create', post, me); - })); - - it('フォローしているユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - const ws = await connectStream(alice, 'homeTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }); - - post(bob, { - text: 'foo', - }); - })); - - it('フォローしていないユーザーの投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - let fired = false; - - const ws = await connectStream(alice, 'homeTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - post(bob, { - text: 'foo', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('フォローしているユーザーのダイレクト投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - const ws = await connectStream(alice, 'homeTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - assert.deepStrictEqual(body.text, 'foo'); - ws.close(); - done(); - } - }); - - // Bob が Alice 宛てのダイレクト投稿 - post(bob, { - text: 'foo', - visibility: 'specified', - visibleUserIds: [alice.id], - }); - })); - - it('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - const carol = await signup({ username: 'carol' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'homeTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // Bob が Carol 宛てのダイレクト投稿 - post(bob, { - text: 'foo', - visibility: 'specified', - visibleUserIds: [carol.id], - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - }); - - describe('Local Timeline', () => { - it('自分の投稿が流れる', () => new Promise(async done => { - const me = await signup(); - - const ws = await connectStream(me, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, me.id); - ws.close(); - done(); - } - }); - - post(me, { - text: 'foo', - }); - })); - - it('フォローしていないローカルユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - const ws = await connectStream(alice, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }); - - post(bob, { - text: 'foo', - }); - })); - - it('リモートユーザーの投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob', host: 'example.com' }); - - let fired = false; - - const ws = await connectStream(alice, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - post(bob, { - text: 'foo', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('フォローしてたとしてもリモートユーザーの投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob', host: 'example.com' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - post(bob, { - text: 'foo', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('ホーム指定の投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - let fired = false; - - const ws = await connectStream(alice, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // ホーム指定 - post(bob, { - text: 'foo', - visibility: 'home', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('フォローしているローカルユーザーのダイレクト投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // Bob が Alice 宛てのダイレクト投稿 - post(bob, { - text: 'foo', - visibility: 'specified', - visibleUserIds: [alice.id], - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - let fired = false; - - const ws = await connectStream(alice, 'localTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // フォロワー宛て投稿 - post(bob, { - text: 'foo', - visibility: 'followers', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - }); - - describe('Hybrid Timeline', () => { - it('自分の投稿が流れる', () => new Promise(async done => { - const me = await signup(); - - const ws = await connectStream(me, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, me.id); - ws.close(); - done(); - } - }); - - post(me, { - text: 'foo', - }); - })); - - it('フォローしていないローカルユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }); - - post(bob, { - text: 'foo', - }); - })); - - it('フォローしているリモートユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob', host: 'example.com' }); - - // Alice が Bob をフォロー - await follow(alice, bob); - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }); - - post(bob, { - text: 'foo', - }); - })); - - it('フォローしていないリモートユーザーの投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob', host: 'example.com' }); - - let fired = false; - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - post(bob, { - text: 'foo', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('フォローしているユーザーのダイレクト投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - assert.deepStrictEqual(body.text, 'foo'); - ws.close(); - done(); - } - }); - - // Bob が Alice 宛てのダイレクト投稿 - post(bob, { - text: 'foo', - visibility: 'specified', - visibleUserIds: [alice.id], - }); - })); - - it('フォローしているユーザーのホーム投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // Alice が Bob をフォロー - await request('/following/create', { - userId: bob.id, - }, alice); - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - assert.deepStrictEqual(body.text, 'foo'); - ws.close(); - done(); - } - }); - - // ホーム投稿 - post(bob, { - text: 'foo', - visibility: 'home', - }); - })); - - it('フォローしていないローカルユーザーのホーム投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - let fired = false; - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // ホーム投稿 - post(bob, { - text: 'foo', - visibility: 'home', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - let fired = false; - - const ws = await connectStream(alice, 'hybridTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // フォロワー宛て投稿 - post(bob, { - text: 'foo', - visibility: 'followers', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - }); - - describe('Global Timeline', () => { - it('フォローしていないローカルユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - const ws = await connectStream(alice, 'globalTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }); - - post(bob, { - text: 'foo', - }); - })); - - it('フォローしていないリモートユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob', host: 'example.com' }); - - const ws = await connectStream(alice, 'globalTimeline', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }); - - post(bob, { - text: 'foo', - }); - })); - - it('ホーム投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - let fired = false; - - const ws = await connectStream(alice, 'globalTimeline', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }); - - // ホーム投稿 - post(bob, { - text: 'foo', - visibility: 'home', - }); - - setTimeout(() => { - assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - }); - - describe('UserList Timeline', () => { - it('リストに入れているユーザーの投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // リスト作成 - const list = await request('/users/lists/create', { + // List: chitose => ayano, kyoko + list = await api('users/lists/create', { name: 'my list', - }, alice).then(x => x.body); + }, chitose).then(x => x.body); - // Alice が Bob をリスイン - await request('/users/lists/push', { + await api('users/lists/push', { listId: list.id, - userId: bob.id, - }, alice); + userId: ayano.id, + }, chitose); - const ws = await connectStream(alice, 'userList', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - ws.close(); - done(); - } - }, { + await api('users/lists/push', { listId: list.id, + userId: kyoko.id, + }, chitose); + }); + + after(async () => { + await shutdownServer(p); + }); + + describe('Events', () => { + it('mention event', async () => { + const fired = await waitFire( + kyoko, 'main', // kyoko:main + () => post(ayano, { text: 'foo @kyoko bar' }), // ayano mention => kyoko + msg => msg.type === 'mention' && msg.body.userId === ayano.id // wait ayano + ); + + assert.strictEqual(fired, true); }); - post(bob, { - text: 'foo', + it('renote event', async () => { + const fired = await waitFire( + kyoko, 'main', // kyoko:main + () => post(ayano, { renoteId: kyokoNote.id }), // ayano renote + msg => msg.type === 'renote' && msg.body.renoteId === kyokoNote.id // wait renote + ); + + assert.strictEqual(fired, true); }); - })); + }); - it('リストに入れていないユーザーの投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); + describe('Home Timeline', () => { + it('自分の投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:Home + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.text === 'foo' + ); - // リスト作成 - const list = await request('/users/lists/create', { - name: 'my list', - }, alice).then(x => x.body); - - let fired = false; - - const ws = await connectStream(alice, 'userList', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }, { - listId: list.id, + assert.strictEqual(fired, true); }); - post(bob, { - text: 'foo', + it('フォローしているユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'foo' }, kyoko), // kyoko posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); }); - setTimeout(() => { + it('フォローしていないユーザーの投稿は流れない', async () => { + const fired = await waitFire( + kyoko, 'homeTimeline', // kyoko:home + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.userId === ayano.id // wait ayano + ); + assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - - // #4471 - it('リストに入れているユーザーのダイレクト投稿が流れる', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // リスト作成 - const list = await request('/users/lists/create', { - name: 'my list', - }, alice).then(x => x.body); - - // Alice が Bob をリスイン - await request('/users/lists/push', { - listId: list.id, - userId: bob.id, - }, alice); - - const ws = await connectStream(alice, 'userList', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.userId, bob.id); - assert.deepStrictEqual(body.text, 'foo'); - ws.close(); - done(); - } - }, { - listId: list.id, }); - // Bob が Alice 宛てのダイレクト投稿 - post(bob, { - text: 'foo', - visibility: 'specified', - visibleUserIds: [alice.id], - }); - })); + it('フォローしているユーザーのダイレクト投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id], }, kyoko), // kyoko dm => ayano + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); - // #4335 - it('リストに入れているがフォローはしてないユーザーのフォロワー宛て投稿は流れない', () => new Promise(async done => { - const alice = await signup({ username: 'alice' }); - const bob = await signup({ username: 'bob' }); - - // リスト作成 - const list = await request('/users/lists/create', { - name: 'my list', - }, alice).then(x => x.body); - - // Alice が Bob をリスイン - await request('/users/lists/push', { - listId: list.id, - userId: bob.id, - }, alice); - - let fired = false; - - const ws = await connectStream(alice, 'userList', ({ type, body }) => { - if (type == 'note') { - fired = true; - } - }, { - listId: list.id, + assert.strictEqual(fired, true); }); - // フォロワー宛て投稿 - post(bob, { - text: 'foo', - visibility: 'followers', - }); + it('フォローしているユーザーでも自分が指定されていないダイレクト投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'homeTimeline', // ayano:home + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id], }, kyoko), // kyoko dm => chitose + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); - setTimeout(() => { assert.strictEqual(fired, false); - ws.close(); - done(); - }, 3000); - })); - }); + }); + }); // Home - describe('Hashtag Timeline', () => { - it('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { - const me = await signup(); + describe('Local Timeline', () => { + it('自分の投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.text === 'foo' + ); - const ws = await connectStream(me, 'hashtag', ({ type, body }) => { - if (type == 'note') { - assert.deepStrictEqual(body.text, '#foo'); + assert.strictEqual(fired, true); + }); + + it('フォローしていないローカルユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, chitose), // chitose posts + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, true); + }); + + it('リモートユーザーの投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts + msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしてたとしてもリモートユーザーの投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo' }, akari), // akari posts + msg => msg.type === 'note' && msg.body.userId === akari.id // wait akari + ); + + assert.strictEqual(fired, false); + }); + + it('ホーム指定の投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko home posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしているローカルユーザーのダイレクト投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), // kyoko DM => ayano + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'localTimeline', // ayano:Local + () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('Hybrid Timeline', () => { + it('自分の投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, ayano), // ayano posts + msg => msg.type === 'note' && msg.body.text === 'foo' + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないローカルユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, chitose), // chitose posts + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしているリモートユーザーの投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, akari), // akari posts + msg => msg.type === 'note' && msg.body.userId === akari.id // wait akari + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないリモートユーザーの投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts + msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしているユーザーのダイレクト投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [ayano.id] }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしているユーザーのホーム投稿が流れる', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないローカルユーザーのホーム投稿は流れない', async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'home' }, chitose), + msg => msg.type === 'note' && msg.body.userId === chitose.id + ); + + assert.strictEqual(fired, false); + }); + + it('フォローしていないローカルユーザーのフォロワー宛て投稿は流れない', () => async () => { + const fired = await waitFire( + ayano, 'hybridTimeline', // ayano:Hybrid + () => api('notes/create', { text: 'foo', visibility: 'followers' }, chitose), + msg => msg.type === 'note' && msg.body.userId === chitose.id + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('Global Timeline', () => { + it('フォローしていないローカルユーザーの投稿が流れる', () => async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo' }, chitose), // chitose posts + msg => msg.type === 'note' && msg.body.userId === chitose.id // wait chitose + ); + + assert.strictEqual(fired, true); + }); + + it('フォローしていないリモートユーザーの投稿が流れる', () => async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo' }, chinatsu), // chinatsu posts + msg => msg.type === 'note' && msg.body.userId === chinatsu.id // wait chinatsu + ); + + assert.strictEqual(fired, true); + }); + + it('ホーム投稿は流れない', () => async () => { + const fired = await waitFire( + ayano, 'globalTimeline', // ayano:Global + () => api('notes/create', { text: 'foo', visibility: 'home' }, kyoko), // kyoko posts + msg => msg.type === 'note' && msg.body.userId === kyoko.id // wait kyoko + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('UserList Timeline', () => { + it('リストに入れているユーザーの投稿が流れる', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo' }, ayano), + msg => msg.type === 'note' && msg.body.userId === ayano.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, true); + }); + + it('リストに入れていないユーザーの投稿は流れない', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo' }, chinatsu), + msg => msg.type === 'note' && msg.body.userId === chinatsu.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, false); + }); + + // #4471 + it('リストに入れているユーザーのダイレクト投稿が流れる', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', visibility: 'specified', visibleUserIds: [chitose.id] }, ayano), + msg => msg.type === 'note' && msg.body.userId === ayano.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, true); + }); + + // #4335 + it('リストに入れているがフォローはしてないユーザーのフォロワー宛て投稿は流れない', () => async () => { + const fired = await waitFire( + chitose, 'userList', + () => api('notes/create', { text: 'foo', visibility: 'followers' }, kyoko), + msg => msg.type === 'note' && msg.body.userId === kyoko.id, + { listId: list.id, } + ); + + assert.strictEqual(fired, false); + }); + }); + + describe('Hashtag Timeline', () => { + it('指定したハッシュタグの投稿が流れる', () => new Promise(async done => { + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + assert.deepStrictEqual(body.text, '#foo'); + ws.close(); + done(); + } + }, { + q: [ + ['foo'], + ], + }); + + post(chitose, { + text: '#foo', + }); + })); + + it('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; + + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + } + }, { + q: [ + ['foo', 'bar'], + ], + }); + + post(chitose, { + text: '#foo', + }); + + post(chitose, { + text: '#bar', + }); + + post(chitose, { + text: '#foo #bar', + }); + + setTimeout(() => { + assert.strictEqual(fooCount, 0); + assert.strictEqual(barCount, 0); + assert.strictEqual(fooBarCount, 1); ws.close(); done(); - } - }, { - q: [ - ['foo'], - ], - }); + }, 3000); + })); - post(me, { - text: '#foo', - }); - })); + it('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; + let piyoCount = 0; - it('指定したハッシュタグの投稿が流れる (AND)', () => new Promise(async done => { - const me = await signup(); + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + if (body.text === '#piyo') piyoCount++; + } + }, { + q: [ + ['foo'], + ['bar'], + ], + }); - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; + post(chitose, { + text: '#foo', + }); - const ws = await connectStream(me, 'hashtag', ({ type, body }) => { - if (type == 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - } - }, { - q: [ - ['foo', 'bar'], - ], - }); + post(chitose, { + text: '#bar', + }); - post(me, { - text: '#foo', - }); + post(chitose, { + text: '#foo #bar', + }); - post(me, { - text: '#bar', - }); + post(chitose, { + text: '#piyo', + }); - post(me, { - text: '#foo #bar', - }); + setTimeout(() => { + assert.strictEqual(fooCount, 1); + assert.strictEqual(barCount, 1); + assert.strictEqual(fooBarCount, 1); + assert.strictEqual(piyoCount, 0); + ws.close(); + done(); + }, 3000); + })); - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - ws.close(); - done(); - }, 3000); - })); + it('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise(async done => { + let fooCount = 0; + let barCount = 0; + let fooBarCount = 0; + let piyoCount = 0; + let waaaCount = 0; - it('指定したハッシュタグの投稿が流れる (OR)', () => new Promise(async done => { - const me = await signup(); + const ws = await connectStream(chitose, 'hashtag', ({ type, body }) => { + if (type == 'note') { + if (body.text === '#foo') fooCount++; + if (body.text === '#bar') barCount++; + if (body.text === '#foo #bar') fooBarCount++; + if (body.text === '#piyo') piyoCount++; + if (body.text === '#waaa') waaaCount++; + } + }, { + q: [ + ['foo', 'bar'], + ['piyo'], + ], + }); - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - let piyoCount = 0; + post(chitose, { + text: '#foo', + }); - const ws = await connectStream(me, 'hashtag', ({ type, body }) => { - if (type == 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - if (body.text === '#piyo') piyoCount++; - } - }, { - q: [ - ['foo'], - ['bar'], - ], - }); + post(chitose, { + text: '#bar', + }); - post(me, { - text: '#foo', - }); + post(chitose, { + text: '#foo #bar', + }); - post(me, { - text: '#bar', - }); + post(chitose, { + text: '#piyo', + }); - post(me, { - text: '#foo #bar', - }); + post(chitose, { + text: '#waaa', + }); - post(me, { - text: '#piyo', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 1); - assert.strictEqual(barCount, 1); - assert.strictEqual(fooBarCount, 1); - assert.strictEqual(piyoCount, 0); - ws.close(); - done(); - }, 3000); - })); - - it('指定したハッシュタグの投稿が流れる (AND + OR)', () => new Promise(async done => { - const me = await signup(); - - let fooCount = 0; - let barCount = 0; - let fooBarCount = 0; - let piyoCount = 0; - let waaaCount = 0; - - const ws = await connectStream(me, 'hashtag', ({ type, body }) => { - if (type == 'note') { - if (body.text === '#foo') fooCount++; - if (body.text === '#bar') barCount++; - if (body.text === '#foo #bar') fooBarCount++; - if (body.text === '#piyo') piyoCount++; - if (body.text === '#waaa') waaaCount++; - } - }, { - q: [ - ['foo', 'bar'], - ['piyo'], - ], - }); - - post(me, { - text: '#foo', - }); - - post(me, { - text: '#bar', - }); - - post(me, { - text: '#foo #bar', - }); - - post(me, { - text: '#piyo', - }); - - post(me, { - text: '#waaa', - }); - - setTimeout(() => { - assert.strictEqual(fooCount, 0); - assert.strictEqual(barCount, 0); - assert.strictEqual(fooBarCount, 1); - assert.strictEqual(piyoCount, 1); - assert.strictEqual(waaaCount, 0); - ws.close(); - done(); - }, 3000); - })); + setTimeout(() => { + assert.strictEqual(fooCount, 0); + assert.strictEqual(barCount, 0); + assert.strictEqual(fooBarCount, 1); + assert.strictEqual(piyoCount, 1); + assert.strictEqual(waaaCount, 0); + ws.close(); + done(); + }, 3000); + })); + }); }); }); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 0ee15067d..245cf858d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -186,7 +186,7 @@ export function connectStream(user: any, channel: string, listener: (message: Re }); } -export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean) => { +export const waitFire = async (user: any, channel: string, trgr: () => any, cond: (msg: Record) => boolean, params?: any) => { return new Promise(async (res, rej) => { let timer: NodeJS.Timeout; @@ -198,7 +198,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond if (timer) clearTimeout(timer); res(true); } - }); + }, params); } catch (e) { rej(e); } @@ -208,7 +208,7 @@ export const waitFire = async (user: any, channel: string, trgr: () => any, cond timer = setTimeout(() => { ws.close(); res(false); - }, 5000); + }, 3000); try { await trgr(); diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock index 880bbf7d1..3c9f2680f 100644 --- a/packages/backend/yarn.lock +++ b/packages/backend/yarn.lock @@ -2485,6 +2485,11 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== +entities@^2.0.3: + version "2.2.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" + integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== + entities@^4.3.0: version "4.3.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.0.tgz#62915f08d67353bb4eb67e3d62641a4059aec656" @@ -5985,6 +5990,14 @@ rndstr@1.0.0: rangestr "0.0.1" seedrandom "2.4.2" +rss-parser@3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/rss-parser/-/rss-parser-3.12.0.tgz#b8888699ea46304a74363fbd8144671b2997984c" + integrity sha512-aqD3E8iavcCdkhVxNDIdg1nkBI17jgqF+9OqPS1orwNaOgySdpvq6B+DoONLhzjzwV8mWg37sb60e4bmLK117A== + dependencies: + entities "^2.0.3" + xml2js "^0.4.19" + run-parallel@^1.1.9: version "1.1.9" resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679" @@ -7174,7 +7187,7 @@ xml2js@0.4.19: sax ">=0.6.0" xmlbuilder "~9.0.1" -xml2js@^0.4.23: +xml2js@^0.4.19, xml2js@^0.4.23: version "0.4.23" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js index 981b08d74..a5a4fd0f4 100644 --- a/packages/client/.eslintrc.js +++ b/packages/client/.eslintrc.js @@ -22,9 +22,8 @@ module.exports = { }, ], // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // data の禁止理由: 抽象的すぎるため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'data', 'e'], + 'id-denylist': ['error', 'window', 'e'], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { 'alphabetical': false, diff --git a/packages/client/package.json b/packages/client/package.json index 5a087a297..b810abd08 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -76,7 +76,6 @@ "vanilla-tilt": "1.7.2", "vite": "3.0.0-beta.5", "vue": "3.2.37", - "vue-marquee-text-component": "2.0.1", "vue-prism-editor": "2.0.0-alpha.2", "vuedraggable": "4.0.1", "websocket": "1.0.34", diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index eb2ba0a1e..38f2ee4b3 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -17,6 +17,7 @@ const accountData = localStorage.getItem('account'); export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmAdmin = $i != null && $i.isAdmin; export async function signout() { waiting(); diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue index b346585ce..16c77f726 100644 --- a/packages/client/src/components/drive-file-thumbnail.vue +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -59,7 +59,7 @@ const isThumbnailAvailable = computed(() => { display: flex; background: var(--panel); border-radius: 8px; - overflow: clip; + overflow: hidden; overflow: clip; > .icon-sub { position: absolute; diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue index 345001c43..5fd9ec460 100644 --- a/packages/client/src/components/form-dialog.vue +++ b/packages/client/src/components/form-dialog.vue @@ -41,7 +41,7 @@ - + diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index d46174acc..f87b92183 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -6,7 +6,7 @@
-
+
@@ -27,6 +27,7 @@ const props = withDefaults(defineProps<{ max: number; step?: number; textConverter?: (value: number) => string, + showTicks?: boolean; }>(), { step: 1, textConverter: (v) => v.toString(), @@ -188,7 +189,7 @@ const onMousedown = (ev: MouseEvent | TouchEvent) => { height: 3px; background: rgba(0, 0, 0, 0.1); border-radius: 999px; - overflow: clip; + overflow: hidden; overflow: clip; > .highlight { position: absolute; diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue index 5395a8796..e6917611f 100644 --- a/packages/client/src/components/global/page-header.vue +++ b/packages/client/src/components/global/page-header.vue @@ -172,11 +172,8 @@ onUnmounted(() => { diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue index a78b49965..be7214db1 100644 --- a/packages/client/src/components/note-preview.vue +++ b/packages/client/src/components/note-preview.vue @@ -27,7 +27,7 @@ const props = defineProps<{ display: flex; margin: 0; padding: 0; - overflow: clip; + overflow: hidden; overflow: clip; font-size: 0.95em; &.min-width_350px { diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue index b813b9a2b..93c34b6bf 100644 --- a/packages/client/src/components/note-simple.vue +++ b/packages/client/src/components/note-simple.vue @@ -36,7 +36,7 @@ const showContent = $ref(false); display: flex; margin: 0; padding: 0; - overflow: clip; + overflow: hidden; overflow: clip; font-size: 0.95em; &.min-width_350px { diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index c2c92f541..98c5c9a67 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -297,7 +297,7 @@ function readPromo() { position: relative; transition: box-shadow 0.1s ease; font-size: 1.05em; - overflow: clip; + overflow: hidden; overflow: clip; contain: content; // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue index 7de09d3be..5b06c7718 100644 --- a/packages/client/src/components/page-window.vue +++ b/packages/client/src/components/page-window.vue @@ -48,7 +48,10 @@ const router = new Router(routes, props.initialPath); let pageMetadata = $ref>(); let windowEl = $ref>(); -const history = $ref([props.initialPath]); +const history = $ref<{ path: string; key: any; }[]>([{ + path: router.getCurrentPath(), + key: router.getCurrentKey(), +}]); const buttonsLeft = $computed(() => { const buttons = []; @@ -72,7 +75,7 @@ const buttonsRight = $computed(() => { }); router.addListener('push', ctx => { - history.push(router.getCurrentPath()); + history.push({ path: ctx.path, key: ctx.key }); }); provide('router', router); @@ -111,7 +114,7 @@ function menu(ev) { function back() { history.pop(); - router.change(history[history.length - 1]); + router.change(history[history.length - 1].path, history[history.length - 1].key); } function close() { @@ -136,5 +139,6 @@ defineExpose({ diff --git a/packages/client/src/components/tag-cloud.vue b/packages/client/src/components/tag-cloud.vue index 8df8d0b05..5ffa7321e 100644 --- a/packages/client/src/components/tag-cloud.vue +++ b/packages/client/src/components/tag-cloud.vue @@ -1,5 +1,5 @@