diff --git a/CHANGELOG.md b/CHANGELOG.md index 9390b0e27..843cdb300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,17 +11,31 @@ You should also include the user name that made the change. ## 12.x.x (unreleased) +### Changes +- ハイライトがみつけるに統合されました +- カスタム絵文字ページはインスタンス情報ページに統合されました +- 連合ページはインスタンス情報ページに統合されました + ### Improvements - Server: Allow GET method for some endpoints @syuilo - Server: Add rate limit to i/notifications @tamaina - 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-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 -- Client: Removing entries from a clip @futchitwo +- 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 +- Add additional drive capacity change support @CyberRex0 - 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 @@ -30,6 +44,10 @@ You should also include the user name that made the change. - 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 f81338922..de5826b21 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -203,6 +203,7 @@ done: "完了" processing: "処理中" preview: "プレビュー" default: "デフォルト" +defaultValueIs: "デフォルト: {value}" noCustomEmojis: "絵文字はありません" noJobs: "ジョブはありません" federating: "連合中" @@ -381,6 +382,7 @@ administrator: "管理者" token: "トークン" twoStepAuthentication: "二段階認証" moderator: "モデレーター" +moderation: "モデレーション" nUsersMentioned: "{n}人が投稿" securityKey: "セキュリティキー" securityKeyName: "キーの名前" @@ -541,7 +543,7 @@ relays: "リレー" addRelay: "リレーの追加" inboxUrl: "inboxのURL" addedRelays: "追加済みのリレー" -serviceworkerInfo: "プッシュ通知を行うには有効する必要があります。" +serviceworkerInfo: "プッシュ通知を行うには有効にする必要があります。" deletedNote: "削除された投稿" invisibleNote: "非公開の投稿" enableInfiniteScroll: "自動でもっと見る" @@ -854,9 +856,19 @@ noEmailServerWarning: "メールサーバーの設定がされていません。 thereIsUnresolvedAbuseReportWarning: "未対応の通報があります。" recommended: "推奨" check: "チェック" +driveCapOverrideLabel: "このユーザーのドライブ容量上限を変更" +driveCapOverrideCaption: "0以下を指定すると解除されます。" +requireAdminForView: "閲覧するには管理者アカウントでログインしている必要があります。" isSystemAccount: "システムにより自動で作成・管理されているアカウントです。" typeToConfirm: "この操作を行うには {x} と入力してください" deleteAccount: "アカウント削除" +document: "ドキュメント" +numberOfPageCache: "ページキャッシュ数" +numberOfPageCacheDescription: "多くすると利便性が向上しますが、負荷とメモリ使用量が増えます。" +logoutConfirm: "ログアウトしますか?" +lastActiveDate: "最終利用日時" +statusbar: "ステータスバー" +pleaseSelect: "選択してください" _emailUnavailable: used: "既に使用されています" @@ -1242,10 +1254,12 @@ _widgets: trends: "トレンド" clock: "時計" rss: "RSSリーダー" + rssTicker: "RSSティッカー" activity: "アクティビティ" photos: "フォト" digitalClock: "デジタル時計" federation: "連合" + instanceCloud: "インスタンスクラウド" postForm: "投稿フォーム" slideshow: "スライドショー" button: "ボタン" @@ -1710,8 +1724,6 @@ _notification: _deck: alwaysShowMainColumn: "常にメインカラムを表示" columnAlign: "カラムの寄せ" - columnMargin: "カラム間のマージン" - columnHeaderHeight: "カラムのヘッダー幅" addColumn: "カラムを追加" swapLeft: "左に移動" swapRight: "右に移動" @@ -1720,6 +1732,9 @@ _deck: stackLeft: "左に重ねる" popRight: "右に出す" profile: "プロファイル" + introduction: "カラムを組み合わせて自分だけのインターフェイスを作りましょう!" + introduction2: "画面の右にある + を押して、いつでもカラムを追加できます。" + widgetsIntroduction: "カラムのメニューから、「ウィジェットの編集」を選択してウィジェットを追加してください" _columns: main: "メイン" diff --git a/package.json b/package.json index 62ee80de9..e7998e115 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "misskey", - "version": "12.112.0-beta.7", + "version": "12.112.0-beta.16", "codename": "indigo", "repository": { "type": "git", @@ -48,13 +48,13 @@ "@types/gulp": "4.0.9", "@types/gulp-rename": "2.0.1", "@typescript-eslint/eslint-plugin": "latest", - "@typescript-eslint/parser": "5.27.1", + "@typescript-eslint/parser": "5.30.0", "cross-env": "7.0.3", - "cypress": "10.0.3", + "cypress": "10.3.0", "eslint-plugin-import": "^2.26.0", "eslint-plugin-vue": "latest", "start-server-and-test": "1.14.0", - "typescript": "4.7.3", + "typescript": "4.7.4", "vue-eslint-parser": "^9.0.2" } } diff --git a/packages/backend/migration/1655813815729-driveCapacityOverrideMb.js b/packages/backend/migration/1655813815729-driveCapacityOverrideMb.js new file mode 100644 index 000000000..f257cd112 --- /dev/null +++ b/packages/backend/migration/1655813815729-driveCapacityOverrideMb.js @@ -0,0 +1,13 @@ +export class driveCapacityOverrideMb1655813815729 { + name = 'driveCapacityOverrideMb1655813815729' + + async up(queryRunner) { + await queryRunner.query(`ALTER TABLE "user" ADD "driveCapacityOverrideMb" integer`); + await queryRunner.query(`COMMENT ON COLUMN "user"."driveCapacityOverrideMb" IS 'Overrides user drive capacity limit'`); + } + + async down(queryRunner) { + await queryRunner.query(`COMMENT ON COLUMN "user"."driveCapacityOverrideMb" IS 'Overrides user drive capacity limit'`); + await queryRunner.query(`ALTER TABLE "user" DROP COLUMN "driveCapacityOverrideMb"`); + } +} 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 cf10ece1a..5209273c1 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -18,9 +18,9 @@ "lodash": "^4.17.21" }, "dependencies": { - "@bull-board/api": "^3.11.1", - "@bull-board/koa": "3.10.4", - "@bull-board/ui": "^3.11.1", + "@bull-board/api": "4.0.0", + "@bull-board/koa": "4.0.0", + "@bull-board/ui": "4.0.0", "@discordapp/twemoji": "14.0.2", "@elastic/elasticsearch": "7.17.0", "@koa/cors": "3.3.0", @@ -34,10 +34,10 @@ "archiver": "5.3.1", "autobind-decorator": "2.4.0", "autwh": "0.1.0", - "aws-sdk": "2.1152.0", + "aws-sdk": "2.1165.0", "bcryptjs": "2.4.3", "blurhash": "1.1.5", - "bull": "4.8.3", + "bull": "4.8.4", "cacheable-lookup": "6.0.4", "cbor": "8.1.0", "chalk": "5.0.1", @@ -57,7 +57,7 @@ "ip-cidr": "3.0.10", "is-svg": "4.3.2", "js-yaml": "4.1.0", - "jsdom": "19.0.0", + "jsdom": "20.0.0", "json5": "2.2.1", "json5-loader": "4.0.1", "jsonld": "6.0.0", @@ -79,26 +79,27 @@ "multer": "1.4.4", "nested-property": "4.0.0", "node-fetch": "3.2.6", - "nodemailer": "6.7.5", + "nodemailer": "6.7.6", "oauth": "^0.9.15", "os-utils": "0.0.14", - "parse5": "6.0.1", + "parse5": "7.0.0", "pg": "8.7.3", "private-ip": "2.3.3", "probe-image-size": "7.2.3", "promise-limit": "2.7.0", "pug": "3.0.2", "punycode": "2.1.1", - "pureimage": "0.3.8", + "pureimage": "0.3.14", "qrcode": "1.5.0", "random-seed": "0.3.0", "ratelimiter": "3.4.1", - "re2": "1.17.4", + "re2": "1.17.7", "redis-lock": "0.1.4", "reflect-metadata": "0.1.13", "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", @@ -109,15 +110,15 @@ "style-loader": "3.3.1", "summaly": "2.6.0", "syslog-pro": "1.0.0", - "systeminformation": "5.11.16", + "systeminformation": "5.11.22", "tinycolor2": "1.4.2", "tmp": "0.2.1", - "ts-loader": "9.3.0", + "ts-loader": "9.3.1", "ts-node": "10.8.1", - "tsc-alias": "1.6.9", + "tsc-alias": "1.6.11", "tsconfig-paths": "4.0.0", "twemoji-parser": "14.0.0", - "typeorm": "0.3.6", + "typeorm": "0.3.7", "ulid": "2.3.0", "unzipper": "0.10.11", "uuid": "8.3.2", @@ -150,11 +151,10 @@ "@types/koa__multer": "2.0.4", "@types/koa__router": "8.0.11", "@types/mocha": "9.1.1", - "@types/node": "17.0.41", + "@types/node": "18.0.0", "@types/node-fetch": "3.0.3", "@types/nodemailer": "6.4.4", "@types/oauth": "0.9.1", - "@types/parse5": "6.0.3", "@types/pug": "2.0.6", "@types/punycode": "2.1.0", "@types/qrcode": "1.4.2", @@ -163,8 +163,8 @@ "@types/redis": "4.0.11", "@types/rename": "1.0.4", "@types/sanitize-html": "2.6.2", - "@types/semver": "7.3.9", - "@types/sharp": "0.30.2", + "@types/semver": "7.3.10", + "@types/sharp": "0.30.4", "@types/sinonjs__fake-timers": "8.1.2", "@types/speakeasy": "2.0.7", "@types/tinycolor2": "1.4.3", @@ -173,13 +173,13 @@ "@types/web-push": "3.3.2", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", - "@typescript-eslint/eslint-plugin": "5.27.1", - "@typescript-eslint/parser": "5.27.1", + "@typescript-eslint/eslint-plugin": "5.30.0", + "@typescript-eslint/parser": "5.30.0", "cross-env": "7.0.3", - "eslint": "8.17.0", + "eslint": "8.18.0", "eslint-plugin-import": "2.26.0", "execa": "6.1.0", "form-data": "^4.0.0", - "typescript": "4.7.2" + "typescript": "4.7.4" } } 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/mfm/from-html.ts b/packages/backend/src/mfm/from-html.ts index 15110b6b7..7751bac56 100644 --- a/packages/backend/src/mfm/from-html.ts +++ b/packages/backend/src/mfm/from-html.ts @@ -1,8 +1,10 @@ -import * as parse5 from 'parse5'; -import treeAdapter from 'parse5/lib/tree-adapters/default.js'; import { URL } from 'node:url'; +import * as parse5 from 'parse5'; +import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js'; -const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; +const treeAdapter = TreeAdapter.defaultTreeAdapter; + +const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/; const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/; export function fromHtml(html: string, hashtagNames?: string[]): string { @@ -19,7 +21,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { return text.trim(); - function getText(node: parse5.Node): string { + function getText(node: TreeAdapter.Node): string { if (treeAdapter.isTextNode(node)) return node.value; if (!treeAdapter.isElementNode(node)) return ''; if (node.nodeName === 'br') return '\n'; @@ -31,7 +33,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { return ''; } - function appendChildren(childNodes: parse5.ChildNode[]): void { + function appendChildren(childNodes: TreeAdapter.ChildNode[]): void { if (childNodes) { for (const n of childNodes) { analyze(n); @@ -39,7 +41,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { } } - function analyze(node: parse5.Node) { + function analyze(node: TreeAdapter.Node) { if (treeAdapter.isTextNode(node)) { text += node.value; return; @@ -170,7 +172,7 @@ export function fromHtml(html: string, hashtagNames?: string[]): string { const t = getText(node); if (t) { text += '\n> '; - text += t.split('\n').join(`\n> `); + text += t.split('\n').join('\n> '); } break; } 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/entities/user.ts b/packages/backend/src/models/entities/user.ts index df92fb825..bc9446be4 100644 --- a/packages/backend/src/models/entities/user.ts +++ b/packages/backend/src/models/entities/user.ts @@ -218,6 +218,12 @@ export class User { }) public token: string | null; + @Column('integer', { + nullable: true, + comment: 'Overrides user drive capacity limit', + }) + public driveCapacityOverrideMb: number | null; + constructor(data: Partial) { if (data == null) return; 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/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 8a4e48efd..645091395 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -315,6 +315,7 @@ export const UserRepository = db.getRepository(User).extend({ } : undefined) : undefined, emojis: populateEmojis(user.emojis, user.host), onlineStatus: this.getOnlineStatus(user), + driveCapacityOverrideMb: user.driveCapacityOverrideMb, ...(opts.detail ? { url: profile!.url, 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/common/generate-muted-user-query.ts b/packages/backend/src/server/api/common/generate-muted-user-query.ts index e276ff2bd..470ece1a6 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 @@ -51,26 +51,7 @@ 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() })`) - // 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.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`); 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 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..4644f34d9 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,8 @@ 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'; +import * as ep___admin_driveCapOverride from './endpoints/admin/drive-capacity-override.js'; const eps = [ ['admin/meta', ep___admin_meta], @@ -348,6 +352,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 +378,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 +630,8 @@ const eps = [ ['users/search', ep___users_search], ['users/show', ep___users_show], ['users/stats', ep___users_stats], + ['admin/drive-capacity-override', ep___admin_driveCapOverride], + ['fetch-rss', ep___fetchRss], ]; export interface IEndpointMeta { diff --git a/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts new file mode 100644 index 000000000..a4b29770e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/admin/drive-capacity-override.ts @@ -0,0 +1,47 @@ +import define from '../../define.js'; +import { Users } from '@/models/index.js'; +import { User } from '@/models/entities/user.js'; +import { insertModerationLog } from '@/services/insert-moderation-log.js'; +export const meta = { + tags: ['admin'], + + requireCredential: true, + requireModerator: true, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + overrideMb: { type: 'number', nullable: true }, + }, + required: ['userId', 'overrideMb'], +} 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'); + } + + if (!Users.isLocalUser(user)) { + throw new Error('user is not local user'); + } + + /*if (user.isAdmin) { + throw new Error('cannot suspend admin'); + } + if (user.isModerator) { + throw new Error('cannot suspend moderator'); + }*/ + + await Users.update(user.id, { + driveCapacityOverrideMb: ps.overrideMb, + }); + + insertModerationLog(me, 'change-drive-capacity-override', { + targetId: user.id, + }); +}); 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.ts b/packages/backend/src/server/api/endpoints/drive.ts index 47e940cdd..82497adef 100644 --- a/packages/backend/src/server/api/endpoints/drive.ts +++ b/packages/backend/src/server/api/endpoints/drive.ts @@ -39,7 +39,7 @@ export default define(meta, paramDef, async (ps, user) => { const usage = await DriveFiles.calcDriveUsageOf(user.id); return { - capacity: 1024 * 1024 * instance.localDriveCapacityMb, + capacity: 1024 * 1024 * (user.driveCapacityOverrideMb || instance.localDriveCapacityMb), usage: usage, }; }); 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 d3c265908..e02c7b97e 100644 --- a/packages/backend/src/server/api/endpoints/federation/stats.ts +++ b/packages/backend/src/server/api/endpoints/federation/stats.ts @@ -15,6 +15,7 @@ export const meta = { export const paramDef = { type: 'object', properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, }, required: [], } as const; @@ -29,7 +30,7 @@ export default define(meta, paramDef, async (ps) => { order: { followersCount: 'DESC', }, - take: 10, + take: ps.limit, }), Instances.find({ where: { @@ -38,7 +39,7 @@ export default define(meta, paramDef, async (ps) => { order: { followingCount: 'DESC', }, - take: 10, + take: ps.limit, }), Followings.count({ where: { @@ -53,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/server/api/endpoints/users.ts b/packages/backend/src/server/api/endpoints/users.ts index 2377faebd..3a8211374 100644 --- a/packages/backend/src/server/api/endpoints/users.ts +++ b/packages/backend/src/server/api/endpoints/users.ts @@ -27,6 +27,12 @@ export const paramDef = { sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] }, state: { type: 'string', enum: ['all', 'admin', 'moderator', 'adminOrModerator', 'alive'], default: 'all' }, origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' }, + hostname: { + type: 'string', + nullable: true, + default: null, + description: 'The local host is represented with `null`.', + }, }, required: [], } as const; @@ -48,6 +54,10 @@ export default define(meta, paramDef, async (ps, me) => { case 'remote': query.andWhere('user.host IS NOT NULL'); break; } + if (ps.hostname) { + query.andWhere('user.host = :hostname', { hostname: ps.hostname.toLowerCase() }); + } + switch (ps.sort) { case '+follower': query.orderBy('user.followersCount', 'DESC'); break; case '-follower': query.orderBy('user.followersCount', 'ASC'); break; 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 8bb927987..f01f47723 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -51,7 +51,7 @@ export default class extends Channel { } // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する - if (iUserRelated(note, this.muting)) return; + if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; diff --git a/packages/backend/src/services/drive/add-file.ts b/packages/backend/src/services/drive/add-file.ts index cfbcb60dd..0dfad11cf 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(); @@ -307,7 +307,7 @@ async function deleteOldFile(user: IRemoteUser) { type AddFileArgs = { /** User who wish to add file */ - user: { id: User['id']; host: User['host'] } | null; + user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null; /** File path */ path: string; /** Name */ @@ -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)}`); @@ -366,9 +371,16 @@ export async function addFile({ //#region Check drive usage if (user && !isLink) { const usage = await DriveFiles.calcDriveUsageOf(user); + const u = await Users.findOneBy({ id: user.id }); const instance = await fetchMeta(); - const driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); + let driveCapacity = 1024 * 1024 * (Users.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb); + + if (Users.isLocalUser(user) && u?.driveCapacityOverrideMb != null) { + driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb; + logger.debug('drive capacity override applied'); + logger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`); + } logger.debug(`drive usage is ${usage} (max: ${driveCapacity})`); @@ -427,11 +439,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/client/.eslintrc.js b/packages/client/.eslintrc.js index 902214348..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, @@ -69,6 +68,7 @@ module.exports = { // Vue '$$': false, '$ref': false, + '$shallowRef': false, '$computed': false, // Misskey diff --git a/packages/client/assets/tagcanvas.min.js b/packages/client/assets/tagcanvas.min.js new file mode 100644 index 000000000..bcee46e68 --- /dev/null +++ b/packages/client/assets/tagcanvas.min.js @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2010-2021 Graham Breach + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ +/** + * TagCanvas 2.11 + * For more information, please contact + */ + (function(){"use strict";var r,C,p=Math.abs,o=Math.sin,l=Math.cos,g=Math.max,h=Math.min,af=Math.ceil,E=Math.sqrt,w=Math.pow,I={},D={},R={0:"0,",1:"17,",2:"34,",3:"51,",4:"68,",5:"85,",6:"102,",7:"119,",8:"136,",9:"153,",a:"170,",A:"170,",b:"187,",B:"187,",c:"204,",C:"204,",d:"221,",D:"221,",e:"238,",E:"238,",f:"255,",F:"255,"},f,d,b,T,z,F,M,c=document,v,e,P,j={};for(r=0;r<256;++r)C=r.toString(16),r<16&&(C='0'+C),D[C]=D[C.toUpperCase()]=r.toString()+',';function n(a){return typeof a!='undefined'}function B(a){return typeof a=='object'&&a!=null}function G(a,c,b){return isNaN(a)?b:h(b,g(c,a))}function x(){return!1}function q(){return(new Date).valueOf()}function ak(c,d){var b=[],e=c.length,a;for(a=0;a=1)?0:a<=-1?Math.PI:Math.acos(a)},z.unit=function(){var a=this.length();return new s(this.x/a,this.y/a,this.z/a)};function ay(b,a){a=a*Math.PI/180,b=b*Math.PI/180;var c=o(b)*l(a),d=-o(a),e=-l(b)*l(a);return new s(c,d,e)}function m(a){this[1]={1:a[0],2:a[1],3:a[2]},this[2]={1:a[3],2:a[4],3:a[5]},this[3]={1:a[6],2:a[7],3:a[8]}}T=m.prototype,m.Identity=function(){return new m([1,0,0,0,1,0,0,0,1])},m.Rotation=function(e,a){var c=o(e),d=l(e),b=1-d;return new m([d+w(a.x,2)*b,a.x*a.y*b-a.z*c,a.x*a.z*b+a.y*c,a.y*a.x*b+a.z*c,d+w(a.y,2)*b,a.y*a.z*b-a.x*c,a.z*a.x*b-a.y*c,a.z*a.y*b+a.x*c,d+w(a.z,2)*b])},T.mul=function(c){var d=[],a,b,e=c.xform?1:0;for(a=1;a<=3;++a)for(b=1;b<=3;++b)e?d.push(this[a][1]*c[1][b]+this[a][2]*c[2][b]+this[a][3]*c[3][b]):d.push(this[a][b]*c);return new m(d)},T.xform=function(b){var a={},c=b.x,d=b.y,e=b.z;return a.x=c*this[1][1]+d*this[2][1]+e*this[3][1],a.y=c*this[1][2]+d*this[2][2]+e*this[3][2],a.z=c*this[1][3]+d*this[2][3]+e*this[3][3],a};function aB(g,j,k,m,f){var a,b,c,d,e=[],h=2/g,i;i=Math.PI*(3-E(5)+(parseFloat(f)?parseFloat(f):0));for(a=0;a0)}function aC(a,c,f,d){var e=a.createLinearGradient(0,0,c,0),b;for(b in d)e.addColorStop(1-b,d[b]);a.fillStyle=e,a.fillRect(0,f,c,1)}function L(a,m,j){var l=1024,d=1,e=a.weightGradient,i,f,b,c;if(a.gCanvas)f=a.gCanvas.getContext('2d'),d=a.gCanvas.height;else{if(B(e[0])?d=e.length:e=[e],a.gCanvas=i=k(l,d),!i)return null;f=i.getContext('2d');for(b=0;b0?b=i*b/100:b=b*j,a=e.getContext('2d'),a.globalCompositeOperation='source-over',a.fillStyle='#fff',b>=i/2?(b=h(c,d)/2,a.beginPath(),a.moveTo(c/2,d/2),a.arc(c/2,d/2,b,0,2*Math.PI,!1),a.fill(),a.closePath()):(b=h(c/2,d/2,b),y(a,0,0,c,d,b,!0),a.fill()),a.globalCompositeOperation='source-in',a.drawImage(l,0,0,c,d),e)}function ao(q,m,i,b,h,a,c){var g=p(c[0]),f=p(c[1]),j=m+(g>a?g+a:a*2)*b,l=i+(f>a?f+a:a*2)*b,n=b*((a||0)+(c[0]<0?g:0)),o=b*((a||0)+(c[1]<0?f:0)),e,d;return e=k(j,l),!e?null:(d=e.getContext('2d'),h&&(d.shadowColor=h),a&&(d.shadowBlur=a*b),c&&(d.shadowOffsetX=c[0]*b,d.shadowOffsetY=c[1]*b),d.drawImage(q,n,o,m,i),{image:e,width:j/b,height:l/b})}function ae(m,o,l){var c=parseInt(m.toString().length*l),h=parseInt(l*2*m.length),j=k(c,h),g,i,e,f,b,d,n,a;if(!j)return null;g=j.getContext('2d'),g.fillStyle='#000',g.fillRect(0,0,c,h),Y(g,l+'px '+o,'#fff',m,0,0,0,0,[],'centre'),i=g.getImageData(0,0,c,h),e=i.width,f=i.height,a={min:{x:e,y:f},max:{x:-1,y:-1}};for(d=0;d0&&(ba.max.x&&(a.max.x=b),da.max.y&&(a.max.y=d));return e!=c&&(a.min.x*=c/e,a.max.x*=c/e),f!=h&&(a.min.y*=c/f,a.max.y*=c/f),j=null,a}function Q(a){return"'"+a.replace(/(\'|\")/g,'').replace(/\s*,\s*/g,"', '")+"'"}function t(b,d,a){a=a||c,a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent('on'+b,d)}function am(b,d,a){a=a||c,a.removeEventListener?a.removeEventListener(b,d):a.detachEvent('on'+b,d)}function A(g,e,j,a,b){var l=b.imageScale,h,c,k,m,f,d;if(!e.complete)return t('load',function(){A(g,e,j,a,b)},e);if(!g.complete)return t('load',function(){A(g,e,j,a,b)},g);if(j&&!j.complete)return t('load',function(){A(g,e,j,a,b)},j);e.width=e.width,e.height=e.height,l&&(g.width=e.width*l,g.height=e.height*l),a.iw=g.width,a.ih=g.height,b.txtOpt&&(c=g,h=b.zoomMax*b.txtScale,f=a.iw*h,d=a.ih*h,f0?(a.iw+=2*b.outlineIncrease,a.ih+=2*b.outlineIncrease,f=h*a.iw,d=h*a.ih,c=S(a.fimage,f,d),a.oimage=c,a.fimage=H(a.fimage,a.oimage.width,a.oimage.height)):(f=h*(a.iw+2*b.outlineIncrease),d=h*(a.ih+2*b.outlineIncrease),c=S(a.fimage,f,d),a.oimage=H(c,a.fimage.width,a.fimage.height))))),a.alt=j,a.Init()}function i(a,d){var b=c.defaultView,e=d.replace(/\-([a-z])/g,function(a){return a.charAt(1).toUpperCase()});return b&&b.getComputedStyle&&b.getComputedStyle(a,null).getPropertyValue(d)||a.currentStyle&&a.currentStyle[e]}function aj(c,d,e){var b=1,a;return d?b=1*(c.getAttribute(d)||e):(a=i(c,'font-size'))&&(b=a.indexOf('px')>-1&&a.replace('px','')*1||a.indexOf('pt')>-1&&a.replace('pt','')*1.25||a*3.3),b}function u(a){return a.target&&n(a.target.id)?a.target.id:a.srcElement.parentNode.id}function K(a,c){var b,d,e=parseInt(i(c,'width'))/c.width,f=parseInt(i(c,'height'))/c.height;return n(a.offsetX)?b={x:a.offsetX,y:a.offsetY}:(d=X(c.id),n(a.changedTouches)&&(a=a.changedTouches[0]),a.pageX&&(b={x:a.pageX-d.x,y:a.pageY-d.y})),b&&e&&f&&(b.x/=e,b.y/=f),b}function an(c){var d=c.target||c.fromElement.parentNode,b=a.tc[d.id];b&&(b.mx=b.my=-1,b.UnFreeze(),b.EndDrag())}function ad(e){var g,c=a,b,d,f=u(e);for(g in c.tc)b=c.tc[g],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);f&&c.tc[f]&&(b=c.tc[f],(d=K(e,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(e,d)),b.drawn=0)}function ap(b){var e=a,f=c.addEventListener?0:1,d=u(b);d&&b.button==f&&e.tc[d]&&e.tc[d].BeginDrag(b)}function aq(b){var f=a,g=c.addEventListener?0:1,e=u(b),d;e&&b.button==g&&f.tc[e]&&(d=f.tc[e],ad(b),!d.EndDrag()&&!d.touchState&&d.Clicked(b))}function ar(c){var e=u(c),b=e&&a.tc[e],d;b&&c.changedTouches&&(c.touches.length==1&&b.touchState==0?(b.touchState=1,b.BeginDrag(c),(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.drawn=0)):c.targetTouches.length==2&&b.pinchZoom?(b.touchState=3,b.EndDrag(),b.BeginPinch(c)):(b.EndDrag(),b.EndPinch(),b.touchState=0))}function ac(c){var d=u(c),b=d&&a.tc[d];if(b&&c.changedTouches){switch(b.touchState){case 1:b.Draw(),b.Clicked();break;break;case 2:b.EndDrag();break;case 3:b.EndPinch()}b.touchState=0}}function au(c){var f,e=a,b,d,g=u(c);for(f in e.tc)b=e.tc[f],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);if(b=g&&e.tc[g],b&&c.changedTouches&&b.touchState){switch(b.touchState){case 1:case 2:(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(c,d)&&(b.touchState=2));break;case 3:b.Pinch(c)}b.drawn=0}}function ab(b){var d=a,c=u(b);c&&d.tc[c]&&(b.cancelBubble=!0,b.returnValue=!1,b.preventDefault&&b.preventDefault(),d.tc[c].Wheel((b.wheelDelta||b.detail)>0))}function aw(d){var c,b=a;clearTimeout(b.scrollTimer);for(c in b.tc)b.tc[c].Pause();b.scrollTimer=setTimeout(function(){var b,c=a;for(b in c.tc)c.tc[b].Resume()},b.scrollPause)}function al(){Z(q())}function Z(b){var c=a.tc,d;a.NextFrame(a.interval),b=b||q();for(d in c)c[d].Draw(b)}function az(){requestAnimationFrame(Z)}function aA(a){setTimeout(al,a)}function X(f){var g=c.getElementById(f),b=g.getBoundingClientRect(),a=c.documentElement,d=c.body,e=window,h=e.pageXOffset||a.scrollLeft,i=e.pageYOffset||a.scrollTop,j=a.clientLeft||d.clientLeft,k=a.clientTop||d.clientTop;return{x:b.left+h-j,y:b.top+i-k}}function aI(a,b,d,e){var c=a.radius*a.z1/(a.z1+a.z2+b.z);return{x:b.x*c*d,y:b.y*c*e,z:b.z,w:(a.z1-b.z)/a.z2}}function V(a){this.e=a,this.br=0,this.line=[],this.text=[],this.original=a.innerText||a.textContent}F=V.prototype,F.Empty=function(){for(var a=0;ah?(d.push(this.line.join(' ')),this.line=[a[b]]):this.line.push(a[b]);d.push(this.line.join(' '))}return this.text=d};function _(a,b){this.ts=null,this.tc=a,this.tag=b,this.x=this.y=this.w=this.h=this.sc=1,this.z=0,this.pulse=1,this.pulsate=a.pulsateTo<1,this.colour=a.outlineColour,this.adash=~~a.outlineDash,this.agap=~~a.outlineDashSpace||this.adash,this.aspeed=a.outlineDashSpeed*1,this.colour=='tag'?this.colour=i(b.a,'color'):this.colour=='tagbg'&&(this.colour=i(b.a,'background-color')),this.Draw=this.pulsate?this.DrawPulsate:this.DrawSimple,this.radius=a.outlineRadius|0,this.SetMethod(a.outlineMethod,a.altImage)}f=_.prototype,f.SetMethod=function(a,d){var b={block:['PreDraw','DrawBlock'],colour:['PreDraw','DrawColour'],outline:['PostDraw','DrawOutline'],classic:['LastDraw','DrawOutline'],size:['PreDraw','DrawSize'],none:['LastDraw']},c=b[a]||b.outline;a=='none'?this.Draw=function(){return 1}:this.drawFunc=this[c[1]],this[c[0]]=this.Draw,d&&(this.RealPreDraw=this.PreDraw,this.PreDraw=this.DrawAlt)},f.Update=function(d,e,i,j,a,f,g,h){var b=this.tc.outlineOffset,c=2*b;this.x=a*d+g-b,this.y=a*e+h-b,this.w=a*i+c,this.h=a*j+c,this.sc=a,this.z=f},f.Ants=function(k){if(!this.adash)return;var b=this.adash,c=this.agap,a=this.aspeed,j=b+c,h=0,g=b,f=c,i=0,d=0,e;a&&(d=p(a)*(q()-this.ts)/50,a<0&&(d=864e4-d),a=~~d%j),a?(b>=a?(h=b-a,g=a):(f=j-a,i=c-f),e=[h,f,g,i]):e=[b,c],k.setLineDash(e)},f.DrawOutline=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.strokeStyle=f,this.Ants(a),y(a,d,e,b,c,g,!0)},f.DrawSize=function(i,n,m,l,k,j,a,h,g){var f=a.w,e=a.h,c,b,d;return this.pulsate?(a.image?d=(a.image.height+this.tc.outlineIncrease)/a.image.height:d=a.oscale,b=a.fimage||a.image,c=1+(d-1)*(1-this.pulse),a.h*=c,a.w*=c):b=a.oimage,a.alpha=1,a.Draw(i,h,g,b),a.h=e,a.w=f,1},f.DrawColour=function(d,h,i,e,f,g,a,b,c){return a.oimage?(this.pulse<1?(a.alpha=1-w(this.pulse,2),a.Draw(d,b,c,a.fimage),a.alpha=this.pulse):a.alpha=1,a.Draw(d,b,c,a.oimage),1):this[a.image?'DrawColourImage':'DrawColourText'](d,h,i,e,f,g,a,b,c)},f.DrawColourText=function(f,h,i,j,g,e,a,b,c){var d=a.colour;return a.colour=e,a.alpha=1,a.Draw(f,b,c),a.colour=d,1},f.DrawColourImage=function(a,q,p,o,n,m,i,r,l){var f=a.canvas,e=~~g(q,0),d=~~g(p,0),c=h(f.width-e,o)+.5|0,b=h(f.height-d,n)+.5|0,j;return v?(v.width=c,v.height=b):v=k(c,b),!v?this.SetMethod('outline'):(j=v.getContext('2d'),j.drawImage(f,e,d,c,b,0,0,c,b),a.clearRect(e,d,c,b),this.pulsate?i.alpha=1-w(this.pulse,2):i.alpha=1,i.Draw(a,r,l),a.setTransform(1,0,0,1,0,0),a.save(),a.beginPath(),a.rect(e,d,c,b),a.clip(),a.globalCompositeOperation='source-in',a.fillStyle=m,a.fillRect(e,d,c,b),a.restore(),a.globalAlpha=1,a.globalCompositeOperation='destination-over',a.drawImage(v,0,0,c,b,e,d,c,b),a.globalCompositeOperation='source-over',1)},f.DrawAlt=function(b,a,c,d,f,g){var e=this.RealPreDraw(b,a,c,d,f,g);return a.alt&&(a.DrawImage(b,c,d,a.alt),e=1),e},f.DrawBlock=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.fillStyle=f,y(a,d,e,b,c,g)},f.DrawSimple=function(a,b,c,d,e,f){var g=this.tc;return a.setTransform(1,0,0,1,0,0),a.strokeStyle=this.colour,a.lineWidth=g.outlineThickness,a.shadowBlur=a.shadowOffsetX=a.shadowOffsetY=0,a.globalAlpha=f?e:1,this.drawFunc(a,this.x,this.y,this.w,this.h,this.colour,b,c,d)},f.DrawPulsate=function(h,d,e,f){var g=q()-this.ts,c=this.tc,b=c.pulsateTo+(1-c.pulsateTo)*(.5+l(2*Math.PI*g/(1e3*c.pulsateTime))/2);return this.pulse=b=a.Smooth(1,b),this.DrawSimple(h,d,e,f,b,1)},f.Active=function(d,a,b){var c=a>=this.x&&b>=this.y&&a<=this.x+this.w&&b<=this.y+this.h;return c?this.ts=this.ts||q():this.ts=null,c},f.PreDraw=f.PostDraw=f.LastDraw=x;function J(a,h,c,b,e,f,g,d,i,j,k,l,m,n){this.tc=a,this.image=null,this.text=h,this.text_original=n,this.line_widths=[],this.title=c.title||null,this.a=c,this.position=new s(b[0],b[1],b[2]),this.x=this.y=this.z=0,this.w=e,this.h=f,this.colour=g||a.textColour,this.bgColour=d||a.bgColour,this.bgRadius=i|0,this.bgOutline=j||this.colour,this.bgOutlineThickness=k|0,this.textFont=l||a.textFont,this.padding=m|0,this.sc=this.alpha=1,this.weighted=!a.weight,this.outline=new _(a,this),this.audio=null}d=J.prototype,d.Init=function(b){var a=this.tc;this.textHeight=a.textHeight,this.HasText()?this.Measure(a.ctxt,a):(this.w=this.iw,this.h=this.ih),this.SetShadowColour=a.shadowAlpha?this.SetShadowColourAlpha:this.SetShadowColourFixed,this.SetDraw(a)},d.Draw=x,d.HasText=function(){return this.text&&this.text[0].length>0},d.EqualTo=function(a){var b=a.getElementsByTagName('img');return this.a.href!=a.href?0:b.length?this.image.src==b[0].src:(a.innerText||a.textContent)==this.text_original},d.SetImage=function(a){this.image=this.fimage=a},d.SetAudio=function(a){this.audio=a,this.audio.load()},d.SetDraw=function(a){this.Draw=this.fimage?a.ie>7?this.DrawImageIE:this.DrawImage:this.DrawText,a.noSelect&&(this.CheckActive=x)},d.MeasureText=function(d){var a,e=this.text.length,b=0,c;for(a=0;a0?c=H(c,this.oimage.width,this.oimage.height):this.oimage=H(this.oimage,c.width,c.height)),c&&(this.fimage=c,l=this.fimage.width/b,j=this.fimage.height/b),this.SetDraw(a),a.txtOpt=!!this.fimage),this.h=j,this.w=l},d.SetFont=function(a,b,c,d){this.textFont=a,this.colour=b,this.bgColour=c,this.bgOutline=d,this.Measure(this.tc.ctxt,this.tc)},d.SetWeight=function(c){var b=this.tc,e=b.weightMode.split(/[, ]/),d,a,f=c.length;if(!this.HasText())return;this.weighted=!0;for(a=0;a0&&a.weightSizeMax>a.weightSizeMin?this.textHeight=a.weightSize*(a.weightSizeMin+(a.weightSizeMax-a.weightSizeMin)*c):this.textHeight=g(1,b*a.weightSize))},d.SetShadowColourFixed=function(a,b,c){a.shadowColor=b},d.SetShadowColourAlpha=function(a,b,c){a.shadowColor=aE(b,c)},d.DrawText=function(a,h,i){var e=this.tc,g=this.x,f=this.y,c=this.sc,b,d;a.globalAlpha=this.alpha,a.fillStyle=this.colour,e.shadow&&this.SetShadowColour(a,e.shadow,this.alpha),a.font=this.font,g+=h/c,f+=i/c-this.h/2;for(b=0;b{this.stopped?this.audio.pause():this.playing=1}),1}};function a(f,o,k){var d,i,b=c.getElementById(f),l=['id','class','innerHTML'];if(!b)throw 0;if(n(window.G_vmlCanvasManager)&&(b=window.G_vmlCanvasManager.initElement(b),this.ie=parseFloat(navigator.appVersion.split('MSIE')[1])),b&&(!b.getContext||!b.getContext('2d').fillText)){i=c.createElement('DIV');for(d=0;d0?a.scrollPause=~~this.scrollPause:this.scrollPause=0,this.minTags>0&&this.repeatTags<1&&(d=this.GetTags().length)&&(this.repeatTags=af(this.minTags/d)-1),this.transform=m.Identity(),this.startTime=this.time=q(),this.mx=this.my=-1,this.centreImage&&av(this),this.Animate=this.dragControl?this.AnimateDrag:this.AnimatePosition,this.animTiming=typeof a[this.animTiming]=='function'?a[this.animTiming]:a.Smooth,this.shadowBlur||this.shadowOffset[0]||this.shadowOffset[1]?(this.ctxt.shadowColor=this.shadow,this.shadow=this.ctxt.shadowColor,this.shadowAlpha=aD()):delete this.shadow,this.activeAudio===!1?e='off':this.activeAudio&&this.LoadAudio(),this.Load(),o&&this.hideTags&&function(b){a.loaded?b.HideTags():t('load',function(){b.HideTags()},window)}(this),this.yaw=this.initial?this.initial[0]*this.maxSpeed:0,this.pitch=this.initial?this.initial[1]*this.maxSpeed:0,this.tooltip?(this.ctitle=b.title,b.title='',this.tooltip=='native'?this.Tooltip=this.TooltipNative:(this.Tooltip=this.TooltipDiv,this.ttdiv||(this.ttdiv=c.createElement('div'),this.ttdiv.className=this.tooltipClass,this.ttdiv.style.position='absolute',this.ttdiv.style.zIndex=b.style.zIndex+1,t('mouseover',function(a){a.target.style.display='none'},this.ttdiv),c.body.appendChild(this.ttdiv)))):this.Tooltip=this.TooltipNone,!this.noMouse&&!j[f]){j[f]=[['mousemove',ad],['mouseout',an],['mouseup',aq],['touchstart',ar],['touchend',ac],['touchcancel',ac],['touchmove',au]],this.dragControl&&(j[f].push(['mousedown',ap]),j[f].push(['selectstart',x])),this.wheelZoom&&(j[f].push(['mousewheel',ab]),j[f].push(['DOMMouseScroll',ab])),this.scrollPause&&j[f].push(['scroll',aw,window]);for(d=0;dthis.max_weight[a])&&(this.max_weight[a]=c),(!this.min_weight[a]||cthis.min_weight[a]&&(g=1);if(g)for(b=0;b=d&&this.my>=e)return!0},b.ToggleAudio=function(){var a=this.audioOff||e&&e.state==='suspended';a||this.currentAudio&&this.currentAudio.StopAudio(),this.audioOff=!a},b.Draw=function(s){if(this.paused)return;var l=this.canvas,i=l.width,j=l.height,q=0,p=(s-this.time)*a.interval/1e3,h=i/2+this.offsetX,g=j/2+this.offsetY,d=this.ctxt,b,f,c,o=-1,e=this.taglist,k=e.length,t=this.active&&this.active.tag,m='',u=this.frontSelect,r=this.centreFunc==x,n;if(this.time=s,this.frozen&&this.drawn)return this.Animate(i,j,p);n=this.AnimateFixed(),d.setTransform(1,0,0,1,0,0);for(c=0;c=0&&this.my>=0&&this.taglist[c].CheckActive(d,h,g),f&&f.sc>q&&(!u||f.z<=0)&&(b=f,o=c,b.tag=this.taglist[c],q=f.sc);this.active=b}this.txtOpt||this.shadow&&this.SetShadow(d),d.clearRect(0,0,i,j);for(c=0;c=this.fadeIn?(this.fadeIn=0,this.fixedAlpha=1):this.fixedAlpha=b/this.fadeIn),this.fixedAnim)&&(this.fixedAnim.transform||(this.fixedAnim.transform=this.transform),a=this.fixedAnim,b=q()-a.t0,c=a.angle,d,e=this.animTiming(a.t,b),this.transform=a.transform,b>=a.t?(this.fixedCallbackTag=a.tag,this.fixedCallback=a.cb,this.fixedAnim=this.yaw=this.pitch=0):c*=e,d=m.Rotation(c,a.axis),this.transform=this.transform.mul(d),this.fixedAnim!=0)},b.AnimatePosition=function(g,h,f){var a=this,d=a.mx,e=a.my,b,c;!a.frozen&&d>=0&&e>=0&&db&&(a.yaw=c>a.z0?a.yaw*a.decel:0),!a.ly&&d>b&&(a.pitch=d>a.z0?a.pitch*a.decel:0)},b.Zoom=function(a){this.z2=this.z1*(1/a),this.drawn=0},b.Clicked=function(b){if(this.CheckAudioIcon()){this.ToggleAudio();return}var a=this.active;try{a&&a.tag&&(this.clickToFront===!1||this.clickToFront===null?a.tag.Clicked(b):this.TagToFront(a.tag,this.clickToFront,function(){a.tag.Clicked(b)},!0))}catch(a){}},b.Wheel=function(a){var b=this.zoom+this.zoomStep*(a?1:-1);this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.BeginDrag=function(a){this.down=K(a,this.canvas),a.cancelBubble=!0,a.returnValue=!1,a.preventDefault&&a.preventDefault()},b.Drag=function(e,a){if(this.dragControl&&this.down){var d=this.dragThreshold*this.dragThreshold,b=a.x-this.down.x,c=a.y-this.down.y;(this.dragging||b*b+c*c>d)&&(this.dx=b,this.dy=c,this.dragging=1,this.down=a)}return this.dragging},b.EndDrag=function(){var a=this.dragging;return this.dragging=this.down=null,a};function ah(a){var b=a.targetTouches[0],c=a.targetTouches[1];return E(w(c.pageX-b.pageX,2)+w(c.pageY-b.pageY,2))}b.BeginPinch=function(a){this.pinched=[ah(a),this.zoom],a.preventDefault&&a.preventDefault()},b.Pinch=function(d){var b,c,a=this.pinched;if(!a)return;c=ah(d),b=a[1]*c/a[0],this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.EndPinch=function(a){this.pinched=null},b.Pause=function(){this.paused=!0},b.Resume=function(){this.paused=!1},b.SetSpeed=function(a){this.initial=a,this.yaw=a[0]*this.maxSpeed,this.pitch=a[1]*this.maxSpeed},b.FindTag=function(a){if(!n(a))return null;if(n(a.index)&&(a=a.index),!B(a))return this.taglist[a];var c,d,b;n(a.id)?(c='id',d=a.id):n(a.text)&&(c='innerText',d=a.text);for(b=0;b - +
- + -
{{ $t('aboutX', { x: instanceName }) }}
+
{{ $ts.instanceInfo }}
@@ -34,13 +34,14 @@ diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue index a810bd214..2fed0d35e 100644 --- a/packages/client/src/components/modal-page-window.vue +++ b/packages/client/src/components/modal-page-window.vue @@ -143,7 +143,6 @@ function onContextmenu(ev: MouseEvent) { background: var(--windowHeader); -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); - box-shadow: 0px 1px var(--divider); > button { height: $height; 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/object-view.value.vue b/packages/client/src/components/object-view.value.vue index 6f388636d..0c7230d78 100644 --- a/packages/client/src/components/object-view.value.vue +++ b/packages/client/src/components/object-view.value.vue @@ -1,31 +1,35 @@ @@ -66,6 +90,14 @@ export default defineComponent({ > .boolean { display: inline; color: var(--codeBoolean); + + &.true { + font-weight: bold; + } + + &.false { + opacity: 0.7; + } } > .string { @@ -78,7 +110,12 @@ export default defineComponent({ color: var(--codeNumber); } - > .array { + > .array.empty { + display: inline; + opacity: 0.7; + } + + > .array:not(.empty) { display: inline; > .element { @@ -87,13 +124,28 @@ export default defineComponent({ } } - > .object { + > .object.empty { + display: inline; + opacity: 0.7; + } + + > .object:not(.empty) { display: inline; > .kv { display: block; padding-left: 16px; + > .toggle { + width: 16px; + color: var(--accent); + visibility: hidden; + + &.visible { + visibility: visible; + } + } + > .k { display: inline; margin-right: 8px; diff --git a/packages/client/src/components/object-view.vue b/packages/client/src/components/object-view.vue index e9db96de8..db66049fc 100644 --- a/packages/client/src/components/object-view.vue +++ b/packages/client/src/components/object-view.vue @@ -4,26 +4,13 @@
- diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue index a06776237..58c43b22b 100644 --- a/packages/client/src/components/page/page.vue +++ b/packages/client/src/components/page/page.vue @@ -24,7 +24,6 @@ export default defineComponent({ }, }, setup(props, ctx) { - const hpml = new Hpml(props.page, { randomSeed: Math.random(), visitor: $i, diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue index d2b88f6bf..a068aca79 100644 --- a/packages/client/src/components/poll-editor.vue +++ b/packages/client/src/components/poll-editor.vue @@ -116,8 +116,11 @@ function get() { let base = parseInt(after.value); switch (unit.value) { case 'day': base *= 24; + // fallthrough case 'hour': base *= 60; + // fallthrough case 'minute': base *= 60; + // fallthrough case 'second': return base *= 1000; default: return null; } diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue index 91a90a699..ee40615f1 100644 --- a/packages/client/src/components/reactions-viewer.reaction.vue +++ b/packages/client/src/components/reactions-viewer.reaction.vue @@ -12,106 +12,81 @@ - diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue index c9fad64eb..e0230dccd 100644 --- a/packages/client/src/components/toast.vue +++ b/packages/client/src/components/toast.vue @@ -54,7 +54,7 @@ onMounted(() => { width: min-content; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); border-radius: 8px; - overflow: clip; + overflow: hidden; overflow: clip; text-align: center; pointer-events: none; diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue index e6b20d988..d8052b511 100644 --- a/packages/client/src/components/ui/button.vue +++ b/packages/client/src/components/ui/button.vue @@ -148,7 +148,7 @@ export default defineComponent({ text-decoration: none; background: var(--buttonBg); border-radius: 5px; - overflow: clip; + overflow: hidden; overflow: clip; box-sizing: border-box; transition: background 0.1s ease; diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue index 7c595d811..e14242827 100644 --- a/packages/client/src/components/ui/container.vue +++ b/packages/client/src/components/ui/container.vue @@ -10,7 +10,8 @@ - @@ -142,7 +143,7 @@ export default defineComponent({ .ukygtjoj { position: relative; - overflow: clip; + overflow: hidden; overflow: clip; &.naked { background: transparent !important; diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue index 6b075cb44..0cb5b4887 100644 --- a/packages/client/src/components/ui/hr.vue +++ b/packages/client/src/components/ui/hr.vue @@ -3,7 +3,8 @@ diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index dad5dfa8b..cb4ec7c34 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -136,11 +136,11 @@ function focusDown() { > .item { display: block; position: relative; - padding: 8px 18px; + padding: 6px 18px; width: 100%; box-sizing: border-box; white-space: nowrap; - font-size: 0.9em; + font-size: 0.85em; line-height: 20px; text-align: left; overflow: hidden; diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index bf9be971f..b7faea736 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -105,7 +105,6 @@ defineExpose({ background: var(--windowHeader); -webkit-backdrop-filter: var(--blur, blur(15px)); backdrop-filter: var(--blur, blur(15px)); - box-shadow: 0px 1px var(--divider); > button { height: $height; diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue index d6a29ec4b..385f6cdb2 100644 --- a/packages/client/src/components/ui/modal.vue +++ b/packages/client/src/components/ui/modal.vue @@ -389,7 +389,7 @@ defineExpose({ left: 0; width: 100%; height: 100%; - overflow: clip; + overflow: hidden; overflow: clip; > .content { position: fixed; diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index 8305cd1b2..6892b1924 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -99,12 +99,12 @@ export default defineComponent({ buttonsLeft: { type: Array, required: false, - default: [], + default: () => [], }, buttonsRight: { type: Array, required: false, - default: [], + default: () => [], }, }, @@ -410,6 +410,7 @@ export default defineComponent({ backdrop-filter: var(--blur, blur(15px)); //border-bottom: solid 1px var(--divider); font-size: 95%; + font-weight: bold; > .left, > .right { > .button { diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue index 74dd79f73..be84cdbf7 100644 --- a/packages/client/src/components/widgets.vue +++ b/packages/client/src/components/widgets.vue @@ -19,7 +19,9 @@
- +
+ +
@@ -141,6 +143,12 @@ export default defineComponent({ > .remove { right: 8px; } + + > .handle { + > .widget { + pointer-events: none; + } + } } } diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts index 2c4e9c188..76b54ea4b 100644 --- a/packages/client/src/directives/get-size.ts +++ b/packages/client/src/directives/get-size.ts @@ -34,7 +34,6 @@ function calc(src: Element) { export default { mounted(src, binding, vn) { - const resize = new ResizeObserver((entries, observer) => { calc(src); }); diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts index 5e281f4ea..677296a6f 100644 --- a/packages/client/src/menu.ts +++ b/packages/client/src/menu.ts @@ -35,11 +35,6 @@ export const menuDef = reactive({ indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), to: '/my/follow-requests', }, - featured: { - title: 'featured', - icon: 'fas fa-fire-alt', - to: '/featured', - }, explore: { title: 'explore', icon: 'fas fa-hashtag', @@ -81,12 +76,14 @@ export const menuDef = reactive({ os.popupMenu(items, ev.currentTarget ?? ev.target); }, }, + /* groups: { title: 'groups', icon: 'fas fa-users', show: computed(() => $i != null), to: '/my/groups', }, + */ antennas: { title: 'antennas', icon: 'fas fa-satellite', @@ -112,20 +109,6 @@ export const menuDef = reactive({ os.popupMenu(items, ev.currentTarget ?? ev.target); }, }, - mentions: { - title: 'mentions', - icon: 'fas fa-at', - show: computed(() => $i != null), - indicated: computed(() => $i != null && $i.hasUnreadMentions), - to: '/my/mentions', - }, - messages: { - title: 'directNotes', - icon: 'fas fa-envelope', - show: computed(() => $i != null), - indicated: computed(() => $i != null && $i.hasUnreadSpecifiedNotes), - to: '/my/messages', - }, favorites: { title: 'favorites', icon: 'fas fa-star', @@ -153,21 +136,6 @@ export const menuDef = reactive({ icon: 'fas fa-satellite-dish', to: '/channels', }, - federation: { - title: 'federation', - icon: 'fas fa-globe', - to: '/federation', - }, - emojis: { - title: 'emojis', - icon: 'fas fa-laugh', - to: '/emojis', - }, - scratchpad: { - title: 'scratchpad', - icon: 'fas fa-terminal', - to: '/scratchpad', - }, ui: { title: 'switchUi', icon: 'fas fa-columns', diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts index 6db633566..19c4464ea 100644 --- a/packages/client/src/nirax.ts +++ b/packages/client/src/nirax.ts @@ -2,12 +2,15 @@ import { EventEmitter } from 'eventemitter3'; import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue'; +import { pleaseLogin } from '@/scripts/please-login'; type RouteDef = { path: string; component: Component; query?: Record; + loginRequired?: boolean; name?: string; + hash?: string; globalCacheKey?: string; }; @@ -78,7 +81,12 @@ export class Router extends EventEmitter<{ public resolve(path: string): { route: RouteDef; props: Map; } | null { let queryString: string | null = null; + let hash: string | null = null; if (path[0] === '/') path = path.substring(1); + if (path.includes('#')) { + hash = path.substring(path.indexOf('#') + 1); + path = path.substring(0, path.indexOf('#')); + } if (path.includes('?')) { queryString = path.substring(path.indexOf('?') + 1); path = path.substring(0, path.indexOf('?')); @@ -127,6 +135,10 @@ export class Router extends EventEmitter<{ if (parts.length !== 0) continue forEachRouteLoop; + if (route.hash != null && hash != null) { + props.set(route.hash, hash); + } + if (route.query != null && queryString != null) { const queryObject = [...new URLSearchParams(queryString).entries()] .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); @@ -138,6 +150,7 @@ export class Router extends EventEmitter<{ } } } + return { route, props, @@ -158,6 +171,10 @@ export class Router extends EventEmitter<{ throw new Error('no route found for: ' + path); } + if (res.route.loginRequired) { + pleaseLogin('/'); + } + const isSamePath = beforePath === path; if (isSamePath && key == null) key = this.currentKey; this.currentComponent = res.route.component; diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue index ba85860cd..a80041b5c 100644 --- a/packages/client/src/pages/about-misskey.vue +++ b/packages/client/src/pages/about-misskey.vue @@ -1,7 +1,7 @@