mirror of
https://github.com/misskey-dev/misskey.git
synced 2024-12-28 14:30:39 +01:00
Merge branch 'develop' into release/2024.5.0
This commit is contained in:
commit
579b88adc7
51 changed files with 774 additions and 149 deletions
|
@ -3,6 +3,7 @@
|
||||||
### Note
|
### Note
|
||||||
- コントロールパネル内にあるサマリープロキシの設定個所がセキュリティから全般へ変更となります。
|
- コントロールパネル内にあるサマリープロキシの設定個所がセキュリティから全般へ変更となります。
|
||||||
- 悪意のある第三者がリモートユーザーになりすましたアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-2vxv-pv3m-3wvj)をご覧ください。
|
- 悪意のある第三者がリモートユーザーになりすましたアクティビティを受け取れてしまう問題を修正しました。詳しくは[GitHub security advisory](https://github.com/misskey-dev/misskey/security/advisories/GHSA-2vxv-pv3m-3wvj)をご覧ください。
|
||||||
|
- 管理者向け権限 `read:admin:show-users` は `read:admin:show-user` に統合されました。必要に応じてAPIトークンを再発行してください。
|
||||||
|
|
||||||
### General
|
### General
|
||||||
- Enhance: URLプレビューの有効化・無効化を設定できるように #13569
|
- Enhance: URLプレビューの有効化・無効化を設定できるように #13569
|
||||||
|
@ -15,12 +16,17 @@
|
||||||
- サスペンド済みユーザーか
|
- サスペンド済みユーザーか
|
||||||
- 鍵アカウントユーザーか
|
- 鍵アカウントユーザーか
|
||||||
- 「アカウントを見つけやすくする」が有効なユーザーか
|
- 「アカウントを見つけやすくする」が有効なユーザーか
|
||||||
|
- Enhance: Goneを出さずに終了したサーバーへの配信停止を自動的に行うように
|
||||||
|
- もしそのようなサーバーからから配信が届いた場合には自動的に配信を再開します
|
||||||
|
- Enhance: 配信停止の理由を表示するように
|
||||||
- Fix: Play作成時に設定した公開範囲が機能していない問題を修正
|
- Fix: Play作成時に設定した公開範囲が機能していない問題を修正
|
||||||
- Fix: 正規化されていない状態のhashtagが連合されてきたhtmlに含まれているとhashtagが正しくhashtagに復元されない問題を修正
|
- Fix: 正規化されていない状態のhashtagが連合されてきたhtmlに含まれているとhashtagが正しくhashtagに復元されない問題を修正
|
||||||
- Fix: みつけるのアンケート欄にてチャンネルのアンケートが含まれてしまう問題を修正
|
- Fix: みつけるのアンケート欄にてチャンネルのアンケートが含まれてしまう問題を修正
|
||||||
|
|
||||||
### Client
|
### Client
|
||||||
- Feat: アップロードするファイルの名前をランダム文字列にできるように
|
- Feat: アップロードするファイルの名前をランダム文字列にできるように
|
||||||
|
- Feat: 個別のお知らせにリンクで飛べるように
|
||||||
|
(Cherry-picked from https://github.com/MisskeyIO/misskey)
|
||||||
- Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように
|
- Enhance: 自分のノートの添付ファイルから直接ファイルの詳細ページに飛べるように
|
||||||
- Enhance: 広告がMisskeyと同一ドメインの場合はRouterで遷移するように
|
- Enhance: 広告がMisskeyと同一ドメインの場合はRouterで遷移するように
|
||||||
- Enhance: リアクション・いいねの総数を表示するように
|
- Enhance: リアクション・いいねの総数を表示するように
|
||||||
|
@ -43,6 +49,8 @@
|
||||||
- Enhance: AiScriptを0.18.0にバージョンアップ
|
- Enhance: AiScriptを0.18.0にバージョンアップ
|
||||||
- Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように
|
- Enhance: 通常のノートでも、お気に入りに登録したチャンネルにリノートできるように
|
||||||
- Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように
|
- Enhance: 長いテキストをペーストした際にテキストファイルとして添付するかどうかを選択できるように
|
||||||
|
- Enhance: コントロールパネルのクイックアクションからファイルを照会できるように
|
||||||
|
- Enhance: コントロールパネルのクイックアクションから通常の照会を行えるように
|
||||||
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
|
- Fix: 一部のページ内リンクが正しく動作しない問題を修正
|
||||||
- Fix: 周年の実績が閏年を考慮しない問題を修正
|
- Fix: 周年の実績が閏年を考慮しない問題を修正
|
||||||
- Fix: ローカルURLのプレビューポップアップが左上に表示される
|
- Fix: ローカルURLのプレビューポップアップが左上に表示される
|
||||||
|
|
|
@ -4,4 +4,4 @@
|
||||||
# SPDX-License-Identifier: AGPL-3.0-only
|
# SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
PORT=$(grep '^port:' /misskey/.config/default.yml | awk 'NR==1{print $2; exit}')
|
PORT=$(grep '^port:' /misskey/.config/default.yml | awk 'NR==1{print $2; exit}')
|
||||||
curl -s -S -o /dev/null "http://localhost:${PORT}"
|
curl -Sfso/dev/null "http://localhost:${PORT}/healthz"
|
||||||
|
|
42
locales/index.d.ts
vendored
42
locales/index.d.ts
vendored
|
@ -4113,9 +4113,13 @@ export interface Locale extends ILocale {
|
||||||
*/
|
*/
|
||||||
"thisPostMayBeAnnoyingIgnore": string;
|
"thisPostMayBeAnnoyingIgnore": string;
|
||||||
/**
|
/**
|
||||||
* 見たことのあるリノートを省略して表示
|
* リノートのスマート省略
|
||||||
*/
|
*/
|
||||||
"collapseRenotes": string;
|
"collapseRenotes": string;
|
||||||
|
/**
|
||||||
|
* リアクションやリノートをしたことがあるノートをたたんで表示します。
|
||||||
|
*/
|
||||||
|
"collapseRenotesDescription": string;
|
||||||
/**
|
/**
|
||||||
* サーバー内部エラー
|
* サーバー内部エラー
|
||||||
*/
|
*/
|
||||||
|
@ -4972,6 +4976,38 @@ export interface Locale extends ILocale {
|
||||||
* お問い合わせ
|
* お問い合わせ
|
||||||
*/
|
*/
|
||||||
"inquiry": string;
|
"inquiry": string;
|
||||||
|
"_delivery": {
|
||||||
|
/**
|
||||||
|
* 配信状態
|
||||||
|
*/
|
||||||
|
"status": string;
|
||||||
|
/**
|
||||||
|
* 配信停止
|
||||||
|
*/
|
||||||
|
"stop": string;
|
||||||
|
/**
|
||||||
|
* 配信再開
|
||||||
|
*/
|
||||||
|
"resume": string;
|
||||||
|
"_type": {
|
||||||
|
/**
|
||||||
|
* 配信中
|
||||||
|
*/
|
||||||
|
"none": string;
|
||||||
|
/**
|
||||||
|
* 手動停止中
|
||||||
|
*/
|
||||||
|
"manuallySuspended": string;
|
||||||
|
/**
|
||||||
|
* サーバー削除のため停止中
|
||||||
|
*/
|
||||||
|
"goneSuspended": string;
|
||||||
|
/**
|
||||||
|
* サーバー応答なしのため停止中
|
||||||
|
*/
|
||||||
|
"autoSuspendedForNotResponding": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
"_bubbleGame": {
|
"_bubbleGame": {
|
||||||
/**
|
/**
|
||||||
* 遊び方
|
* 遊び方
|
||||||
|
@ -7903,10 +7939,6 @@ export interface Locale extends ILocale {
|
||||||
* ユーザーのプライベートな情報を見る
|
* ユーザーのプライベートな情報を見る
|
||||||
*/
|
*/
|
||||||
"read:admin:show-user": string;
|
"read:admin:show-user": string;
|
||||||
/**
|
|
||||||
* ユーザーのプライベートな情報を見る
|
|
||||||
*/
|
|
||||||
"read:admin:show-users": string;
|
|
||||||
/**
|
/**
|
||||||
* ユーザーを凍結する
|
* ユーザーを凍結する
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -1024,7 +1024,8 @@ thisPostMayBeAnnoying: "この投稿は迷惑になる可能性があります
|
||||||
thisPostMayBeAnnoyingHome: "ホームに投稿"
|
thisPostMayBeAnnoyingHome: "ホームに投稿"
|
||||||
thisPostMayBeAnnoyingCancel: "やめる"
|
thisPostMayBeAnnoyingCancel: "やめる"
|
||||||
thisPostMayBeAnnoyingIgnore: "このまま投稿"
|
thisPostMayBeAnnoyingIgnore: "このまま投稿"
|
||||||
collapseRenotes: "見たことのあるリノートを省略して表示"
|
collapseRenotes: "リノートのスマート省略"
|
||||||
|
collapseRenotesDescription: "リアクションやリノートをしたことがあるノートをたたんで表示します。"
|
||||||
internalServerError: "サーバー内部エラー"
|
internalServerError: "サーバー内部エラー"
|
||||||
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
|
internalServerErrorDescription: "サーバー内部で予期しないエラーが発生しました。"
|
||||||
copyErrorInfo: "エラー情報をコピー"
|
copyErrorInfo: "エラー情報をコピー"
|
||||||
|
@ -1240,6 +1241,16 @@ noDescription: "説明文はありません"
|
||||||
alwaysConfirmFollow: "フォローの際常に確認する"
|
alwaysConfirmFollow: "フォローの際常に確認する"
|
||||||
inquiry: "お問い合わせ"
|
inquiry: "お問い合わせ"
|
||||||
|
|
||||||
|
_delivery:
|
||||||
|
status: "配信状態"
|
||||||
|
stop: "配信停止"
|
||||||
|
resume: "配信再開"
|
||||||
|
_type:
|
||||||
|
none: "配信中"
|
||||||
|
manuallySuspended: "手動停止中"
|
||||||
|
goneSuspended: "サーバー削除のため停止中"
|
||||||
|
autoSuspendedForNotResponding: "サーバー応答なしのため停止中"
|
||||||
|
|
||||||
_bubbleGame:
|
_bubbleGame:
|
||||||
howToPlay: "遊び方"
|
howToPlay: "遊び方"
|
||||||
hold: "ホールド"
|
hold: "ホールド"
|
||||||
|
@ -2075,7 +2086,6 @@ _permissions:
|
||||||
"read:admin:server-info": "サーバーの情報を見る"
|
"read:admin:server-info": "サーバーの情報を見る"
|
||||||
"read:admin:show-moderation-log": "モデレーションログを見る"
|
"read:admin:show-moderation-log": "モデレーションログを見る"
|
||||||
"read:admin:show-user": "ユーザーのプライベートな情報を見る"
|
"read:admin:show-user": "ユーザーのプライベートな情報を見る"
|
||||||
"read:admin:show-users": "ユーザーのプライベートな情報を見る"
|
|
||||||
"write:admin:suspend-user": "ユーザーを凍結する"
|
"write:admin:suspend-user": "ユーザーを凍結する"
|
||||||
"write:admin:unset-user-avatar": "ユーザーのアバターを削除する"
|
"write:admin:unset-user-avatar": "ユーザーのアバターを削除する"
|
||||||
"write:admin:unset-user-banner": "ユーザーのバーナーを削除する"
|
"write:admin:unset-user-banner": "ユーザーのバーナーを削除する"
|
||||||
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class NotRespondingSince1716345015347 {
|
||||||
|
name = 'NotRespondingSince1716345015347'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ADD "notRespondingSince" TIMESTAMP WITH TIME ZONE`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" DROP COLUMN "notRespondingSince"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class SuspensionStateInsteadOfIsSspended1716345771510 {
|
||||||
|
name = 'SuspensionStateInsteadOfIsSspended1716345771510'
|
||||||
|
|
||||||
|
async up(queryRunner) {
|
||||||
|
await queryRunner.query(`CREATE TYPE "public"."instance_suspensionstate_enum" AS ENUM('none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding')`);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_34500da2e38ac393f7bb6b299c"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" RENAME COLUMN "isSuspended" TO "suspensionState"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" DROP DEFAULT`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" TYPE "public"."instance_suspensionstate_enum" USING (
|
||||||
|
CASE "suspensionState"
|
||||||
|
WHEN TRUE THEN 'manuallySuspended'::instance_suspensionstate_enum
|
||||||
|
ELSE 'none'::instance_suspensionstate_enum
|
||||||
|
END
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" SET DEFAULT 'none'`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_3ede46f507c87ad698051d56a8" ON "instance" ("suspensionState") `);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(queryRunner) {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_3ede46f507c87ad698051d56a8"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" DROP DEFAULT`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" TYPE boolean USING (
|
||||||
|
CASE "suspensionState"
|
||||||
|
WHEN 'none'::instance_suspensionstate_enum THEN FALSE
|
||||||
|
ELSE TRUE
|
||||||
|
END
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" ALTER COLUMN "suspensionState" SET DEFAULT false`);
|
||||||
|
|
||||||
|
await queryRunner.query(`ALTER TABLE "instance" RENAME COLUMN "suspensionState" TO "isSuspended"`);
|
||||||
|
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_34500da2e38ac393f7bb6b299c" ON "instance" ("isSuspended") `);
|
||||||
|
|
||||||
|
await queryRunner.query(`DROP TYPE "public"."instance_suspensionstate_enum"`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import Logger from '@/logger.js';
|
||||||
import { envOption } from '../env.js';
|
import { envOption } from '../env.js';
|
||||||
import { masterMain } from './master.js';
|
import { masterMain } from './master.js';
|
||||||
import { workerMain } from './worker.js';
|
import { workerMain } from './worker.js';
|
||||||
|
import { readyRef } from './ready.js';
|
||||||
|
|
||||||
import 'reflect-metadata';
|
import 'reflect-metadata';
|
||||||
|
|
||||||
|
@ -79,6 +80,8 @@ if (cluster.isWorker || envOption.disableClustering) {
|
||||||
await workerMain();
|
await workerMain();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readyRef.value = true;
|
||||||
|
|
||||||
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
|
// ユニットテスト時にMisskeyが子プロセスで起動された時のため
|
||||||
// それ以外のときは process.send は使えないので弾く
|
// それ以外のときは process.send は使えないので弾く
|
||||||
if (process.send) {
|
if (process.send) {
|
||||||
|
|
6
packages/backend/src/boot/ready.ts
Normal file
6
packages/backend/src/boot/ready.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const readyRef = { value: false };
|
|
@ -4,13 +4,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Brackets } from 'typeorm';
|
import { Brackets, EntityNotFoundError } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { MiUser } from '@/models/User.js';
|
import type { MiUser } from '@/models/User.js';
|
||||||
import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
|
import type { AnnouncementReadsRepository, AnnouncementsRepository, MiAnnouncement, MiAnnouncementRead, UsersRepository } from '@/models/_.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { Packed } from '@/misc/json-schema.js';
|
import { Packed } from '@/misc/json-schema.js';
|
||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
|
||||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||||
|
|
||||||
|
@ -29,6 +30,7 @@ export class AnnouncementService {
|
||||||
private idService: IdService,
|
private idService: IdService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private moderationLogService: ModerationLogService,
|
private moderationLogService: ModerationLogService,
|
||||||
|
private announcementEntityService: AnnouncementEntityService,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,7 +81,7 @@ export class AnnouncementService {
|
||||||
userId: values.userId,
|
userId: values.userId,
|
||||||
}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0]));
|
}).then(x => this.announcementsRepository.findOneByOrFail(x.identifiers[0]));
|
||||||
|
|
||||||
const packed = (await this.packMany([announcement]))[0];
|
const packed = await this.announcementEntityService.pack(announcement);
|
||||||
|
|
||||||
if (values.userId) {
|
if (values.userId) {
|
||||||
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
|
this.globalEventService.publishMainStream(values.userId, 'announcementCreated', {
|
||||||
|
@ -177,6 +179,24 @@ export class AnnouncementService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async getAnnouncement(announcementId: MiAnnouncement['id'], me: MiUser | null): Promise<Packed<'Announcement'>> {
|
||||||
|
const announcement = await this.announcementsRepository.findOneByOrFail({ id: announcementId });
|
||||||
|
if (me) {
|
||||||
|
if (announcement.userId && announcement.userId !== me.id) {
|
||||||
|
throw new EntityNotFoundError(this.announcementsRepository.metadata.target, { id: announcementId });
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = await this.announcementReadsRepository.findOneBy({
|
||||||
|
announcementId: announcement.id,
|
||||||
|
userId: me.id,
|
||||||
|
});
|
||||||
|
return this.announcementEntityService.pack({ ...announcement, isRead: read !== null }, me);
|
||||||
|
} else {
|
||||||
|
return this.announcementEntityService.pack(announcement, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> {
|
public async read(user: MiUser, announcementId: MiAnnouncement['id']): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
@ -193,29 +213,4 @@ export class AnnouncementService {
|
||||||
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
|
this.globalEventService.publishMainStream(user.id, 'readAllAnnouncements');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
public async packMany(
|
|
||||||
announcements: MiAnnouncement[],
|
|
||||||
me?: { id: MiUser['id'] } | null | undefined,
|
|
||||||
options?: {
|
|
||||||
reads?: MiAnnouncementRead[];
|
|
||||||
},
|
|
||||||
): Promise<Packed<'Announcement'>[]> {
|
|
||||||
const reads = me ? (options?.reads ?? await this.getReads(me.id)) : [];
|
|
||||||
return announcements.map(announcement => ({
|
|
||||||
id: announcement.id,
|
|
||||||
createdAt: this.idService.parse(announcement.id).date.toISOString(),
|
|
||||||
updatedAt: announcement.updatedAt?.toISOString() ?? null,
|
|
||||||
text: announcement.text,
|
|
||||||
title: announcement.title,
|
|
||||||
imageUrl: announcement.imageUrl,
|
|
||||||
icon: announcement.icon,
|
|
||||||
display: announcement.display,
|
|
||||||
needConfirmationToRead: announcement.needConfirmationToRead,
|
|
||||||
silence: announcement.silence,
|
|
||||||
forYou: announcement.userId === me?.id,
|
|
||||||
isRead: reads.some(read => read.announcementId === announcement.id),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -84,6 +84,7 @@ import ApRequestChart from './chart/charts/ap-request.js';
|
||||||
import { ChartManagementService } from './chart/ChartManagementService.js';
|
import { ChartManagementService } from './chart/ChartManagementService.js';
|
||||||
|
|
||||||
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
|
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
|
||||||
|
import { AnnouncementEntityService } from './entities/AnnouncementEntityService.js';
|
||||||
import { AntennaEntityService } from './entities/AntennaEntityService.js';
|
import { AntennaEntityService } from './entities/AntennaEntityService.js';
|
||||||
import { AppEntityService } from './entities/AppEntityService.js';
|
import { AppEntityService } from './entities/AppEntityService.js';
|
||||||
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
|
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
|
||||||
|
@ -223,6 +224,7 @@ const $ApRequestChart: Provider = { provide: 'ApRequestChart', useExisting: ApRe
|
||||||
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
|
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useExisting: ChartManagementService };
|
||||||
|
|
||||||
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
|
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useExisting: AbuseUserReportEntityService };
|
||||||
|
const $AnnouncementEntityService: Provider = { provide: 'AnnouncementEntityService', useExisting: AnnouncementEntityService };
|
||||||
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
|
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useExisting: AntennaEntityService };
|
||||||
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
|
const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting: AppEntityService };
|
||||||
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
|
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
|
||||||
|
@ -363,6 +365,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ChartManagementService,
|
ChartManagementService,
|
||||||
|
|
||||||
AbuseUserReportEntityService,
|
AbuseUserReportEntityService,
|
||||||
|
AnnouncementEntityService,
|
||||||
AntennaEntityService,
|
AntennaEntityService,
|
||||||
AppEntityService,
|
AppEntityService,
|
||||||
AuthSessionEntityService,
|
AuthSessionEntityService,
|
||||||
|
@ -499,6 +502,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ChartManagementService,
|
$ChartManagementService,
|
||||||
|
|
||||||
$AbuseUserReportEntityService,
|
$AbuseUserReportEntityService,
|
||||||
|
$AnnouncementEntityService,
|
||||||
$AntennaEntityService,
|
$AntennaEntityService,
|
||||||
$AppEntityService,
|
$AppEntityService,
|
||||||
$AuthSessionEntityService,
|
$AuthSessionEntityService,
|
||||||
|
@ -635,6 +639,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
ChartManagementService,
|
ChartManagementService,
|
||||||
|
|
||||||
AbuseUserReportEntityService,
|
AbuseUserReportEntityService,
|
||||||
|
AnnouncementEntityService,
|
||||||
AntennaEntityService,
|
AntennaEntityService,
|
||||||
AppEntityService,
|
AppEntityService,
|
||||||
AuthSessionEntityService,
|
AuthSessionEntityService,
|
||||||
|
@ -770,6 +775,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
|
||||||
$ChartManagementService,
|
$ChartManagementService,
|
||||||
|
|
||||||
$AbuseUserReportEntityService,
|
$AbuseUserReportEntityService,
|
||||||
|
$AnnouncementEntityService,
|
||||||
$AntennaEntityService,
|
$AntennaEntityService,
|
||||||
$AppEntityService,
|
$AppEntityService,
|
||||||
$AuthSessionEntityService,
|
$AuthSessionEntityService,
|
||||||
|
|
|
@ -0,0 +1,71 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import type { AnnouncementsRepository, AnnouncementReadsRepository, MiAnnouncement, MiUser } from '@/models/_.js';
|
||||||
|
import type { Packed } from '@/misc/json-schema.js';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { IdService } from '@/core/IdService.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AnnouncementEntityService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.announcementsRepository)
|
||||||
|
private announcementsRepository: AnnouncementsRepository,
|
||||||
|
|
||||||
|
@Inject(DI.announcementReadsRepository)
|
||||||
|
private announcementReadsRepository: AnnouncementReadsRepository,
|
||||||
|
|
||||||
|
private idService: IdService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async pack(
|
||||||
|
src: MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null },
|
||||||
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
|
): Promise<Packed<'Announcement'>> {
|
||||||
|
const announcement = typeof src === 'object'
|
||||||
|
? src
|
||||||
|
: await this.announcementsRepository.findOneByOrFail({
|
||||||
|
id: src,
|
||||||
|
}) as MiAnnouncement & { isRead?: boolean | null };
|
||||||
|
|
||||||
|
if (me && announcement.isRead === undefined) {
|
||||||
|
announcement.isRead = await this.announcementReadsRepository
|
||||||
|
.countBy({
|
||||||
|
announcementId: announcement.id,
|
||||||
|
userId: me.id,
|
||||||
|
})
|
||||||
|
.then((count: number) => count > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: announcement.id,
|
||||||
|
createdAt: this.idService.parse(announcement.id).date.toISOString(),
|
||||||
|
updatedAt: announcement.updatedAt?.toISOString() ?? null,
|
||||||
|
title: announcement.title,
|
||||||
|
text: announcement.text,
|
||||||
|
imageUrl: announcement.imageUrl,
|
||||||
|
icon: announcement.icon,
|
||||||
|
display: announcement.display,
|
||||||
|
forYou: announcement.userId === me?.id,
|
||||||
|
needConfirmationToRead: announcement.needConfirmationToRead,
|
||||||
|
silence: announcement.silence,
|
||||||
|
isRead: announcement.isRead !== null ? announcement.isRead : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public async packMany(
|
||||||
|
announcements: (MiAnnouncement['id'] | MiAnnouncement & { isRead?: boolean | null } | MiAnnouncement)[],
|
||||||
|
me?: { id: MiUser['id'] } | null | undefined,
|
||||||
|
) : Promise<Packed<'Announcement'>[]> {
|
||||||
|
return (await Promise.allSettled(announcements.map(x => this.pack(x, me))))
|
||||||
|
.filter(result => result.status === 'fulfilled')
|
||||||
|
.map(result => (result as PromiseFulfilledResult<Packed<'Announcement'>>).value);
|
||||||
|
}
|
||||||
|
}
|
|
@ -39,7 +39,8 @@ export class InstanceEntityService {
|
||||||
followingCount: instance.followingCount,
|
followingCount: instance.followingCount,
|
||||||
followersCount: instance.followersCount,
|
followersCount: instance.followersCount,
|
||||||
isNotResponding: instance.isNotResponding,
|
isNotResponding: instance.isNotResponding,
|
||||||
isSuspended: instance.isSuspended,
|
isSuspended: instance.suspensionState !== 'none',
|
||||||
|
suspensionState: instance.suspensionState,
|
||||||
isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host),
|
isBlocked: this.utilityService.isBlockedHost(meta.blockedHosts, instance.host),
|
||||||
softwareName: instance.softwareName,
|
softwareName: instance.softwareName,
|
||||||
softwareVersion: instance.softwareVersion,
|
softwareVersion: instance.softwareVersion,
|
||||||
|
|
|
@ -81,13 +81,22 @@ export class MiInstance {
|
||||||
public isNotResponding: boolean;
|
public isNotResponding: boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* このインスタンスへの配信を停止するか
|
* このインスタンスと不通になった日時
|
||||||
|
*/
|
||||||
|
@Column('timestamp with time zone', {
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
public notRespondingSince: Date | null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* このインスタンスへの配信状態
|
||||||
*/
|
*/
|
||||||
@Index()
|
@Index()
|
||||||
@Column('boolean', {
|
@Column('enum', {
|
||||||
default: false,
|
default: 'none',
|
||||||
|
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
|
||||||
})
|
})
|
||||||
public isSuspended: boolean;
|
public suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding';
|
||||||
|
|
||||||
@Column('varchar', {
|
@Column('varchar', {
|
||||||
length: 64, nullable: true,
|
length: 64, nullable: true,
|
||||||
|
|
|
@ -45,6 +45,11 @@ export const packedFederationInstanceSchema = {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
},
|
},
|
||||||
|
suspensionState: {
|
||||||
|
type: 'string',
|
||||||
|
nullable: false, optional: false,
|
||||||
|
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
|
||||||
|
},
|
||||||
isBlocked: {
|
isBlocked: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
optional: false, nullable: false,
|
optional: false, nullable: false,
|
||||||
|
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import * as Bull from 'bullmq';
|
import * as Bull from 'bullmq';
|
||||||
|
import { Not } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { InstancesRepository } from '@/models/_.js';
|
import type { InstancesRepository } from '@/models/_.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
|
@ -62,7 +63,7 @@ export class DeliverProcessorService {
|
||||||
if (suspendedHosts == null) {
|
if (suspendedHosts == null) {
|
||||||
suspendedHosts = await this.instancesRepository.find({
|
suspendedHosts = await this.instancesRepository.find({
|
||||||
where: {
|
where: {
|
||||||
isSuspended: true,
|
suspensionState: Not('none'),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.suspendedHostsCache.set(suspendedHosts);
|
this.suspendedHostsCache.set(suspendedHosts);
|
||||||
|
@ -79,6 +80,7 @@ export class DeliverProcessorService {
|
||||||
if (i.isNotResponding) {
|
if (i.isNotResponding) {
|
||||||
this.federatedInstanceService.update(i.id, {
|
this.federatedInstanceService.update(i.id, {
|
||||||
isNotResponding: false,
|
isNotResponding: false,
|
||||||
|
notRespondingSince: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,7 +100,15 @@ export class DeliverProcessorService {
|
||||||
if (!i.isNotResponding) {
|
if (!i.isNotResponding) {
|
||||||
this.federatedInstanceService.update(i.id, {
|
this.federatedInstanceService.update(i.id, {
|
||||||
isNotResponding: true,
|
isNotResponding: true,
|
||||||
|
notRespondingSince: new Date(),
|
||||||
});
|
});
|
||||||
|
} else if (i.notRespondingSince) {
|
||||||
|
// 1週間以上不通ならサスペンド
|
||||||
|
if (i.suspensionState === 'none' && i.notRespondingSince.getTime() <= Date.now() - 1000 * 60 * 60 * 24 * 7) {
|
||||||
|
this.federatedInstanceService.update(i.id, {
|
||||||
|
suspensionState: 'autoSuspendedForNotResponding',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apRequestChart.deliverFail();
|
this.apRequestChart.deliverFail();
|
||||||
|
@ -116,7 +126,7 @@ export class DeliverProcessorService {
|
||||||
if (job.data.isSharedInbox && res.statusCode === 410) {
|
if (job.data.isSharedInbox && res.statusCode === 410) {
|
||||||
this.federatedInstanceService.fetch(host).then(i => {
|
this.federatedInstanceService.fetch(host).then(i => {
|
||||||
this.federatedInstanceService.update(i.id, {
|
this.federatedInstanceService.update(i.id, {
|
||||||
isSuspended: true,
|
suspensionState: 'goneSuspended',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
throw new Bull.UnrecoverableError(`${host} is gone`);
|
throw new Bull.UnrecoverableError(`${host} is gone`);
|
||||||
|
|
|
@ -188,6 +188,8 @@ export class InboxProcessorService {
|
||||||
this.federatedInstanceService.update(i.id, {
|
this.federatedInstanceService.update(i.id, {
|
||||||
latestRequestReceivedAt: new Date(),
|
latestRequestReceivedAt: new Date(),
|
||||||
isNotResponding: false,
|
isNotResponding: false,
|
||||||
|
// もしサーバーが死んでるために配信が止まっていた場合には自動的に復活させてあげる
|
||||||
|
suspensionState: i.suspensionState === 'autoSuspendedForNotResponding' ? 'none' : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
this.fetchInstanceMetadataService.fetchInstanceMetadata(i);
|
||||||
|
|
54
packages/backend/src/server/HealthServerService.ts
Normal file
54
packages/backend/src/server/HealthServerService.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import * as Redis from 'ioredis';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { bindThis } from '@/decorators.js';
|
||||||
|
import { DI } from '@/di-symbols.js';
|
||||||
|
import { readyRef } from '@/boot/ready.js';
|
||||||
|
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
||||||
|
import type { MeiliSearch } from 'meilisearch';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class HealthServerService {
|
||||||
|
constructor(
|
||||||
|
@Inject(DI.redis)
|
||||||
|
private redis: Redis.Redis,
|
||||||
|
|
||||||
|
@Inject(DI.redisForPub)
|
||||||
|
private redisForPub: Redis.Redis,
|
||||||
|
|
||||||
|
@Inject(DI.redisForSub)
|
||||||
|
private redisForSub: Redis.Redis,
|
||||||
|
|
||||||
|
@Inject(DI.redisForTimelines)
|
||||||
|
private redisForTimelines: Redis.Redis,
|
||||||
|
|
||||||
|
@Inject(DI.db)
|
||||||
|
private db: DataSource,
|
||||||
|
|
||||||
|
@Inject(DI.meilisearch)
|
||||||
|
private meilisearch: MeiliSearch | null,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
|
||||||
|
fastify.get('/', async (request, reply) => {
|
||||||
|
reply.code(await Promise.all([
|
||||||
|
new Promise<void>((resolve, reject) => readyRef.value ? resolve() : reject()),
|
||||||
|
this.redis.ping(),
|
||||||
|
this.redisForPub.ping(),
|
||||||
|
this.redisForSub.ping(),
|
||||||
|
this.redisForTimelines.ping(),
|
||||||
|
this.db.query('SELECT 1'),
|
||||||
|
...(this.meilisearch ? [this.meilisearch.health()] : []),
|
||||||
|
]).then(() => 200, () => 503));
|
||||||
|
reply.header('Cache-Control', 'no-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,6 +8,7 @@ import { EndpointsModule } from '@/server/api/EndpointsModule.js';
|
||||||
import { CoreModule } from '@/core/CoreModule.js';
|
import { CoreModule } from '@/core/CoreModule.js';
|
||||||
import { ApiCallService } from './api/ApiCallService.js';
|
import { ApiCallService } from './api/ApiCallService.js';
|
||||||
import { FileServerService } from './FileServerService.js';
|
import { FileServerService } from './FileServerService.js';
|
||||||
|
import { HealthServerService } from './HealthServerService.js';
|
||||||
import { NodeinfoServerService } from './NodeinfoServerService.js';
|
import { NodeinfoServerService } from './NodeinfoServerService.js';
|
||||||
import { ServerService } from './ServerService.js';
|
import { ServerService } from './ServerService.js';
|
||||||
import { WellKnownServerService } from './WellKnownServerService.js';
|
import { WellKnownServerService } from './WellKnownServerService.js';
|
||||||
|
@ -55,6 +56,7 @@ import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js
|
||||||
ClientServerService,
|
ClientServerService,
|
||||||
ClientLoggerService,
|
ClientLoggerService,
|
||||||
FeedService,
|
FeedService,
|
||||||
|
HealthServerService,
|
||||||
UrlPreviewService,
|
UrlPreviewService,
|
||||||
ActivityPubServerService,
|
ActivityPubServerService,
|
||||||
FileServerService,
|
FileServerService,
|
||||||
|
|
|
@ -28,6 +28,7 @@ import { ApiServerService } from './api/ApiServerService.js';
|
||||||
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
|
import { StreamingApiServerService } from './api/StreamingApiServerService.js';
|
||||||
import { WellKnownServerService } from './WellKnownServerService.js';
|
import { WellKnownServerService } from './WellKnownServerService.js';
|
||||||
import { FileServerService } from './FileServerService.js';
|
import { FileServerService } from './FileServerService.js';
|
||||||
|
import { HealthServerService } from './HealthServerService.js';
|
||||||
import { ClientServerService } from './web/ClientServerService.js';
|
import { ClientServerService } from './web/ClientServerService.js';
|
||||||
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
import { OpenApiServerService } from './api/openapi/OpenApiServerService.js';
|
||||||
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
|
||||||
|
@ -61,6 +62,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
private wellKnownServerService: WellKnownServerService,
|
private wellKnownServerService: WellKnownServerService,
|
||||||
private nodeinfoServerService: NodeinfoServerService,
|
private nodeinfoServerService: NodeinfoServerService,
|
||||||
private fileServerService: FileServerService,
|
private fileServerService: FileServerService,
|
||||||
|
private healthServerService: HealthServerService,
|
||||||
private clientServerService: ClientServerService,
|
private clientServerService: ClientServerService,
|
||||||
private globalEventService: GlobalEventService,
|
private globalEventService: GlobalEventService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
@ -108,6 +110,7 @@ export class ServerService implements OnApplicationShutdown {
|
||||||
fastify.register(this.wellKnownServerService.createServer);
|
fastify.register(this.wellKnownServerService.createServer);
|
||||||
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
|
fastify.register(this.oauth2ProviderService.createServer, { prefix: '/oauth' });
|
||||||
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
|
fastify.register(this.oauth2ProviderService.createTokenServer, { prefix: '/oauth/token' });
|
||||||
|
fastify.register(this.healthServerService.createServer, { prefix: '/healthz' });
|
||||||
|
|
||||||
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
fastify.get<{ Params: { path: string }; Querystring: { static?: any; badge?: any; }; }>('/emoji/:path(.*)', async (request, reply) => {
|
||||||
const path = request.params.path;
|
const path = request.params.path;
|
||||||
|
|
|
@ -137,7 +137,7 @@ export class ApiServerService {
|
||||||
const instances = await this.instancesRepository.find({
|
const instances = await this.instancesRepository.find({
|
||||||
select: ['host'],
|
select: ['host'],
|
||||||
where: {
|
where: {
|
||||||
isSuspended: false,
|
suspensionState: 'none',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'
|
||||||
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
||||||
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
||||||
import * as ep___announcements from './endpoints/announcements.js';
|
import * as ep___announcements from './endpoints/announcements.js';
|
||||||
|
import * as ep___announcements_show from './endpoints/announcements/show.js';
|
||||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||||
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
||||||
import * as ep___antennas_list from './endpoints/antennas/list.js';
|
import * as ep___antennas_list from './endpoints/antennas/list.js';
|
||||||
|
@ -455,6 +456,7 @@ const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', us
|
||||||
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
|
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
|
||||||
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
|
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
|
||||||
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
|
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
|
||||||
|
const $announcements_show: Provider = { provide: 'ep:announcements/show', useClass: ep___announcements_show.default };
|
||||||
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
|
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
|
||||||
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
|
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
|
||||||
const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default };
|
const $antennas_list: Provider = { provide: 'ep:antennas/list', useClass: ep___antennas_list.default };
|
||||||
|
@ -831,6 +833,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$admin_roles_updateDefaultPolicies,
|
$admin_roles_updateDefaultPolicies,
|
||||||
$admin_roles_users,
|
$admin_roles_users,
|
||||||
$announcements,
|
$announcements,
|
||||||
|
$announcements_show,
|
||||||
$antennas_create,
|
$antennas_create,
|
||||||
$antennas_delete,
|
$antennas_delete,
|
||||||
$antennas_list,
|
$antennas_list,
|
||||||
|
@ -1201,6 +1204,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
||||||
$admin_roles_updateDefaultPolicies,
|
$admin_roles_updateDefaultPolicies,
|
||||||
$admin_roles_users,
|
$admin_roles_users,
|
||||||
$announcements,
|
$announcements,
|
||||||
|
$announcements_show,
|
||||||
$antennas_create,
|
$antennas_create,
|
||||||
$antennas_delete,
|
$antennas_delete,
|
||||||
$antennas_list,
|
$antennas_list,
|
||||||
|
|
|
@ -83,6 +83,7 @@ import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js'
|
||||||
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
|
||||||
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
|
||||||
import * as ep___announcements from './endpoints/announcements.js';
|
import * as ep___announcements from './endpoints/announcements.js';
|
||||||
|
import * as ep___announcements_show from './endpoints/announcements/show.js';
|
||||||
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
import * as ep___antennas_create from './endpoints/antennas/create.js';
|
||||||
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
|
||||||
import * as ep___antennas_list from './endpoints/antennas/list.js';
|
import * as ep___antennas_list from './endpoints/antennas/list.js';
|
||||||
|
@ -453,6 +454,7 @@ const eps = [
|
||||||
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
|
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
|
||||||
['admin/roles/users', ep___admin_roles_users],
|
['admin/roles/users', ep___admin_roles_users],
|
||||||
['announcements', ep___announcements],
|
['announcements', ep___announcements],
|
||||||
|
['announcements/show', ep___announcements_show],
|
||||||
['antennas/create', ep___antennas_create],
|
['antennas/create', ep___antennas_create],
|
||||||
['antennas/delete', ep___antennas_delete],
|
['antennas/delete', ep___antennas_delete],
|
||||||
['antennas/list', ep___antennas_list],
|
['antennas/list', ep___antennas_list],
|
||||||
|
|
|
@ -46,12 +46,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
throw new Error('instance not found');
|
throw new Error('instance not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSuspendedBefore = instance.suspensionState !== 'none';
|
||||||
|
let suspensionState: undefined | 'manuallySuspended' | 'none';
|
||||||
|
|
||||||
|
if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) {
|
||||||
|
suspensionState = ps.isSuspended ? 'manuallySuspended' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
await this.federatedInstanceService.update(instance.id, {
|
await this.federatedInstanceService.update(instance.id, {
|
||||||
isSuspended: ps.isSuspended,
|
suspensionState,
|
||||||
moderationNote: ps.moderationNote,
|
moderationNote: ps.moderationNote,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ps.isSuspended != null && instance.isSuspended !== ps.isSuspended) {
|
if (ps.isSuspended != null && isSuspendedBefore !== ps.isSuspended) {
|
||||||
if (ps.isSuspended) {
|
if (ps.isSuspended) {
|
||||||
this.moderationLogService.log(me, 'suspendRemoteInstance', {
|
this.moderationLogService.log(me, 'suspendRemoteInstance', {
|
||||||
id: instance.id,
|
id: instance.id,
|
||||||
|
|
|
@ -16,7 +16,7 @@ export const meta = {
|
||||||
|
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
requireModerator: true,
|
requireModerator: true,
|
||||||
kind: 'read:admin:show-users',
|
kind: 'read:admin:show-user',
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|
|
@ -7,9 +7,9 @@ import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { Brackets } from 'typeorm';
|
import { Brackets } from 'typeorm';
|
||||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
import { QueryService } from '@/core/QueryService.js';
|
import { QueryService } from '@/core/QueryService.js';
|
||||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { AnnouncementReadsRepository, AnnouncementsRepository } from '@/models/_.js';
|
import type { AnnouncementsRepository } from '@/models/_.js';
|
||||||
|
|
||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['meta'],
|
tags: ['meta'],
|
||||||
|
@ -44,11 +44,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
@Inject(DI.announcementsRepository)
|
@Inject(DI.announcementsRepository)
|
||||||
private announcementsRepository: AnnouncementsRepository,
|
private announcementsRepository: AnnouncementsRepository,
|
||||||
|
|
||||||
@Inject(DI.announcementReadsRepository)
|
|
||||||
private announcementReadsRepository: AnnouncementReadsRepository,
|
|
||||||
|
|
||||||
private queryService: QueryService,
|
private queryService: QueryService,
|
||||||
private announcementService: AnnouncementService,
|
private announcementEntityService: AnnouncementEntityService,
|
||||||
) {
|
) {
|
||||||
super(meta, paramDef, async (ps, me) => {
|
super(meta, paramDef, async (ps, me) => {
|
||||||
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
|
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
|
||||||
|
@ -60,7 +57,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
|
|
||||||
const announcements = await query.limit(ps.limit).getMany();
|
const announcements = await query.limit(ps.limit).getMany();
|
||||||
|
|
||||||
return this.announcementService.packMany(announcements, me);
|
return this.announcementEntityService.packMany(announcements, me);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { EntityNotFoundError } from 'typeorm';
|
||||||
|
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||||
|
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||||
|
import { ApiError } from '../../error.js';
|
||||||
|
|
||||||
|
export const meta = {
|
||||||
|
tags: ['meta'],
|
||||||
|
|
||||||
|
requireCredential: false,
|
||||||
|
|
||||||
|
res: {
|
||||||
|
type: 'object',
|
||||||
|
optional: false, nullable: false,
|
||||||
|
ref: 'Announcement',
|
||||||
|
},
|
||||||
|
|
||||||
|
errors: {
|
||||||
|
noSuchAnnouncement: {
|
||||||
|
message: 'No such announcement.',
|
||||||
|
code: 'NO_SUCH_ANNOUNCEMENT',
|
||||||
|
id: 'b57b5e1d-4f49-404a-9edb-46b00268f121',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const paramDef = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
announcementId: { type: 'string', format: 'misskey:id' },
|
||||||
|
},
|
||||||
|
required: ['announcementId'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||||
|
constructor(
|
||||||
|
private announcementService: AnnouncementService,
|
||||||
|
) {
|
||||||
|
super(meta, paramDef, async (ps, me) => {
|
||||||
|
try {
|
||||||
|
return await this.announcementService.getAnnouncement(ps.announcementId, me);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof EntityNotFoundError) throw new ApiError(meta.errors.noSuchAnnouncement);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -498,26 +498,32 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||||
private async verifyLink(url: string, user: MiLocalUser) {
|
private async verifyLink(url: string, user: MiLocalUser) {
|
||||||
if (!safeForSql(url)) return;
|
if (!safeForSql(url)) return;
|
||||||
|
|
||||||
const html = await this.httpRequestService.getHtml(url);
|
try {
|
||||||
|
const html = await this.httpRequestService.getHtml(url);
|
||||||
|
|
||||||
const { window } = new JSDOM(html);
|
const { window } = new JSDOM(html);
|
||||||
const doc = window.document;
|
const doc = window.document;
|
||||||
|
|
||||||
const myLink = `${this.config.url}/@${user.username}`;
|
const myLink = `${this.config.url}/@${user.username}`;
|
||||||
|
|
||||||
const aEls = Array.from(doc.getElementsByTagName('a'));
|
const aEls = Array.from(doc.getElementsByTagName('a'));
|
||||||
const linkEls = Array.from(doc.getElementsByTagName('link'));
|
const linkEls = Array.from(doc.getElementsByTagName('link'));
|
||||||
|
|
||||||
const includesMyLink = aEls.some(a => a.href === myLink);
|
const includesMyLink = aEls.some(a => a.href === myLink);
|
||||||
const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink);
|
const includesRelMeLinks = [...aEls, ...linkEls].some(link => link.rel === 'me' && link.href === myLink);
|
||||||
|
|
||||||
if (includesMyLink || includesRelMeLinks) {
|
if (includesMyLink || includesRelMeLinks) {
|
||||||
await this.userProfilesRepository.createQueryBuilder('profile').update()
|
await this.userProfilesRepository.createQueryBuilder('profile').update()
|
||||||
.where('userId = :userId', { userId: user.id })
|
.where('userId = :userId', { userId: user.id })
|
||||||
.set({
|
.set({
|
||||||
verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている
|
verifiedLinks: () => `array_append("verifiedLinks", '${url}')`, // ここでSQLインジェクションされそうなのでとりあえず safeForSql で弾いている
|
||||||
})
|
})
|
||||||
.execute();
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.close();
|
||||||
|
} catch (err) {
|
||||||
|
// なにもしない
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -466,7 +466,9 @@ export class ClientServerService {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Atom
|
// Atom
|
||||||
fastify.get<{ Params: { user: string; } }>('/@:user.atom', async (request, reply) => {
|
fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => {
|
||||||
|
if (request.params.user == null) return await renderBase(reply);
|
||||||
|
|
||||||
const feed = await getFeed(request.params.user);
|
const feed = await getFeed(request.params.user);
|
||||||
|
|
||||||
if (feed) {
|
if (feed) {
|
||||||
|
@ -479,7 +481,9 @@ export class ClientServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// RSS
|
// RSS
|
||||||
fastify.get<{ Params: { user: string; } }>('/@:user.rss', async (request, reply) => {
|
fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => {
|
||||||
|
if (request.params.user == null) return await renderBase(reply);
|
||||||
|
|
||||||
const feed = await getFeed(request.params.user);
|
const feed = await getFeed(request.params.user);
|
||||||
|
|
||||||
if (feed) {
|
if (feed) {
|
||||||
|
@ -492,7 +496,9 @@ export class ClientServerService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// JSON
|
// JSON
|
||||||
fastify.get<{ Params: { user: string; } }>('/@:user.json', async (request, reply) => {
|
fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => {
|
||||||
|
if (request.params.user == null) return await renderBase(reply);
|
||||||
|
|
||||||
const feed = await getFeed(request.params.user);
|
const feed = await getFeed(request.params.user);
|
||||||
|
|
||||||
if (feed) {
|
if (feed) {
|
||||||
|
|
|
@ -10,6 +10,7 @@ import { ModuleMocker } from 'jest-mock';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { GlobalModule } from '@/GlobalModule.js';
|
import { GlobalModule } from '@/GlobalModule.js';
|
||||||
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
import { AnnouncementService } from '@/core/AnnouncementService.js';
|
||||||
|
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
|
||||||
import type {
|
import type {
|
||||||
AnnouncementReadsRepository,
|
AnnouncementReadsRepository,
|
||||||
AnnouncementsRepository,
|
AnnouncementsRepository,
|
||||||
|
@ -67,6 +68,7 @@ describe('AnnouncementService', () => {
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AnnouncementService,
|
AnnouncementService,
|
||||||
|
AnnouncementEntityService,
|
||||||
CacheService,
|
CacheService,
|
||||||
IdService,
|
IdService,
|
||||||
],
|
],
|
||||||
|
|
|
@ -11,3 +11,4 @@ export const clipsCache = new Cache<Misskey.entities.Clip[]>(1000 * 60 * 30, ()
|
||||||
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
|
export const rolesCache = new Cache(1000 * 60 * 30, () => misskeyApi('admin/roles/list'));
|
||||||
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
|
export const userListsCache = new Cache<Misskey.entities.UserList[]>(1000 * 60 * 30, () => misskeyApi('users/lists/list'));
|
||||||
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list'));
|
export const antennasCache = new Cache<Misskey.entities.Antenna[]>(1000 * 60 * 30, () => misskeyApi('antennas/list'));
|
||||||
|
export const favoritedChannelsCache = new Cache<Misskey.entities.Channel[]>(1000 * 60 * 30, () => misskeyApi('channels/my-favorites', { limit: 100 }));
|
||||||
|
|
|
@ -58,6 +58,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import XHeader from './_header_.vue';
|
import XHeader from './_header_.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
|
@ -90,8 +91,17 @@ const pagination = {
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
function getStatus(instance) {
|
function getStatus(instance: Misskey.entities.FederationInstance) {
|
||||||
if (instance.isSuspended) return 'Suspended';
|
switch (instance.suspensionState) {
|
||||||
|
case 'manuallySuspended':
|
||||||
|
return 'Manually Suspended';
|
||||||
|
case 'goneSuspended':
|
||||||
|
return 'Automatically Suspended (Gone)';
|
||||||
|
case 'autoSuspendedForNotResponding':
|
||||||
|
return 'Automatically Suspended (Not Responding)';
|
||||||
|
case 'none':
|
||||||
|
break;
|
||||||
|
}
|
||||||
if (instance.isBlocked) return 'Blocked';
|
if (instance.isBlocked) return 'Blocked';
|
||||||
if (instance.isSilenced) return 'Silenced';
|
if (instance.isSilenced) return 'Silenced';
|
||||||
if (instance.isNotResponding) return 'Error';
|
if (instance.isNotResponding) return 'Error';
|
||||||
|
|
|
@ -42,7 +42,7 @@ import MkInput from '@/components/MkInput.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
|
import MkFileListForAdmin from '@/components/MkFileListForAdmin.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { lookupFile } from '@/scripts/admin-lookup.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
|
||||||
|
@ -73,33 +73,10 @@ function clear() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(file) {
|
|
||||||
os.pageWindow(`/admin/file/${file.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function find() {
|
|
||||||
const { canceled, result: q } = await os.inputText({
|
|
||||||
title: i18n.ts.fileIdOrUrl,
|
|
||||||
minLength: 1,
|
|
||||||
});
|
|
||||||
if (canceled) return;
|
|
||||||
|
|
||||||
misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
|
|
||||||
show(file);
|
|
||||||
}).catch(err => {
|
|
||||||
if (err.code === 'NO_SUCH_FILE') {
|
|
||||||
os.alert({
|
|
||||||
type: 'error',
|
|
||||||
text: i18n.ts.notFound,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const headerActions = computed(() => [{
|
const headerActions = computed(() => [{
|
||||||
text: i18n.ts.lookup,
|
text: i18n.ts.lookup,
|
||||||
icon: 'ti ti-search',
|
icon: 'ti ti-search',
|
||||||
handler: find,
|
handler: lookupFile,
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.clearCachedFiles,
|
text: i18n.ts.clearCachedFiles,
|
||||||
icon: 'ti ti-trash',
|
icon: 'ti ti-trash',
|
||||||
|
|
|
@ -33,9 +33,10 @@ import { i18n } from '@/i18n.js';
|
||||||
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
import MkSuperMenu from '@/components/MkSuperMenu.vue';
|
||||||
import MkInfo from '@/components/MkInfo.vue';
|
import MkInfo from '@/components/MkInfo.vue';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
|
import { lookup } from '@/scripts/lookup.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { lookupUser, lookupUserByEmail } from '@/scripts/lookup-user.js';
|
import { lookupUser, lookupUserByEmail, lookupFile } from '@/scripts/admin-lookup.js';
|
||||||
import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
import { PageMetadata, definePageMetadata, provideMetadataReceiver, provideReactiveMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { useRouter } from '@/router/supplier.js';
|
import { useRouter } from '@/router/supplier.js';
|
||||||
|
|
||||||
|
@ -82,7 +83,7 @@ const menuDef = computed(() => [{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
icon: 'ti ti-search',
|
icon: 'ti ti-search',
|
||||||
text: i18n.ts.lookup,
|
text: i18n.ts.lookup,
|
||||||
action: lookup,
|
action: adminLookup,
|
||||||
}, ...(instance.disableRegistration ? [{
|
}, ...(instance.disableRegistration ? [{
|
||||||
type: 'button',
|
type: 'button',
|
||||||
icon: 'ti ti-user-plus',
|
icon: 'ti ti-user-plus',
|
||||||
|
@ -282,7 +283,7 @@ function invite() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function lookup(ev: MouseEvent) {
|
function adminLookup(ev: MouseEvent) {
|
||||||
os.popupMenu([{
|
os.popupMenu([{
|
||||||
text: i18n.ts.user,
|
text: i18n.ts.user,
|
||||||
icon: 'ti ti-user',
|
icon: 'ti ti-user',
|
||||||
|
@ -295,23 +296,17 @@ function lookup(ev: MouseEvent) {
|
||||||
action: () => {
|
action: () => {
|
||||||
lookupUserByEmail();
|
lookupUserByEmail();
|
||||||
},
|
},
|
||||||
}, {
|
|
||||||
text: i18n.ts.note,
|
|
||||||
icon: 'ti ti-pencil',
|
|
||||||
action: () => {
|
|
||||||
alert('TODO');
|
|
||||||
},
|
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.file,
|
text: i18n.ts.file,
|
||||||
icon: 'ti ti-cloud',
|
icon: 'ti ti-cloud',
|
||||||
action: () => {
|
action: () => {
|
||||||
alert('TODO');
|
lookupFile();
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
text: i18n.ts.instance,
|
text: i18n.ts.lookup,
|
||||||
icon: 'ti ti-planet',
|
icon: 'ti ti-world-search',
|
||||||
action: () => {
|
action: () => {
|
||||||
alert('TODO');
|
lookup();
|
||||||
},
|
},
|
||||||
}], ev.currentTarget ?? ev.target);
|
}], ev.currentTarget ?? ev.target);
|
||||||
}
|
}
|
||||||
|
|
|
@ -63,7 +63,7 @@ import MkInput from '@/components/MkInput.vue';
|
||||||
import MkSelect from '@/components/MkSelect.vue';
|
import MkSelect from '@/components/MkSelect.vue';
|
||||||
import MkPagination from '@/components/MkPagination.vue';
|
import MkPagination from '@/components/MkPagination.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
import { lookupUser } from '@/scripts/lookup-user.js';
|
import { lookupUser } from '@/scripts/admin-lookup.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
import MkUserCardMini from '@/components/MkUserCardMini.vue';
|
||||||
|
|
142
packages/frontend/src/pages/announcement.vue
Normal file
142
packages/frontend/src/pages/announcement.vue
Normal file
|
@ -0,0 +1,142 @@
|
||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: syuilo and misskey-project
|
||||||
|
SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
-->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<MkStickyContainer>
|
||||||
|
<template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template>
|
||||||
|
<MkSpacer :contentMax="800">
|
||||||
|
<Transition
|
||||||
|
:enterActiveClass="defaultStore.state.animation ? $style.fadeEnterActive : ''"
|
||||||
|
:leaveActiveClass="defaultStore.state.animation ? $style.fadeLeaveActive : ''"
|
||||||
|
:enterFromClass="defaultStore.state.animation ? $style.fadeEnterFrom : ''"
|
||||||
|
:leaveToClass="defaultStore.state.animation ? $style.fadeLeaveTo : ''"
|
||||||
|
mode="out-in"
|
||||||
|
>
|
||||||
|
<div v-if="announcement" :key="announcement.id" class="_panel" :class="$style.announcement">
|
||||||
|
<div v-if="announcement.forYou" :class="$style.forYou"><i class="ti ti-pin"></i> {{ i18n.ts.forYou }}</div>
|
||||||
|
<div :class="$style.header">
|
||||||
|
<span v-if="$i && !announcement.silence && !announcement.isRead" style="margin-right: 0.5em;">🆕</span>
|
||||||
|
<span style="margin-right: 0.5em;">
|
||||||
|
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
|
||||||
|
<i v-else-if="announcement.icon === 'warning'" class="ti ti-alert-triangle" style="color: var(--warn);"></i>
|
||||||
|
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
|
||||||
|
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
|
||||||
|
</span>
|
||||||
|
<Mfm :text="announcement.title"/>
|
||||||
|
</div>
|
||||||
|
<div :class="$style.content">
|
||||||
|
<Mfm :text="announcement.text"/>
|
||||||
|
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
|
||||||
|
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
|
||||||
|
{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;">
|
||||||
|
{{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="$i && !announcement.silence && !announcement.isRead" :class="$style.footer">
|
||||||
|
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MkError v-else-if="error" @retry="fetch()"/>
|
||||||
|
<MkLoading v-else/>
|
||||||
|
</Transition>
|
||||||
|
</MkSpacer>
|
||||||
|
</MkStickyContainer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts" setup>
|
||||||
|
import { ref, computed, watch } from 'vue';
|
||||||
|
import * as Misskey from 'misskey-js';
|
||||||
|
import MkButton from '@/components/MkButton.vue';
|
||||||
|
import * as os from '@/os.js';
|
||||||
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
|
import { i18n } from '@/i18n.js';
|
||||||
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
|
import { $i, updateAccount } from '@/account.js';
|
||||||
|
import { defaultStore } from '@/store.js';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
announcementId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const announcement = ref<Misskey.entities.Announcement | null>(null);
|
||||||
|
const error = ref<any>(null);
|
||||||
|
const path = computed(() => props.announcementId);
|
||||||
|
|
||||||
|
function fetch() {
|
||||||
|
announcement.value = null;
|
||||||
|
misskeyApi('announcements/show', {
|
||||||
|
announcementId: props.announcementId,
|
||||||
|
}).then(async _announcement => {
|
||||||
|
announcement.value = _announcement;
|
||||||
|
}).catch(err => {
|
||||||
|
error.value = err;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function read(target: Misskey.entities.Announcement): Promise<void> {
|
||||||
|
if (target.needConfirmationToRead) {
|
||||||
|
const confirm = await os.confirm({
|
||||||
|
type: 'question',
|
||||||
|
title: i18n.ts._announcement.readConfirmTitle,
|
||||||
|
text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
|
||||||
|
});
|
||||||
|
if (confirm.canceled) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
target.isRead = true;
|
||||||
|
await misskeyApi('i/read-announcement', { announcementId: target.id });
|
||||||
|
if ($i) {
|
||||||
|
updateAccount({
|
||||||
|
unreadAnnouncements: $i.unreadAnnouncements.filter((a: { id: string; }) => a.id !== target.id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => path.value, fetch, { immediate: true });
|
||||||
|
|
||||||
|
const headerActions = computed(() => []);
|
||||||
|
|
||||||
|
const headerTabs = computed(() => []);
|
||||||
|
|
||||||
|
definePageMetadata(() => ({
|
||||||
|
title: announcement.value ? `${i18n.ts.announcements}: ${announcement.value.title}` : i18n.ts.announcements,
|
||||||
|
icon: 'ti ti-speakerphone',
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" module>
|
||||||
|
.announcement {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forYou {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
line-height: 24px;
|
||||||
|
font-size: 90%;
|
||||||
|
white-space: pre;
|
||||||
|
color: #d28a3f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 120%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
> img {
|
||||||
|
display: block;
|
||||||
|
max-height: 300px;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -21,14 +21,19 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
|
<i v-else-if="announcement.icon === 'error'" class="ti ti-circle-x" style="color: var(--error);"></i>
|
||||||
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
|
<i v-else-if="announcement.icon === 'success'" class="ti ti-check" style="color: var(--success);"></i>
|
||||||
</span>
|
</span>
|
||||||
<span>{{ announcement.title }}</span>
|
<MkA :to="`/announcements/${announcement.id}`"><span>{{ announcement.title }}</span></MkA>
|
||||||
</div>
|
</div>
|
||||||
<div :class="$style.content">
|
<div :class="$style.content">
|
||||||
<Mfm :text="announcement.text"/>
|
<Mfm :text="announcement.text"/>
|
||||||
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
|
<img v-if="announcement.imageUrl" :src="announcement.imageUrl"/>
|
||||||
<div style="opacity: 0.7; font-size: 85%;">
|
<MkA :to="`/announcements/${announcement.id}`">
|
||||||
<MkTime :time="announcement.updatedAt ?? announcement.createdAt" mode="detail"/>
|
<div style="margin-top: 8px; opacity: 0.7; font-size: 85%;">
|
||||||
</div>
|
{{ i18n.ts.createdAt }}: <MkTime :time="announcement.createdAt" mode="detail"/>
|
||||||
|
</div>
|
||||||
|
<div v-if="announcement.updatedAt" style="opacity: 0.7; font-size: 85%;">
|
||||||
|
{{ i18n.ts.updatedAt }}: <MkTime :time="announcement.updatedAt" mode="detail"/>
|
||||||
|
</div>
|
||||||
|
</MkA>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
|
<div v-if="tab !== 'past' && $i && !announcement.silence && !announcement.isRead" :class="$style.footer">
|
||||||
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
|
<MkButton primary @click="read(announcement)"><i class="ti ti-check"></i> {{ i18n.ts.gotIt }}</MkButton>
|
||||||
|
@ -73,24 +78,24 @@ const paginationEl = ref<InstanceType<typeof MkPagination>>();
|
||||||
|
|
||||||
const tab = ref('current');
|
const tab = ref('current');
|
||||||
|
|
||||||
async function read(announcement) {
|
async function read(target) {
|
||||||
if (announcement.needConfirmationToRead) {
|
if (target.needConfirmationToRead) {
|
||||||
const confirm = await os.confirm({
|
const confirm = await os.confirm({
|
||||||
type: 'question',
|
type: 'question',
|
||||||
title: i18n.ts._announcement.readConfirmTitle,
|
title: i18n.ts._announcement.readConfirmTitle,
|
||||||
text: i18n.tsx._announcement.readConfirmText({ title: announcement.title }),
|
text: i18n.tsx._announcement.readConfirmText({ title: target.title }),
|
||||||
});
|
});
|
||||||
if (confirm.canceled) return;
|
if (confirm.canceled) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!paginationEl.value) return;
|
if (!paginationEl.value) return;
|
||||||
paginationEl.value.updateItem(announcement.id, a => {
|
paginationEl.value.updateItem(target.id, a => {
|
||||||
a.isRead = true;
|
a.isRead = true;
|
||||||
return a;
|
return a;
|
||||||
});
|
});
|
||||||
misskeyApi('i/read-announcement', { announcementId: announcement.id });
|
misskeyApi('i/read-announcement', { announcementId: target.id });
|
||||||
updateAccount({
|
updateAccount({
|
||||||
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== announcement.id),
|
unreadAnnouncements: $i!.unreadAnnouncements.filter(a => a.id !== target.id),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -83,6 +83,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { deviceKind } from '@/scripts/device-kind.js';
|
import { deviceKind } from '@/scripts/device-kind.js';
|
||||||
import MkNotes from '@/components/MkNotes.vue';
|
import MkNotes from '@/components/MkNotes.vue';
|
||||||
import { url } from '@/config.js';
|
import { url } from '@/config.js';
|
||||||
|
import { favoritedChannelsCache } from '@/cache.js';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import MkInput from '@/components/MkInput.vue';
|
import MkInput from '@/components/MkInput.vue';
|
||||||
import { defaultStore } from '@/store.js';
|
import { defaultStore } from '@/store.js';
|
||||||
|
@ -153,6 +154,7 @@ function favorite() {
|
||||||
channelId: channel.value.id,
|
channelId: channel.value.id,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
favorited.value = true;
|
favorited.value = true;
|
||||||
|
favoritedChannelsCache.delete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,6 +170,7 @@ async function unfavorite() {
|
||||||
channelId: channel.value.id,
|
channelId: channel.value.id,
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
favorited.value = false;
|
favorited.value = false;
|
||||||
|
favoritedChannelsCache.delete();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -35,7 +35,16 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<FormSection v-if="iAmModerator">
|
<FormSection v-if="iAmModerator">
|
||||||
<template #label>Moderation</template>
|
<template #label>Moderation</template>
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
<MkSwitch v-model="suspended" :disabled="!instance" @update:modelValue="toggleSuspend">{{ i18n.ts.stopActivityDelivery }}</MkSwitch>
|
<MkKeyValue>
|
||||||
|
<template #key>
|
||||||
|
{{ i18n.ts._delivery.status }}
|
||||||
|
</template>
|
||||||
|
<template #value>
|
||||||
|
{{ i18n.ts._delivery._type[suspensionState] }}
|
||||||
|
</template>
|
||||||
|
</MkKeyValue>
|
||||||
|
<MkButton v-if="suspensionState === 'none'" :disabled="!instance" danger @click="stopDelivery">{{ i18n.ts._delivery.stop }}</MkButton>
|
||||||
|
<MkButton v-if="suspensionState !== 'none'" :disabled="!instance" @click="resumeDelivery">{{ i18n.ts._delivery.resume }}</MkButton>
|
||||||
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
|
<MkSwitch v-model="isBlocked" :disabled="!meta || !instance" @update:modelValue="toggleBlock">{{ i18n.ts.blockThisInstance }}</MkSwitch>
|
||||||
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
|
<MkSwitch v-model="isSilenced" :disabled="!meta || !instance" @update:modelValue="toggleSilenced">{{ i18n.ts.silenceThisInstance }}</MkSwitch>
|
||||||
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
|
<MkButton @click="refreshMetadata"><i class="ti ti-refresh"></i> Refresh metadata</MkButton>
|
||||||
|
@ -155,7 +164,7 @@ const tab = ref('overview');
|
||||||
const chartSrc = ref('instance-requests');
|
const chartSrc = ref('instance-requests');
|
||||||
const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
|
const meta = ref<Misskey.entities.AdminMetaResponse | null>(null);
|
||||||
const instance = ref<Misskey.entities.FederationInstance | null>(null);
|
const instance = ref<Misskey.entities.FederationInstance | null>(null);
|
||||||
const suspended = ref(false);
|
const suspensionState = ref<'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding'>('none');
|
||||||
const isBlocked = ref(false);
|
const isBlocked = ref(false);
|
||||||
const isSilenced = ref(false);
|
const isSilenced = ref(false);
|
||||||
const faviconUrl = ref<string | null>(null);
|
const faviconUrl = ref<string | null>(null);
|
||||||
|
@ -183,7 +192,7 @@ async function fetch(): Promise<void> {
|
||||||
instance.value = await misskeyApi('federation/show-instance', {
|
instance.value = await misskeyApi('federation/show-instance', {
|
||||||
host: props.host,
|
host: props.host,
|
||||||
});
|
});
|
||||||
suspended.value = instance.value?.isSuspended ?? false;
|
suspensionState.value = instance.value?.suspensionState ?? 'none';
|
||||||
isBlocked.value = instance.value?.isBlocked ?? false;
|
isBlocked.value = instance.value?.isBlocked ?? false;
|
||||||
isSilenced.value = instance.value?.isSilenced ?? false;
|
isSilenced.value = instance.value?.isSilenced ?? false;
|
||||||
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
|
faviconUrl.value = getProxiedImageUrlNullable(instance.value?.faviconUrl, 'preview') ?? getProxiedImageUrlNullable(instance.value?.iconUrl, 'preview');
|
||||||
|
@ -209,11 +218,21 @@ async function toggleSilenced(): Promise<void> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSuspend(): Promise<void> {
|
async function stopDelivery(): Promise<void> {
|
||||||
if (!instance.value) throw new Error('No instance?');
|
if (!instance.value) throw new Error('No instance?');
|
||||||
|
suspensionState.value = 'manuallySuspended';
|
||||||
await misskeyApi('admin/federation/update-instance', {
|
await misskeyApi('admin/federation/update-instance', {
|
||||||
host: instance.value.host,
|
host: instance.value.host,
|
||||||
isSuspended: suspended.value,
|
isSuspended: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeDelivery(): Promise<void> {
|
||||||
|
if (!instance.value) throw new Error('No instance?');
|
||||||
|
suspensionState.value = 'none';
|
||||||
|
await misskeyApi('admin/federation/update-instance', {
|
||||||
|
host: instance.value.host,
|
||||||
|
isSuspended: false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -50,9 +50,12 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
<div class="_gaps_m">
|
<div class="_gaps_m">
|
||||||
<div class="_gaps_s">
|
<div class="_gaps_s">
|
||||||
|
<MkSwitch v-model="collapseRenotes">
|
||||||
|
<template #label>{{ i18n.ts.collapseRenotes }}</template>
|
||||||
|
<template #caption>{{ i18n.ts.collapseRenotesDescription }}</template>
|
||||||
|
</MkSwitch>
|
||||||
<MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
|
<MkSwitch v-model="showNoteActionsOnlyHover">{{ i18n.ts.showNoteActionsOnlyHover }}</MkSwitch>
|
||||||
<MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch>
|
<MkSwitch v-model="showClipButtonInNoteFooter">{{ i18n.ts.showClipButtonInNoteFooter }}</MkSwitch>
|
||||||
<MkSwitch v-model="collapseRenotes">{{ i18n.ts.collapseRenotes }}</MkSwitch>
|
|
||||||
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
|
<MkSwitch v-model="advancedMfm">{{ i18n.ts.enableAdvancedMfm }}</MkSwitch>
|
||||||
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
|
<MkSwitch v-if="advancedMfm" v-model="animatedMfm">{{ i18n.ts.enableAnimatedMfm }}</MkSwitch>
|
||||||
<MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch>
|
<MkSwitch v-if="advancedMfm" v-model="enableQuickAddMfmFunction">{{ i18n.ts.enableQuickAddMfmFunction }}</MkSwitch>
|
||||||
|
|
|
@ -48,7 +48,7 @@ import { i18n } from '@/i18n.js';
|
||||||
import { instance } from '@/instance.js';
|
import { instance } from '@/instance.js';
|
||||||
import { $i } from '@/account.js';
|
import { $i } from '@/account.js';
|
||||||
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
import { definePageMetadata } from '@/scripts/page-metadata.js';
|
||||||
import { antennasCache, userListsCache } from '@/cache.js';
|
import { antennasCache, userListsCache, favoritedChannelsCache } from '@/cache.js';
|
||||||
import { deviceKind } from '@/scripts/device-kind.js';
|
import { deviceKind } from '@/scripts/device-kind.js';
|
||||||
import { deepMerge } from '@/scripts/merge.js';
|
import { deepMerge } from '@/scripts/merge.js';
|
||||||
import { MenuItem } from '@/types/menu.js';
|
import { MenuItem } from '@/types/menu.js';
|
||||||
|
@ -173,9 +173,7 @@ async function chooseAntenna(ev: MouseEvent): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function chooseChannel(ev: MouseEvent): Promise<void> {
|
async function chooseChannel(ev: MouseEvent): Promise<void> {
|
||||||
const channels = await misskeyApi('channels/my-favorites', {
|
const channels = await favoritedChannelsCache.fetch();
|
||||||
limit: 100,
|
|
||||||
});
|
|
||||||
const items: MenuItem[] = [
|
const items: MenuItem[] = [
|
||||||
...channels.map(channel => {
|
...channels.map(channel => {
|
||||||
const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null;
|
const lastReadedAt = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.id}`) ?? null;
|
||||||
|
|
|
@ -193,6 +193,9 @@ const routes: RouteDef[] = [{
|
||||||
}, {
|
}, {
|
||||||
path: '/announcements',
|
path: '/announcements',
|
||||||
component: page(() => import('@/pages/announcements.vue')),
|
component: page(() => import('@/pages/announcements.vue')),
|
||||||
|
}, {
|
||||||
|
path: '/announcements/:announcementId',
|
||||||
|
component: page(() => import('@/pages/announcement.vue')),
|
||||||
}, {
|
}, {
|
||||||
path: '/about',
|
path: '/about',
|
||||||
component: page(() => import('@/pages/about.vue')),
|
component: page(() => import('@/pages/about.vue')),
|
||||||
|
|
|
@ -63,3 +63,26 @@ export async function lookupUserByEmail() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function lookupFile() {
|
||||||
|
const { canceled, result: q } = await os.inputText({
|
||||||
|
title: i18n.ts.fileIdOrUrl,
|
||||||
|
minLength: 1,
|
||||||
|
});
|
||||||
|
if (canceled) return;
|
||||||
|
|
||||||
|
const show = (file) => {
|
||||||
|
os.pageWindow(`/admin/file/${file.id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
misskeyApi('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => {
|
||||||
|
show(file);
|
||||||
|
}).catch(err => {
|
||||||
|
if (err.code === 'NO_SUCH_FILE') {
|
||||||
|
os.alert({
|
||||||
|
type: 'error',
|
||||||
|
text: i18n.ts.notFound,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -16,7 +16,7 @@ import { url } from '@/config.js';
|
||||||
import { defaultStore, noteActions } from '@/store.js';
|
import { defaultStore, noteActions } from '@/store.js';
|
||||||
import { miLocalStorage } from '@/local-storage.js';
|
import { miLocalStorage } from '@/local-storage.js';
|
||||||
import { getUserMenu } from '@/scripts/get-user-menu.js';
|
import { getUserMenu } from '@/scripts/get-user-menu.js';
|
||||||
import { clipsCache } from '@/cache.js';
|
import { clipsCache, favoritedChannelsCache } from '@/cache.js';
|
||||||
import { MenuItem } from '@/types/menu.js';
|
import { MenuItem } from '@/types/menu.js';
|
||||||
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
import MkRippleEffect from '@/components/MkRippleEffect.vue';
|
||||||
import { isSupportShare } from '@/scripts/navigator.js';
|
import { isSupportShare } from '@/scripts/navigator.js';
|
||||||
|
@ -603,9 +603,7 @@ export function getRenoteMenu(props: {
|
||||||
icon: 'ti ti-repeat',
|
icon: 'ti ti-repeat',
|
||||||
text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
|
text: appearNote.channel ? i18n.ts.renoteToOtherChannel : i18n.ts.renoteToChannel,
|
||||||
children: async () => {
|
children: async () => {
|
||||||
const channels = await misskeyApi('channels/my-favorites', {
|
const channels = await favoritedChannelsCache.fetch();
|
||||||
limit: 30,
|
|
||||||
});
|
|
||||||
return channels.filter((channel) => {
|
return channels.filter((channel) => {
|
||||||
if (!appearNote.channelId) return true;
|
if (!appearNote.channelId) return true;
|
||||||
return channel.id !== appearNote.channelId;
|
return channel.id !== appearNote.channelId;
|
||||||
|
|
|
@ -9,7 +9,7 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')"
|
v-for="announcement in $i.unreadAnnouncements.filter(x => x.display === 'banner')"
|
||||||
:key="announcement.id"
|
:key="announcement.id"
|
||||||
:class="$style.item"
|
:class="$style.item"
|
||||||
to="/announcements"
|
:to="`/announcements/${announcement.id}`"
|
||||||
>
|
>
|
||||||
<span :class="$style.icon">
|
<span :class="$style.icon">
|
||||||
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
|
<i v-if="announcement.icon === 'info'" class="ti ti-info-circle"></i>
|
||||||
|
|
|
@ -26,6 +26,7 @@ import { updateColumn, Column } from './deck-store.js';
|
||||||
import MkTimeline from '@/components/MkTimeline.vue';
|
import MkTimeline from '@/components/MkTimeline.vue';
|
||||||
import MkButton from '@/components/MkButton.vue';
|
import MkButton from '@/components/MkButton.vue';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
import { favoritedChannelsCache } from '@/cache.js';
|
||||||
import { misskeyApi } from '@/scripts/misskey-api.js';
|
import { misskeyApi } from '@/scripts/misskey-api.js';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
|
|
||||||
|
@ -42,20 +43,18 @@ if (props.column.channelId == null) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setChannel() {
|
async function setChannel() {
|
||||||
const channels = await misskeyApi('channels/my-favorites', {
|
const channels = await favoritedChannelsCache.fetch();
|
||||||
limit: 100,
|
const { canceled, result: chosenChannel } = await os.select({
|
||||||
});
|
|
||||||
const { canceled, result: channel } = await os.select({
|
|
||||||
title: i18n.ts.selectChannel,
|
title: i18n.ts.selectChannel,
|
||||||
items: channels.map(x => ({
|
items: channels.map(x => ({
|
||||||
value: x, text: x.name,
|
value: x, text: x.name,
|
||||||
})),
|
})),
|
||||||
default: props.column.channelId,
|
default: props.column.channelId,
|
||||||
});
|
});
|
||||||
if (canceled) return;
|
if (canceled || chosenChannel == null) return;
|
||||||
updateColumn(props.column.id, {
|
updateColumn(props.column.id, {
|
||||||
channelId: channel.id,
|
channelId: chosenChannel.id,
|
||||||
name: channel.name,
|
name: chosenChannel.name,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -336,6 +336,12 @@ type AnnouncementsRequest = operations['announcements']['requestBody']['content'
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
|
type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
// @public (undocumented)
|
||||||
|
type AnnouncementsShowResponse = operations['announcements___show']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type Antenna = components['schemas']['Antenna'];
|
type Antenna = components['schemas']['Antenna'];
|
||||||
|
|
||||||
|
@ -1224,6 +1230,8 @@ declare namespace entities {
|
||||||
AdminRolesUsersResponse,
|
AdminRolesUsersResponse,
|
||||||
AnnouncementsRequest,
|
AnnouncementsRequest,
|
||||||
AnnouncementsResponse,
|
AnnouncementsResponse,
|
||||||
|
AnnouncementsShowRequest,
|
||||||
|
AnnouncementsShowResponse,
|
||||||
AntennasCreateRequest,
|
AntennasCreateRequest,
|
||||||
AntennasCreateResponse,
|
AntennasCreateResponse,
|
||||||
AntennasDeleteRequest,
|
AntennasDeleteRequest,
|
||||||
|
@ -2626,7 +2634,7 @@ type PagesUpdateRequest = operations['pages___update']['requestBody']['content']
|
||||||
function parse(acct: string): Acct;
|
function parse(acct: string): Acct;
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "read:admin:show-users", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
|
export const permissions: readonly ["read:account", "write:account", "read:blocks", "write:blocks", "read:drive", "write:drive", "read:favorites", "write:favorites", "read:following", "write:following", "read:messaging", "write:messaging", "read:mutes", "write:mutes", "write:notes", "read:notifications", "write:notifications", "read:reactions", "write:reactions", "write:votes", "read:pages", "write:pages", "write:page-likes", "read:page-likes", "read:user-groups", "write:user-groups", "read:channels", "write:channels", "read:gallery", "write:gallery", "read:gallery-likes", "write:gallery-likes", "read:flash", "write:flash", "read:flash-likes", "write:flash-likes", "read:admin:abuse-user-reports", "write:admin:delete-account", "write:admin:delete-all-files-of-a-user", "read:admin:index-stats", "read:admin:table-stats", "read:admin:user-ips", "read:admin:meta", "write:admin:reset-password", "write:admin:resolve-abuse-user-report", "write:admin:send-email", "read:admin:server-info", "read:admin:show-moderation-log", "read:admin:show-user", "write:admin:suspend-user", "write:admin:unset-user-avatar", "write:admin:unset-user-banner", "write:admin:unsuspend-user", "write:admin:meta", "write:admin:user-note", "write:admin:roles", "read:admin:roles", "write:admin:relays", "read:admin:relays", "write:admin:invite-codes", "read:admin:invite-codes", "write:admin:announcements", "read:admin:announcements", "write:admin:avatar-decorations", "read:admin:avatar-decorations", "write:admin:federation", "write:admin:account", "read:admin:account", "write:admin:emoji", "read:admin:emoji", "write:admin:queue", "read:admin:queue", "write:admin:promo", "write:admin:drive", "read:admin:drive", "write:admin:ad", "read:admin:ad", "write:invite-codes", "read:invite-codes", "write:clip-favorite", "read:clip-favorite", "read:federation", "write:report-abuse"];
|
||||||
|
|
||||||
// @public (undocumented)
|
// @public (undocumented)
|
||||||
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
type PingResponse = operations['ping']['responses']['200']['content']['application/json'];
|
||||||
|
|
|
@ -678,7 +678,7 @@ declare module '../api.js' {
|
||||||
/**
|
/**
|
||||||
* No description provided.
|
* No description provided.
|
||||||
*
|
*
|
||||||
* **Credential required**: *Yes* / **Permission**: *read:admin:show-users*
|
* **Credential required**: *Yes* / **Permission**: *read:admin:show-user*
|
||||||
*/
|
*/
|
||||||
request<E extends 'admin/show-users', P extends Endpoints[E]['req']>(
|
request<E extends 'admin/show-users', P extends Endpoints[E]['req']>(
|
||||||
endpoint: E,
|
endpoint: E,
|
||||||
|
@ -851,6 +851,17 @@ declare module '../api.js' {
|
||||||
credential?: string | null,
|
credential?: string | null,
|
||||||
): Promise<SwitchCaseResponseType<E, P>>;
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *No*
|
||||||
|
*/
|
||||||
|
request<E extends 'announcements/show', P extends Endpoints[E]['req']>(
|
||||||
|
endpoint: E,
|
||||||
|
params: P,
|
||||||
|
credential?: string | null,
|
||||||
|
): Promise<SwitchCaseResponseType<E, P>>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* No description provided.
|
* No description provided.
|
||||||
*
|
*
|
||||||
|
|
|
@ -101,6 +101,8 @@ import type {
|
||||||
AdminRolesUsersResponse,
|
AdminRolesUsersResponse,
|
||||||
AnnouncementsRequest,
|
AnnouncementsRequest,
|
||||||
AnnouncementsResponse,
|
AnnouncementsResponse,
|
||||||
|
AnnouncementsShowRequest,
|
||||||
|
AnnouncementsShowResponse,
|
||||||
AntennasCreateRequest,
|
AntennasCreateRequest,
|
||||||
AntennasCreateResponse,
|
AntennasCreateResponse,
|
||||||
AntennasDeleteRequest,
|
AntennasDeleteRequest,
|
||||||
|
@ -631,6 +633,7 @@ export type Endpoints = {
|
||||||
'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse };
|
'admin/roles/update-default-policies': { req: AdminRolesUpdateDefaultPoliciesRequest; res: EmptyResponse };
|
||||||
'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse };
|
'admin/roles/users': { req: AdminRolesUsersRequest; res: AdminRolesUsersResponse };
|
||||||
'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse };
|
'announcements': { req: AnnouncementsRequest; res: AnnouncementsResponse };
|
||||||
|
'announcements/show': { req: AnnouncementsShowRequest; res: AnnouncementsShowResponse };
|
||||||
'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse };
|
'antennas/create': { req: AntennasCreateRequest; res: AntennasCreateResponse };
|
||||||
'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse };
|
'antennas/delete': { req: AntennasDeleteRequest; res: EmptyResponse };
|
||||||
'antennas/list': { req: EmptyRequest; res: AntennasListResponse };
|
'antennas/list': { req: EmptyRequest; res: AntennasListResponse };
|
||||||
|
|
|
@ -104,6 +104,8 @@ export type AdminRolesUsersRequest = operations['admin___roles___users']['reques
|
||||||
export type AdminRolesUsersResponse = operations['admin___roles___users']['responses']['200']['content']['application/json'];
|
export type AdminRolesUsersResponse = operations['admin___roles___users']['responses']['200']['content']['application/json'];
|
||||||
export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json'];
|
export type AnnouncementsRequest = operations['announcements']['requestBody']['content']['application/json'];
|
||||||
export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
|
export type AnnouncementsResponse = operations['announcements']['responses']['200']['content']['application/json'];
|
||||||
|
export type AnnouncementsShowRequest = operations['announcements___show']['requestBody']['content']['application/json'];
|
||||||
|
export type AnnouncementsShowResponse = operations['announcements___show']['responses']['200']['content']['application/json'];
|
||||||
export type AntennasCreateRequest = operations['antennas___create']['requestBody']['content']['application/json'];
|
export type AntennasCreateRequest = operations['antennas___create']['requestBody']['content']['application/json'];
|
||||||
export type AntennasCreateResponse = operations['antennas___create']['responses']['200']['content']['application/json'];
|
export type AntennasCreateResponse = operations['antennas___create']['responses']['200']['content']['application/json'];
|
||||||
export type AntennasDeleteRequest = operations['antennas___delete']['requestBody']['content']['application/json'];
|
export type AntennasDeleteRequest = operations['antennas___delete']['requestBody']['content']['application/json'];
|
||||||
|
|
|
@ -567,7 +567,7 @@ export type paths = {
|
||||||
* admin/show-users
|
* admin/show-users
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
*
|
*
|
||||||
* **Credential required**: *Yes* / **Permission**: *read:admin:show-users*
|
* **Credential required**: *Yes* / **Permission**: *read:admin:show-user*
|
||||||
*/
|
*/
|
||||||
post: operations['admin___show-users'];
|
post: operations['admin___show-users'];
|
||||||
};
|
};
|
||||||
|
@ -706,6 +706,15 @@ export type paths = {
|
||||||
*/
|
*/
|
||||||
post: operations['announcements'];
|
post: operations['announcements'];
|
||||||
};
|
};
|
||||||
|
'/announcements/show': {
|
||||||
|
/**
|
||||||
|
* announcements/show
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *No*
|
||||||
|
*/
|
||||||
|
post: operations['announcements___show'];
|
||||||
|
};
|
||||||
'/antennas/create': {
|
'/antennas/create': {
|
||||||
/**
|
/**
|
||||||
* antennas/create
|
* antennas/create
|
||||||
|
@ -4475,6 +4484,8 @@ export type components = {
|
||||||
followersCount: number;
|
followersCount: number;
|
||||||
isNotResponding: boolean;
|
isNotResponding: boolean;
|
||||||
isSuspended: boolean;
|
isSuspended: boolean;
|
||||||
|
/** @enum {string} */
|
||||||
|
suspensionState: 'none' | 'manuallySuspended' | 'goneSuspended' | 'autoSuspendedForNotResponding';
|
||||||
isBlocked: boolean;
|
isBlocked: boolean;
|
||||||
/** @example misskey */
|
/** @example misskey */
|
||||||
softwareName: string | null;
|
softwareName: string | null;
|
||||||
|
@ -8645,7 +8656,7 @@ export type operations = {
|
||||||
* admin/show-users
|
* admin/show-users
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
*
|
*
|
||||||
* **Credential required**: *Yes* / **Permission**: *read:admin:show-users*
|
* **Credential required**: *Yes* / **Permission**: *read:admin:show-user*
|
||||||
*/
|
*/
|
||||||
'admin___show-users': {
|
'admin___show-users': {
|
||||||
requestBody: {
|
requestBody: {
|
||||||
|
@ -9660,6 +9671,60 @@ export type operations = {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
/**
|
||||||
|
* announcements/show
|
||||||
|
* @description No description provided.
|
||||||
|
*
|
||||||
|
* **Credential required**: *No*
|
||||||
|
*/
|
||||||
|
announcements___show: {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
/** Format: misskey:id */
|
||||||
|
announcementId: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description OK (with results) */
|
||||||
|
200: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Announcement'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Client error */
|
||||||
|
400: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Authentication error */
|
||||||
|
401: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Forbidden error */
|
||||||
|
403: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description I'm Ai */
|
||||||
|
418: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Internal server error */
|
||||||
|
500: {
|
||||||
|
content: {
|
||||||
|
'application/json': components['schemas']['Error'];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
/**
|
/**
|
||||||
* antennas/create
|
* antennas/create
|
||||||
* @description No description provided.
|
* @description No description provided.
|
||||||
|
|
|
@ -58,7 +58,6 @@ export const permissions = [
|
||||||
'read:admin:server-info',
|
'read:admin:server-info',
|
||||||
'read:admin:show-moderation-log',
|
'read:admin:show-moderation-log',
|
||||||
'read:admin:show-user',
|
'read:admin:show-user',
|
||||||
'read:admin:show-users',
|
|
||||||
'write:admin:suspend-user',
|
'write:admin:suspend-user',
|
||||||
'write:admin:unset-user-avatar',
|
'write:admin:unset-user-avatar',
|
||||||
'write:admin:unset-user-banner',
|
'write:admin:unset-user-banner',
|
||||||
|
|
Loading…
Reference in a new issue