Merge branch 'develop' into fetch-outbox

This commit is contained in:
tamaina 2023-07-15 14:04:54 +00:00
commit 6b26ce3768
68 changed files with 2450 additions and 966 deletions

View file

@ -104,6 +104,7 @@ redis:
# apiKey: ''
# ssl: true
# index: ''
# scope: local
# ┌───────────────┐
#───┘ ID generation └───────────────────────────────────────────

View file

@ -17,6 +17,10 @@
### General
- identicon生成を無効にしてパフォーマンスを向上させることができるようになりました
- サーバーのマシン情報の公開を無効にしてパフォーマンスを向上させることができるようになりました
- 招待機能を改善しました
* 過去に発行した招待コードを確認できるようになりました
* ロールごとに招待コードの発行数制限と制限対象期間、有効期限を設定できるようになりました
* 招待コードを作成したユーザーと使用したユーザーを確認できるようになりました
### Client
- deck UIのカラムのメニューからアンテナとリストの編集画面を開けるように
@ -41,13 +45,19 @@
- Fix: フォルダーのページネーションが機能しない #11180
- Fix: 長い文章を投稿する際、プレビューが画面からはみ出る問題を修正
- Fix: システムフォント設定が正しく反映されない問題を修正
- Fix: アンケート終了時のプッシュ通知が正しく表示されない問題を修正
- Fix: MasterVolumeが0の時だけでなく各通知音の音量設定が0のときも、HTMLAudioElement.playが実行されないように変更
### Server
- JSON.parse の回数を削減することで、ストリーミングのパフォーマンスを向上しました
- nsfwjs のモデルロードを排他することで、重複ロードによってメモリ使用量が増加しないように
- 連合の配送ジョブのパフォーマンスを向上ロック機構の見直し、Redisキャッシュの活用
- 全体的なDBクエリのパフォーマンスを向上
- featuredートのsignedGet回数を減らしました
- リモートサーバーからのNSFW映像のキャッシュだけを無効化できるオプションを追加
- MeilisearchにIndexするートの範囲を設定できるように
- Fix: Remove Meilisearch index when notes are deleted
- Fix: 非英語環境でのPostgreSQLのエラーハンドリングを修正
- Fix: インスタンスのアイコンがbase64の場合の挙動を修正
## 13.13.2

View file

@ -51,11 +51,7 @@ export default function generateDTS() {
ts.NodeFlags.Const | ts.NodeFlags.Ambient | ts.NodeFlags.ContextFlags,
),
),
ts.factory.createExportAssignment(
undefined,
true,
ts.factory.createIdentifier('locales'),
),
ts.factory.createExportDefault(ts.factory.createIdentifier('locales')),
];
const printed = ts.createPrinter({
newLine: ts.NewLineKind.LineFeed,

22
locales/index.d.ts vendored
View file

@ -159,6 +159,8 @@ export interface Locale {
"settingGuide": string;
"cacheRemoteFiles": string;
"cacheRemoteFilesDescription": string;
"cacheRemoteSensitiveFiles": string;
"cacheRemoteSensitiveFilesDescription": string;
"flagAsBot": string;
"flagAsBotDescription": string;
"flagAsCat": string;
@ -1075,6 +1077,23 @@ export interface Locale {
"enableServerMachineStats": string;
"enableIdenticonGeneration": string;
"turnOffToImprovePerformance": string;
"createInviteCode": string;
"createWithOptions": string;
"createCount": string;
"inviteCodeCreated": string;
"inviteLimitExceeded": string;
"createLimitRemaining": string;
"inviteLimitResetCycle": string;
"expirationDate": string;
"noExpirationDate": string;
"inviteCodeUsedAt": string;
"registeredUserUsingInviteCode": string;
"waitingForMailAuth": string;
"inviteCodeCreator": string;
"usedAt": string;
"unused": string;
"used": string;
"expired": string;
"_initialAccountSetting": {
"accountCreated": string;
"letsStartAccountSetup": string;
@ -1465,6 +1484,9 @@ export interface Locale {
"ltlAvailable": string;
"canPublicNote": string;
"canInvite": string;
"inviteLimit": string;
"inviteLimitCycle": string;
"inviteExpirationTime": string;
"canManageCustomEmojis": string;
"driveCapacity": string;
"alwaysMarkNsfw": string;

View file

@ -156,6 +156,8 @@ addEmoji: "絵文字を追加"
settingGuide: "おすすめ設定"
cacheRemoteFiles: "リモートのファイルをキャッシュする"
cacheRemoteFilesDescription: "この設定を無効にすると、リモートファイルをキャッシュせず直リンクするようになります。サーバーのストレージを節約できますが、サムネイルが生成されないので通信量が増加します。"
cacheRemoteSensitiveFiles: "リモートのセンシティブなファイルをキャッシュする"
cacheRemoteSensitiveFilesDescription: "この設定を無効にすると、リモートのセンシティブなファイルはキャッシュせず直リンクするようになります。"
flagAsBot: "Botとして設定"
flagAsBotDescription: "このアカウントがプログラムによって運用される場合は、このフラグをオンにします。オンにすると、反応の連鎖を防ぐためのフラグとして他の開発者に役立ったり、Misskeyのシステム上での扱いがBotに合ったものになります。"
flagAsCat: "にゃああああああああああああああ!!!!!!!!!!!!"
@ -1072,6 +1074,23 @@ branding: "ブランディング"
enableServerMachineStats: "サーバーのマシン情報を公開する"
enableIdenticonGeneration: "ユーザーごとのIdenticon生成を有効にする"
turnOffToImprovePerformance: "オフにするとパフォーマンスが向上します。"
createInviteCode: "招待コードを作成"
createWithOptions: "オプションを指定して作成"
createCount: "作成数"
inviteCodeCreated: "招待コードを作成しました"
inviteLimitExceeded: "作成できる招待コードの数が上限に達しています。"
createLimitRemaining: "作成できる招待コード: 残り {limit} 個"
inviteLimitResetCycle: "{time}で最大 {limit} 個の招待コードを作成できます。"
expirationDate: "有効期限"
noExpirationDate: "有効期限を設けない"
inviteCodeUsedAt: "招待コードが使用された日時"
registeredUserUsingInviteCode: "招待コードを使用したユーザー"
waitingForMailAuth: "メール認証待ち"
inviteCodeCreator: "招待コードを作成したユーザー"
usedAt: "使用日時"
unused: "未使用"
used: "使用済み"
expired: "期限切れ"
_initialAccountSetting:
accountCreated: "アカウントの作成が完了しました!"
@ -1387,6 +1406,9 @@ _role:
ltlAvailable: "ローカルタイムラインの閲覧"
canPublicNote: "パブリック投稿の許可"
canInvite: "サーバー招待コードの発行"
inviteLimit: "招待コードの作成可能数"
inviteLimitCycle: "招待コードの発行間隔"
inviteExpirationTime: "招待コードの有効期限"
canManageCustomEmojis: "カスタム絵文字の管理"
driveCapacity: "ドライブ容量"
alwaysMarkNsfw: "ファイルにNSFWを常に付与"

View file

@ -1,6 +1,6 @@
{
"name": "misskey",
"version": "13.14.0-beta.2",
"version": "13.14.0-beta.3",
"codename": "nasubi",
"repository": {
"type": "git",
@ -59,8 +59,8 @@
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",
"cross-env": "7.0.3",
"cypress": "12.17.0",
"eslint": "8.44.0",
"cypress": "12.17.1",
"eslint": "8.45.0",
"start-server-and-test": "2.0.0"
},
"optionalDependencies": {

View file

@ -0,0 +1,25 @@
export class RefactorInviteSystem1688720440658 {
name = 'RefactorInviteSystem1688720440658'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "expiresAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "pendingUserId" character varying(32)`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "createdById" character varying(32)`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD "usedById" character varying(32)`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189" UNIQUE ("usedById")`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_beba993576db0261a15364ea96e" FOREIGN KEY ("createdById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "registration_ticket" ADD CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189" FOREIGN KEY ("usedById") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_b6f93f2f30bdbb9a5ebdc7c7189"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "FK_beba993576db0261a15364ea96e"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP CONSTRAINT "UQ_b6f93f2f30bdbb9a5ebdc7c7189"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedById"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "createdById"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "pendingUserId"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "usedAt"`);
await queryRunner.query(`ALTER TABLE "registration_ticket" DROP COLUMN "expiresAt"`);
}
}

View file

@ -0,0 +1,13 @@
export class AddIndexToRelations1688880985544 {
name = 'AddIndexToRelations1688880985544'
async up(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_beba993576db0261a15364ea96" ON "registration_ticket" ("createdById") `);
await queryRunner.query(`CREATE INDEX "IDX_b6f93f2f30bdbb9a5ebdc7c718" ON "registration_ticket" ("usedById") `);
}
async down(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_b6f93f2f30bdbb9a5ebdc7c718"`);
await queryRunner.query(`DROP INDEX "public"."IDX_beba993576db0261a15364ea96"`);
}
}

View file

@ -0,0 +1,11 @@
export class NsfwCache1689102832143 {
name = 'NsfwCache1689102832143'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "cacheRemoteSensitiveFiles" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "cacheRemoteSensitiveFiles"`);
}
}

View file

@ -74,7 +74,7 @@
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.3.0",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.68",
"@swc/core": "1.3.69",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
@ -82,11 +82,11 @@
"autwh": "0.1.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"bullmq": "4.2.0",
"bullmq": "4.3.0",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.0",
"chalk": "5.2.0",
"chalk-template": "0.4.0",
"chalk": "5.3.0",
"chalk-template": "1.1.0",
"chokidar": "3.5.3",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
@ -100,7 +100,7 @@
"fluent-ffmpeg": "2.1.2",
"form-data": "4.0.0",
"got": "13.0.0",
"happy-dom": "10.0.3",
"happy-dom": "10.3.2",
"hpagent": "1.2.0",
"ioredis": "5.3.2",
"ip-cidr": "3.1.0",
@ -141,14 +141,14 @@
"rxjs": "7.8.1",
"s-age": "1.1.2",
"sanitize-html": "2.11.0",
"semver": "7.5.3",
"sharp": "0.32.1",
"semver": "7.5.4",
"sharp": "0.32.3",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.9",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.18.6",
"systeminformation": "5.18.7",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.7",
@ -158,7 +158,6 @@
"typescript": "5.1.6",
"ulid": "2.3.0",
"unzipper": "0.10.14",
"uuid": "9.0.0",
"vary": "1.1.2",
"web-push": "3.6.3",
"ws": "8.13.0",
@ -175,14 +174,14 @@
"@types/content-disposition": "0.5.5",
"@types/escape-regexp": "0.0.1",
"@types/fluent-ffmpeg": "2.1.21",
"@types/jest": "29.5.2",
"@types/jest": "29.5.3",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "21.1.1",
"@types/jsonld": "1.5.9",
"@types/jsrsasign": "10.5.8",
"@types/mime-types": "2.1.1",
"@types/ms": "^0.7.31",
"@types/node": "20.4.0",
"@types/node": "20.4.2",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.8",
"@types/oauth": "0.9.1",
@ -201,7 +200,6 @@
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/unzipper": "0.10.6",
"@types/uuid": "9.0.2",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/websocket": "1.0.5",
@ -210,7 +208,7 @@
"@typescript-eslint/parser": "5.61.0",
"aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3",
"eslint": "8.44.0",
"eslint": "8.45.0",
"eslint-plugin-import": "2.27.5",
"execa": "7.1.1",
"jest": "29.6.1",

View file

@ -63,6 +63,7 @@ export type Source = {
apiKey: string;
ssl?: boolean;
index: string;
scope?: 'local' | 'global' | string[];
};
proxy?: string;

View file

@ -81,6 +81,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
import { HashtagEntityService } from './entities/HashtagEntityService.js';
import { InstanceEntityService } from './entities/InstanceEntityService.js';
import { InviteCodeEntityService } from './entities/InviteCodeEntityService.js';
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
import { MutingEntityService } from './entities/MutingEntityService.js';
import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js';
@ -205,6 +206,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService };
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService };
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService };
const $InviteCodeEntityService: Provider = { provide: 'InviteCodeEntityService', useExisting: InviteCodeEntityService };
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService };
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService };
const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService };
@ -329,6 +331,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@ -448,6 +451,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,
@ -567,6 +571,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
GalleryPostEntityService,
HashtagEntityService,
InstanceEntityService,
InviteCodeEntityService,
ModerationLogEntityService,
MutingEntityService,
RenoteMutingEntityService,
@ -685,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$GalleryPostEntityService,
$HashtagEntityService,
$InstanceEntityService,
$InviteCodeEntityService,
$ModerationLogEntityService,
$MutingEntityService,
$RenoteMutingEntityService,

View file

@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { v4 as uuid } from 'uuid';
import { IsNull, DataSource } from 'typeorm';
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
import { User } from '@/models/entities/User.js';
@ -24,7 +24,7 @@ export class CreateSystemUserService {
@bindThis
public async createSystemUser(username: string): Promise<User> {
const password = uuid();
const password = randomUUID();
// Generate hash of password
const salt = await bcrypt.genSalt(8);

View file

@ -1,6 +1,6 @@
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import sharp from 'sharp';
import { sharpBmp } from 'sharp-read-bmp';
import { IsNull } from 'typeorm';
@ -162,7 +162,7 @@ export class DriveService {
?? `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
// for original
const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`;
const key = `${meta.objectStoragePrefix}/${randomUUID()}${ext}`;
const url = `${ baseUrl }/${ key }`;
// for alts
@ -179,7 +179,7 @@ export class DriveService {
];
if (alts.webpublic) {
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`;
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${randomUUID()}.${alts.webpublic.ext}`;
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
this.registerLogger.info(`uploading webpublic: ${webpublicKey}`);
@ -187,7 +187,7 @@ export class DriveService {
}
if (alts.thumbnail) {
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`;
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${randomUUID()}.${alts.thumbnail.ext}`;
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
this.registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
@ -212,9 +212,9 @@ export class DriveService {
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
} else { // use internal storage
const accessKey = uuid();
const thumbnailAccessKey = 'thumbnail-' + uuid();
const webpublicAccessKey = 'webpublic-' + uuid();
const accessKey = randomUUID();
const thumbnailAccessKey = 'thumbnail-' + randomUUID();
const webpublicAccessKey = 'webpublic-' + randomUUID();
const url = this.internalStorageService.saveFromPath(accessKey, path);
@ -584,9 +584,9 @@ export class DriveService {
if (isLink) {
file.url = url;
// ローカルプロキシ用
file.accessKey = uuid();
file.thumbnailAccessKey = 'thumbnail-' + uuid();
file.webpublicAccessKey = 'webpublic-' + uuid();
file.accessKey = randomUUID();
file.thumbnailAccessKey = 'thumbnail-' + randomUUID();
file.webpublicAccessKey = 'webpublic-' + randomUUID();
}
}
@ -713,9 +713,9 @@ export class DriveService {
webpublicUrl: null,
storedInternal: false,
// ローカルプロキシ用
accessKey: uuid(),
thumbnailAccessKey: 'thumbnail-' + uuid(),
webpublicAccessKey: 'webpublic-' + uuid(),
accessKey: randomUUID(),
thumbnailAccessKey: 'thumbnail-' + randomUUID(),
webpublicAccessKey: 'webpublic-' + randomUUID(),
});
} else {
this.driveFilesRepository.delete(file.id);

View file

@ -1,5 +1,6 @@
import * as http from 'node:http';
import * as https from 'node:https';
import * as net from 'node:net';
import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch';
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
@ -46,14 +47,14 @@ export class HttpRequestService {
this.http = new http.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup,
} as http.AgentOptions);
lookup: cache.lookup as unknown as net.LookupFunction,
});
this.https = new https.Agent({
keepAlive: true,
keepAliveMsecs: 30 * 1000,
lookup: cache.lookup,
} as https.AgentOptions);
lookup: cache.lookup as unknown as net.LookupFunction,
});
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
@ -144,7 +145,7 @@ export class HttpRequestService {
method: args.method ?? 'GET',
headers: {
'User-Agent': this.config.userAgent,
...(args.headers ?? {})
...(args.headers ?? {}),
},
body: args.body,
size: args.size ?? 10 * 1024 * 1024,

View file

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import type { IActivity } from '@/core/activitypub/type.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
@ -416,7 +416,7 @@ export class QueueService {
to: webhook.url,
secret: webhook.secret,
createdAt: Date.now(),
eventId: uuid(),
eventId: randomUUID(),
};
return this.webhookDeliverQueue.add(webhook.id, data, {

View file

@ -71,7 +71,7 @@ export class RemoteUserResolveService {
return await this.apPersonService.createPerson(self.href);
}
// ユーザー情報が古い場合は、WebFilgerからやりなおして返す
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
if (user.lastFetchedAt == null || Date.now() - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
await this.usersRepository.update(user.id, {

View file

@ -21,6 +21,9 @@ export type RolePolicies = {
ltlAvailable: boolean;
canPublicNote: boolean;
canInvite: boolean;
inviteLimit: number;
inviteLimitCycle: number;
inviteExpirationTime: number;
canManageCustomEmojis: boolean;
canSearchNotes: boolean;
canHideAds: boolean;
@ -42,6 +45,9 @@ export const DEFAULT_POLICIES: RolePolicies = {
ltlAvailable: true,
canPublicNote: true,
canInvite: false,
inviteLimit: 0,
inviteLimitCycle: 60 * 24 * 7,
inviteExpirationTime: 0,
canManageCustomEmojis: false,
canSearchNotes: false,
canHideAds: false,
@ -277,6 +283,9 @@ export class RoleService implements OnApplicationShutdown {
ltlAvailable: calc('ltlAvailable', vs => vs.some(v => v === true)),
canPublicNote: calc('canPublicNote', vs => vs.some(v => v === true)),
canInvite: calc('canInvite', vs => vs.some(v => v === true)),
inviteLimit: calc('inviteLimit', vs => Math.max(...vs)),
inviteLimitCycle: calc('inviteLimitCycle', vs => Math.max(...vs)),
inviteExpirationTime: calc('inviteExpirationTime', vs => Math.max(...vs)),
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),

View file

@ -52,6 +52,7 @@ function compileQuery(q: Q): string {
@Injectable()
export class SearchService {
private readonly meilisearchIndexScope: 'local' | 'global' | string[] = 'local';
private meilisearchNoteIndex: Index | null = null;
constructor(
@ -92,6 +93,10 @@ export class SearchService {
},
});
}
if (config.meilisearch?.scope) {
this.meilisearchIndexScope = config.meilisearch.scope;
}
}
@bindThis
@ -100,7 +105,22 @@ export class SearchService {
if (!['home', 'public'].includes(note.visibility)) return;
if (this.meilisearch) {
this.meilisearchNoteIndex!.addDocuments([{
switch (this.meilisearchIndexScope) {
case 'global':
break;
case 'local':
if (note.userHost == null) break;
return;
default: {
if (note.userHost == null) break;
if (this.meilisearchIndexScope.includes(note.userHost)) break;
return;
}
}
await this.meilisearchNoteIndex?.addDocuments([{
id: note.id,
createdAt: note.createdAt.getTime(),
userId: note.userId,

View file

@ -1,7 +1,6 @@
import { createPublicKey } from 'node:crypto';
import { createPublicKey, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { v4 as uuid } from 'uuid';
import * as mfm from 'mfm-js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@ -613,7 +612,7 @@ export class ApRendererService {
@bindThis
public addContext<T extends IObject>(x: T): T & { '@context': any; id: string; } {
if (typeof x === 'object' && x.id == null) {
x.id = `${this.config.url}/${uuid()}`;
x.id = `${this.config.url}/${randomUUID()}`;
}
return Object.assign({

View file

@ -1,7 +1,6 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { RemoteUser } from '@/models/entities/User.js';
import type { DriveFile } from '@/models/entities/DriveFile.js';
import { MetaService } from '@/core/MetaService.js';
@ -20,9 +19,6 @@ export class ApImageService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@ -47,7 +43,7 @@ export class ApImageService {
const image = await this.apResolverService.createResolver().resolve(value);
if (image.url == null) {
throw new Error('invalid image: url not privided');
throw new Error('invalid image: url not provided');
}
if (typeof image.url !== 'string') {
@ -62,12 +58,17 @@ export class ApImageService {
const instance = await this.metaService.fetch();
// Cache if remote file cache is on AND either
// 1. remote sensitive file is also on
// 2. or the image is not sensitive
const shouldBeCached = instance.cacheRemoteFiles && (instance.cacheRemoteSensitiveFiles || !image.sensitive);
const file = await this.driveService.uploadFromUrl({
url: image.url,
user: actor,
uri: image.url,
sensitive: image.sensitive,
isLink: !instance.cacheRemoteFiles,
isLink: !shouldBeCached,
comment: truncate(image.name ?? undefined, DB_MAX_IMAGE_COMMENT_LENGTH),
});
if (!file.isLink || file.url === image.url) return file;

View file

@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { User } from '@/models/entities/User.js';
import type { RegistrationTicket } from '@/models/entities/RegistrationTicket.js';
import { bindThis } from '@/decorators.js';
import { UserEntityService } from './UserEntityService.js';
@Injectable()
export class InviteCodeEntityService {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private userEntityService: UserEntityService,
) {
}
@bindThis
public async pack(
src: RegistrationTicket['id'] | RegistrationTicket,
me?: { id: User['id'] } | null | undefined,
): Promise<Packed<'InviteCode'>> {
const target = typeof src === 'object' ? src : await this.registrationTicketsRepository.findOneOrFail({
where: {
id: src,
},
relations: ['createdBy', 'usedBy'],
});
return await awaitAll({
id: target.id,
code: target.code,
expiresAt: target.expiresAt ? target.expiresAt.toISOString() : null,
createdAt: target.createdAt.toISOString(),
createdBy: target.createdBy ? await this.userEntityService.pack(target.createdBy, me) : null,
usedBy: target.usedBy ? await this.userEntityService.pack(target.usedBy, me) : null,
usedAt: target.usedAt ? target.usedAt.toISOString() : null,
used: !!target.usedAt,
});
}
@bindThis
public packMany(
targets: any[],
me: { id: User['id'] },
) {
return Promise.all(targets.map(x => this.pack(x, me)));
}
}

View file

@ -0,0 +1,20 @@
import { secureRndstr } from './secure-rndstr.js';
const CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'; // [0-9A-Z] w/o [01IO] (32 patterns)
export function generateInviteCode(): string {
const code = secureRndstr(8, {
chars: CHARS,
});
const uniqueId = [];
let n = Math.floor(Date.now() / 1000 / 60);
while (true) {
uniqueId.push(CHARS[n % CHARS.length]);
const t = Math.floor(n / CHARS.length);
if (!t) break;
n = t;
}
return code + uniqueId.reverse().join('');
}

View file

@ -19,6 +19,7 @@ import { packedRenoteMutingSchema } from '@/models/json-schema/renote-muting.js'
import { packedBlockingSchema } from '@/models/json-schema/blocking.js';
import { packedNoteReactionSchema } from '@/models/json-schema/note-reaction.js';
import { packedHashtagSchema } from '@/models/json-schema/hashtag.js';
import { packedInviteCodeSchema } from '@/models/json-schema/invite-code.js';
import { packedPageSchema } from '@/models/json-schema/page.js';
import { packedNoteFavoriteSchema } from '@/models/json-schema/note-favorite.js';
import { packedChannelSchema } from '@/models/json-schema/channel.js';
@ -52,6 +53,7 @@ export const refs = {
RenoteMuting: packedRenoteMutingSchema,
Blocking: packedBlockingSchema,
Hashtag: packedHashtagSchema,
InviteCode: packedInviteCodeSchema,
Page: packedPageSchema,
Channel: packedChannelSchema,
QueueCount: packedQueueCountSchema,

View file

@ -1,7 +1,6 @@
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
import type { Clip } from './Clip.js';
@Entity()
export class Meta {
@ -126,6 +125,11 @@ export class Meta {
})
public cacheRemoteFiles: boolean;
@Column('boolean', {
default: true,
})
public cacheRemoteSensitiveFiles: boolean;
@Column({
...id(),
nullable: true,

View file

@ -1,17 +1,60 @@
import { PrimaryColumn, Entity, Index, Column } from 'typeorm';
import { PrimaryColumn, Entity, Index, Column, ManyToOne, JoinColumn, OneToOne } from 'typeorm';
import { id } from '../id.js';
import { User } from './User.js';
@Entity()
export class RegistrationTicket {
@PrimaryColumn(id())
public id: string;
@Column('timestamp with time zone')
public createdAt: Date;
@Index({ unique: true })
@Column('varchar', {
length: 64,
})
public code: string;
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
@Column('timestamp with time zone')
public createdAt: Date;
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public createdBy: User | null;
@Index()
@Column({
...id(),
nullable: true,
})
public createdById: User['id'] | null;
@OneToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public usedBy: User | null;
@Index()
@Column({
...id(),
nullable: true,
})
public usedById: User['id'] | null;
@Column('timestamp with time zone', {
nullable: true,
})
public usedAt: Date | null;
@Column('varchar', {
length: 32,
nullable: true,
})
public pendingUserId: string | null;
}

View file

@ -0,0 +1,45 @@
export const packedInviteCodeSchema = {
type: 'object',
properties: {
id: {
type: 'string',
optional: false, nullable: false,
format: 'id',
example: 'xxxxxxxxxx',
},
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
expiresAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
createdAt: {
type: 'string',
optional: false, nullable: false,
format: 'date-time',
},
createdBy: {
type: 'object',
optional: false, nullable: true,
ref: 'UserLite',
},
usedBy: {
type: 'object',
optional: false, nullable: true,
ref: 'UserLite',
},
usedAt: {
type: 'string',
optional: false, nullable: true,
format: 'date-time',
},
used: {
type: 'boolean',
optional: false, nullable: false,
},
},
} as const;

View file

@ -1,8 +1,8 @@
import { randomUUID } from 'node:crypto';
import { pipeline } from 'node:stream';
import * as fs from 'node:fs';
import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import { DI } from '@/di-symbols.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { LocalUser, User } from '@/models/entities/User.js';
@ -362,7 +362,7 @@ export class ApiCallService implements OnApplicationShutdown {
if (err instanceof ApiError || err instanceof AuthenticationError) {
throw err;
} else {
const errId = uuid();
const errId = randomUUID();
this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
ep: ep.name,
ps: data,

View file

@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
import * as ep___invite from './endpoints/invite.js';
import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js';
import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emoji from './endpoints/emoji.js';
@ -378,7 +383,8 @@ const $admin_federation_updateInstance: Provider = { provide: 'ep:admin/federati
const $admin_getIndexStats: Provider = { provide: 'ep:admin/get-index-stats', useClass: ep___admin_getIndexStats.default };
const $admin_getTableStats: Provider = { provide: 'ep:admin/get-table-stats', useClass: ep___admin_getTableStats.default };
const $admin_getUserIps: Provider = { provide: 'ep:admin/get-user-ips', useClass: ep___admin_getUserIps.default };
const $invite: Provider = { provide: 'ep:invite', useClass: ep___invite.default };
const $admin_invite_create: Provider = { provide: 'ep:admin/invite/create', useClass: ep___admin_invite_create.default };
const $admin_invite_list: Provider = { provide: 'ep:admin/invite/list', useClass: ep___admin_invite_list.default };
const $admin_promo_create: Provider = { provide: 'ep:admin/promo/create', useClass: ep___admin_promo_create.default };
const $admin_queue_clear: Provider = { provide: 'ep:admin/queue/clear', useClass: ep___admin_queue_clear.default };
const $admin_queue_deliverDelayed: Provider = { provide: 'ep:admin/queue/deliver-delayed', useClass: ep___admin_queue_deliverDelayed.default };
@ -570,6 +576,10 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep
const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default };
const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default };
const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default };
const $invite_create: Provider = { provide: 'ep:invite/create', useClass: ep___invite_create.default };
const $invite_delete: Provider = { provide: 'ep:invite/delete', useClass: ep___invite_delete.default };
const $invite_list: Provider = { provide: 'ep:invite/list', useClass: ep___invite_list.default };
const $invite_limit: Provider = { provide: 'ep:invite/limit', useClass: ep___invite_limit.default };
const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default };
const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default };
const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
@ -722,7 +732,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_getIndexStats,
$admin_getTableStats,
$admin_getUserIps,
$invite,
$admin_invite_create,
$admin_invite_list,
$admin_promo_create,
$admin_queue_clear,
$admin_queue_deliverDelayed,
@ -914,6 +925,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
$invite_create,
$invite_delete,
$invite_list,
$invite_limit,
$meta,
$emojis,
$emoji,
@ -1060,7 +1075,8 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_getIndexStats,
$admin_getTableStats,
$admin_getUserIps,
$invite,
$admin_invite_create,
$admin_invite_list,
$admin_promo_create,
$admin_queue_clear,
$admin_queue_deliverDelayed,
@ -1252,6 +1268,10 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_webhooks_show,
$i_webhooks_update,
$i_webhooks_delete,
$invite_create,
$invite_delete,
$invite_list,
$invite_limit,
$meta,
$emojis,
$emoji,

View file

@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, RegistrationTicket } from '@/models/index.js';
import type { Config } from '@/config.js';
import { MetaService } from '@/core/MetaService.js';
import { CaptchaService } from '@/core/CaptchaService.js';
@ -109,13 +109,15 @@ export class SignupApiService {
}
}
let ticket: RegistrationTicket | null = null;
if (instance.disableRegistration) {
if (invitationCode == null || typeof invitationCode !== 'string') {
reply.code(400);
return;
}
const ticket = await this.registrationTicketsRepository.findOneBy({
ticket = await this.registrationTicketsRepository.findOneBy({
code: invitationCode,
});
@ -124,7 +126,15 @@ export class SignupApiService {
return;
}
this.registrationTicketsRepository.delete(ticket.id);
if (ticket.expiresAt && ticket.expiresAt < new Date()) {
reply.code(400);
return;
}
if (ticket.usedAt) {
reply.code(400);
return;
}
}
if (instance.emailRequiredForSignup) {
@ -148,14 +158,14 @@ export class SignupApiService {
const salt = await bcrypt.genSalt(8);
const hash = await bcrypt.hash(password, salt);
await this.userPendingsRepository.insert({
const pendingUser = await this.userPendingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
code,
email: emailAddress!,
username: username,
password: hash,
});
}).then(x => this.userPendingsRepository.findOneByOrFail(x.identifiers[0]));
const link = `${this.config.url}/signup-complete/${code}`;
@ -163,6 +173,13 @@ export class SignupApiService {
`To complete signup, please click this link:<br><a href="${link}">${link}</a>`,
`To complete signup, please click this link: ${link}`);
if (ticket) {
await this.registrationTicketsRepository.update(ticket.id, {
usedAt: new Date(),
pendingUserId: pendingUser.id,
});
}
reply.code(204);
return;
} else {
@ -176,6 +193,14 @@ export class SignupApiService {
includeSecrets: true,
});
if (ticket) {
await this.registrationTicketsRepository.update(ticket.id, {
usedAt: new Date(),
usedBy: account,
usedById: account.id,
});
}
return {
...res,
token: secret,
@ -212,6 +237,15 @@ export class SignupApiService {
emailVerifyCode: null,
});
const ticket = await this.registrationTicketsRepository.findOneBy({ pendingUserId: pendingUser.id });
if (ticket) {
await this.registrationTicketsRepository.update(ticket.id, {
usedBy: account,
usedById: account.id,
pendingUserId: null,
});
}
return this.signinService.signin(request, reply, account as LocalUser);
} catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());

View file

@ -38,7 +38,8 @@ import * as ep___admin_federation_updateInstance from './endpoints/admin/federat
import * as ep___admin_getIndexStats from './endpoints/admin/get-index-stats.js';
import * as ep___admin_getTableStats from './endpoints/admin/get-table-stats.js';
import * as ep___admin_getUserIps from './endpoints/admin/get-user-ips.js';
import * as ep___invite from './endpoints/invite.js';
import * as ep___admin_invite_create from './endpoints/admin/invite/create.js';
import * as ep___admin_invite_list from './endpoints/admin/invite/list.js';
import * as ep___admin_promo_create from './endpoints/admin/promo/create.js';
import * as ep___admin_queue_clear from './endpoints/admin/queue/clear.js';
import * as ep___admin_queue_deliverDelayed from './endpoints/admin/queue/deliver-delayed.js';
@ -230,6 +231,10 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js';
import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js';
import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js';
import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js';
import * as ep___invite_create from './endpoints/invite/create.js';
import * as ep___invite_delete from './endpoints/invite/delete.js';
import * as ep___invite_list from './endpoints/invite/list.js';
import * as ep___invite_limit from './endpoints/invite/limit.js';
import * as ep___meta from './endpoints/meta.js';
import * as ep___emojis from './endpoints/emojis.js';
import * as ep___emoji from './endpoints/emoji.js';
@ -376,7 +381,8 @@ const eps = [
['admin/get-index-stats', ep___admin_getIndexStats],
['admin/get-table-stats', ep___admin_getTableStats],
['admin/get-user-ips', ep___admin_getUserIps],
['invite', ep___invite],
['admin/invite/create', ep___admin_invite_create],
['admin/invite/list', ep___admin_invite_list],
['admin/promo/create', ep___admin_promo_create],
['admin/queue/clear', ep___admin_queue_clear],
['admin/queue/deliver-delayed', ep___admin_queue_deliverDelayed],
@ -568,6 +574,10 @@ const eps = [
['i/webhooks/show', ep___i_webhooks_show],
['i/webhooks/update', ep___i_webhooks_update],
['i/webhooks/delete', ep___i_webhooks_delete],
['invite/create', ep___invite_create],
['invite/delete', ep___invite_delete],
['invite/list', ep___invite_list],
['invite/limit', ep___invite_limit],
['meta', ep___meta],
['emojis', ep___emojis],
['emoji', ep___emoji],

View file

@ -0,0 +1,80 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import { generateInviteCode } from '@/misc/generate-invite-code.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
errors: {
invalidDateTime: {
message: 'Invalid date-time format',
code: 'INVALID_DATE_TIME',
id: 'f1380b15-3760-4c6c-a1db-5c3aaf1cbd49',
},
},
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
count: { type: 'integer', minimum: 1, maximum: 100, default: 1 },
expiresAt: { type: 'string', nullable: true },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.expiresAt && isNaN(Date.parse(ps.expiresAt))) {
throw new ApiError(meta.errors.invalidDateTime);
}
const ticketsPromises = [];
for (let i = 0; i < ps.count; i++) {
ticketsPromises.push(this.registrationTicketsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
code: generateInviteCode(),
}).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0])));
}
const tickets = await Promise.all(ticketsPromises);
return await this.inviteCodeEntityService.packMany(tickets, me);
});
}
}

View file

@ -0,0 +1,70 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['admin'],
requireCredential: true,
requireModerator: true,
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
offset: { type: 'integer', default: 0 },
type: { type: 'string', enum: ['unused', 'used', 'expired', 'all'], default: 'all' },
sort: { type: 'string', enum: ['+createdAt', '-createdAt', '+usedAt', '-usedAt'] },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.registrationTicketsRepository.createQueryBuilder('ticket')
.leftJoinAndSelect('ticket.createdBy', 'createdBy')
.leftJoinAndSelect('ticket.usedBy', 'usedBy');
switch (ps.type) {
case 'unused': query.andWhere('ticket.usedBy IS NULL'); break;
case 'used': query.andWhere('ticket.usedBy IS NOT NULL'); break;
case 'expired': query.andWhere('ticket.expiresAt < :now', { now: new Date() }); break;
}
switch (ps.sort) {
case '+createdAt': query.orderBy('ticket.createdAt', 'DESC'); break;
case '-createdAt': query.orderBy('ticket.createdAt', 'ASC'); break;
case '+usedAt': query.orderBy('ticket.usedAt', 'DESC', 'NULLS LAST'); break;
case '-usedAt': query.orderBy('ticket.usedAt', 'ASC', 'NULLS FIRST'); break;
default: query.orderBy('ticket.id', 'DESC'); break;
}
query.limit(ps.limit);
query.skip(ps.offset);
const tickets = await query.getMany();
return await this.inviteCodeEntityService.packMany(tickets, me);
});
}
}

View file

@ -1,5 +1,4 @@
import { Inject, Injectable } from '@nestjs/common';
import { MAX_NOTE_TEXT_LENGTH } from '@/const.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { MetaService } from '@/core/MetaService.js';
import type { Config } from '@/config.js';
@ -20,6 +19,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
cacheRemoteSensitiveFiles: {
type: 'boolean',
optional: false, nullable: false,
},
emailRequiredForSignup: {
type: 'boolean',
optional: false, nullable: false,
@ -332,6 +335,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
enableServiceWorker: instance.enableServiceWorker,
translatorAvailable: instance.deeplAuthKey != null,
cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
pinnedUsers: instance.pinnedUsers,
hiddenTags: instance.hiddenTags,
blockedHosts: instance.blockedHosts,

View file

@ -43,6 +43,7 @@ export const paramDef = {
defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true },
cacheRemoteFiles: { type: 'boolean' },
cacheRemoteSensitiveFiles: { type: 'boolean' },
emailRequiredForSignup: { type: 'boolean' },
enableHcaptcha: { type: 'boolean' },
hcaptchaSiteKey: { type: 'string', nullable: true },
@ -193,6 +194,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
set.cacheRemoteFiles = ps.cacheRemoteFiles;
}
if (ps.cacheRemoteSensitiveFiles !== undefined) {
set.cacheRemoteSensitiveFiles = ps.cacheRemoteSensitiveFiles;
}
if (ps.emailRequiredForSignup !== undefined) {
set.emailRequiredForSignup = ps.emailRequiredForSignup;
}

View file

@ -1,4 +1,4 @@
import { v4 as uuid } from 'uuid';
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { AppsRepository, AuthSessionsRepository } from '@/models/index.js';
@ -71,7 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
// Generate token
const token = uuid();
const token = randomUUID();
// Create session token document
const doc = await this.authSessionsRepository.insert({

View file

@ -0,0 +1,82 @@
import { MoreThan } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { generateInviteCode } from '@/misc/generate-invite-code.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
requireRolePolicy: 'canInvite',
errors: {
exceededCreateLimit: {
message: 'You have exceeded the limit for creating an invitation code.',
code: 'EXCEEDED_LIMIT_OF_CREATE_INVITE_CODE',
id: '8b165dd3-6f37-4557-8db1-73175d63c641',
},
},
res: {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: 'GR6S02ERUA5VR',
},
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
private idService: IdService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
if (policies.inviteLimit) {
const count = await this.registrationTicketsRepository.countBy({
createdAt: MoreThan(new Date(Date.now() - (policies.inviteLimitCycle * 1000 * 60))),
createdById: me.id,
});
if (count >= policies.inviteLimit) {
throw new ApiError(meta.errors.exceededCreateLimit);
}
}
const ticket = await this.registrationTicketsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
createdBy: me,
createdById: me.id,
expiresAt: policies.inviteExpirationTime ? new Date(Date.now() + (policies.inviteExpirationTime * 1000 * 60)) : null,
code: generateInviteCode(),
}).then(x => this.registrationTicketsRepository.findOneByOrFail(x.identifiers[0]));
return await this.inviteCodeEntityService.pack(ticket, me);
});
}
}

View file

@ -0,0 +1,71 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
requireRolePolicy: 'canInvite',
errors: {
noSuchCode: {
message: 'No such invite code.',
code: 'NO_SUCH_INVITE_CODE',
id: 'cd4f9ae4-7854-4e3e-8df9-c296f051e634',
},
cantDelete: {
message: 'You can\'t delete this invite code.',
code: 'CAN_NOT_DELETE_INVITE_CODE',
id: 'ff17af39-000c-4d4e-abdf-848fa30fc1ce',
},
accessDenied: {
message: 'Access denied.',
code: 'ACCESS_DENIED',
id: '5eb8d909-2540-4970-90b8-dd6f86088121',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
inviteId: { type: 'string', format: 'misskey:id' },
},
required: ['inviteId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const ticket = await this.registrationTicketsRepository.findOneBy({ id: ps.inviteId });
const isModerator = await this.roleService.isModerator(me);
if (ticket == null) {
throw new ApiError(meta.errors.noSuchCode);
}
if (ticket.createdById !== me.id && !isModerator) {
throw new ApiError(meta.errors.accessDenied);
}
if (ticket.usedAt && !isModerator) {
throw new ApiError(meta.errors.cantDelete);
}
await this.registrationTicketsRepository.delete(ticket.id);
});
}
}

View file

@ -1,9 +1,9 @@
import { Inject, Injectable } from '@nestjs/common';
import { MoreThan } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { RoleService } from '@/core/RoleService.js';
import { DI } from '@/di-symbols.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
export const meta = {
tags: ['meta'],
@ -15,12 +15,9 @@ export const meta = {
type: 'object',
optional: false, nullable: false,
properties: {
code: {
type: 'string',
optional: false, nullable: false,
example: '2ERUA5VR',
maxLength: 8,
minLength: 8,
remaining: {
type: 'integer',
optional: false, nullable: true,
},
},
},
@ -39,21 +36,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private idService: IdService,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const code = secureRndstr(8, {
chars: '23456789ABCDEFGHJKLMNPQRSTUVWXYZ', // [0-9A-Z] w/o [01IO] (32 patterns)
});
const policies = await this.roleService.getUserPolicies(me.id);
await this.registrationTicketsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
code,
});
const count = policies.inviteLimit ? await this.registrationTicketsRepository.countBy({
createdAt: MoreThan(new Date(Date.now() - (policies.inviteExpirationTime * 60 * 1000))),
createdById: me.id,
}) : null;
return {
code,
remaining: count !== null ? Math.max(0, policies.inviteLimit - count) : null,
};
});
}

View file

@ -0,0 +1,58 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RegistrationTicketsRepository } from '@/models/index.js';
import { InviteCodeEntityService } from '@/core/entities/InviteCodeEntityService.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['meta'],
requireCredential: true,
requireRolePolicy: 'canInvite',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
private inviteCodeEntityService: InviteCodeEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.registrationTicketsRepository.createQueryBuilder('ticket'), ps.sinceId, ps.untilId)
.andWhere('ticket.createdById = :meId', { meId: me.id })
.leftJoinAndSelect('ticket.createdBy', 'createdBy')
.leftJoinAndSelect('ticket.usedBy', 'usedBy');
const tickets = await query
.limit(ps.limit)
.getMany();
return await this.inviteCodeEntityService.packMany(tickets, me);
});
}
}

View file

@ -83,6 +83,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
cacheRemoteSensitiveFiles: {
type: 'boolean',
optional: false, nullable: false,
},
emailRequiredForSignup: {
type: 'boolean',
optional: false, nullable: false,
@ -272,7 +276,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orWhere('ads.dayOfWeek = 0');
}))
.getMany();
const response: any = {
maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail,
@ -329,6 +333,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
...(ps.detail ? {
cacheRemoteFiles: instance.cacheRemoteFiles,
cacheRemoteSensitiveFiles: instance.cacheRemoteSensitiveFiles,
requireSetup: (await this.usersRepository.countBy({
host: IsNull(),
})) === 0,

View file

@ -1,7 +1,7 @@
import { randomUUID } from 'node:crypto';
import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { v4 as uuid } from 'uuid';
import { createBullBoard } from '@bull-board/api';
import { BullAdapter } from '@bull-board/api/bullAdapter.js';
import { FastifyAdapter } from '@bull-board/fastify';
@ -676,7 +676,7 @@ export class ClientServerService {
});
fastify.setErrorHandler(async (error, request, reply) => {
const errId = uuid();
const errId = randomUUID();
this.clientLoggerService.logger.error(`Internal error occured in ${request.routerPath}: ${error.message}`, {
path: request.routerPath,
params: request.params,

View file

@ -35,7 +35,7 @@ html
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
//- https://github.com/misskey-dev/misskey/issues/9842
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.24.0')
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.25.0')
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
if !config.clientManifestExists

View file

@ -4,6 +4,7 @@ import * as assert from 'assert';
import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals';
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -11,9 +12,12 @@ import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IActivity, IActor, ICollection, IObject, IOrderedCollection, IOrderedCollectionPage, IPost } from '@/core/activitypub/type.js';
import { Note } from '@/models/index.js';
import type { IActivity, IApDocument, IActor, ICollection, IObject, IOrderedCollection, IOrderedCollectionPage, IPost } from '@/core/activitypub/type.js';
import { Meta, Note } from '@/models/index.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
import { MetaService } from '@/core/MetaService.js';
import type { RemoteUser } from '@/models/entities/User.js';
import { MockResolver } from '../misc/mock-resolver.js';
const host = 'https://host1.test';
@ -120,16 +124,47 @@ function createRandomPagedOutbox(actor: NonTransientIActor): NonTransientIOrdere
};
}
async function createRandomRemoteUser(
resolver: MockResolver,
personService: ApPersonService,
): Promise<RemoteUser> {
const actor = createRandomActor();
resolver.register(actor.id, actor);
return await personService.createPerson(actor.id, resolver);
}
describe('ActivityPub', () => {
let imageService: ApImageService;
let noteService: ApNoteService;
let personService: ApPersonService;
let rendererService: ApRendererService;
let resolver: MockResolver;
const metaInitial = {
cacheRemoteFiles: true,
cacheRemoteSensitiveFiles: true,
blockedHosts: [] as string[],
sensitiveWords: [] as string[],
} as Meta;
let meta = metaInitial;
beforeAll(async () => {
const app = await Test.createTestingModule({
imports: [GlobalModule, CoreModule],
}).compile();
})
.overrideProvider(DownloadService).useValue({
async downloadUrl(): Promise<{ filename: string }> {
return {
filename: 'dummy.tmp',
};
},
})
.overrideProvider(MetaService).useValue({
async fetch(): Promise<Meta> {
return meta;
},
}).compile();
await app.init();
app.enableShutdownHooks();
@ -137,6 +172,7 @@ describe('ActivityPub', () => {
noteService = app.get<ApNoteService>(ApNoteService);
personService = app.get<ApPersonService>(ApPersonService);
rendererService = app.get<ApRendererService>(ApRendererService);
imageService = app.get<ApImageService>(ApImageService);
resolver = new MockResolver(await app.resolve<LoggerService>(LoggerService));
// Prevent ApPersonService from fetching instance, as it causes Jest import-after-test error
@ -365,4 +401,91 @@ describe('ActivityPub', () => {
}
});
});
describe('Images', () => {
test('Create images', async () => {
const imageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
imageObject,
);
assert.ok(!driveFile.isLink);
const sensitiveImageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
sensitiveImageObject,
);
assert.ok(!sensitiveDriveFile.isLink);
});
test('cacheRemoteFiles=false disables caching', async () => {
meta = { ...metaInitial, cacheRemoteFiles: false };
const imageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
imageObject,
);
assert.ok(driveFile.isLink);
const sensitiveImageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
sensitiveImageObject,
);
assert.ok(sensitiveDriveFile.isLink);
});
test('cacheRemoteSensitiveFiles=false only affects sensitive files', async () => {
meta = { ...metaInitial, cacheRemoteSensitiveFiles: false };
const imageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/foo.png',
name: '',
};
const driveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
imageObject,
);
assert.ok(!driveFile.isLink);
const sensitiveImageObject: IApDocument = {
type: 'Document',
mediaType: 'image/png',
url: 'http://host1.test/bar.png',
name: '',
sensitive: true,
};
const sensitiveDriveFile = await imageService.createImage(
await createRandomRemoteUser(resolver, personService),
sensitiveImageObject,
);
assert.ok(sensitiveDriveFile.isLink);
});
});
});

View file

@ -115,3 +115,27 @@ export function userDetailed(id = 'someuserid', username = 'miskist', host = 'mi
url: null,
};
}
export function inviteCode(isUsed = false, hasExpiration = false, isExpired = false, isCreatedBySystem = false) {
const date = new Date();
const createdAt = new Date();
createdAt.setDate(date.getDate() - 1)
const expiresAt = new Date();
if (isExpired) {
expiresAt.setHours(date.getHours() - 1)
} else {
expiresAt.setHours(date.getHours() + 1)
}
return {
id: "9gyqzizw77",
code: "SLF3JKF7UV2H9",
expiresAt: hasExpiration ? expiresAt.toISOString() : null,
createdAt: createdAt.toISOString(),
createdBy: isCreatedBySystem ? null : userDetailed('8i3rvznx32'),
usedBy: isUsed ? userDetailed('3i3r2znx1v') : null,
usedAt: isUsed ? date.toISOString() : null,
used: isUsed,
}
}

View file

@ -403,6 +403,7 @@ function toStories(component: string): Promise<string> {
glob('src/components/MkSignupServerRules.vue'),
glob('src/components/MkUserSetupDialog.vue'),
glob('src/components/MkUserSetupDialog.*.vue'),
glob('src/components/MkInviteCode.vue'),
glob('src/pages/user/home.vue'),
]);
const components = globs.flat();

View file

@ -20,9 +20,9 @@
"@rollup/plugin-replace": "5.0.2",
"@rollup/pluginutils": "5.0.2",
"@syuilo/aiscript": "0.13.3",
"@tabler/icons-webfont": "2.24.0",
"@tabler/icons-webfont": "2.25.0",
"@vitejs/plugin-vue": "4.2.3",
"@vue-macros/reactivity-transform": "0.3.11",
"@vue-macros/reactivity-transform": "0.3.14",
"@vue/compiler-sfc": "3.3.4",
"astring": "1.8.6",
"autosize": "6.0.1",
@ -70,30 +70,30 @@
"typescript": "5.1.6",
"uuid": "9.0.0",
"vanilla-tilt": "1.8.0",
"vite": "4.4.1",
"vite": "4.4.4",
"vue": "3.3.4",
"vue-prism-editor": "2.0.0-alpha.2",
"vuedraggable": "next"
},
"devDependencies": {
"@storybook/addon-actions": "7.0.26",
"@storybook/addon-essentials": "7.0.26",
"@storybook/addon-interactions": "7.0.26",
"@storybook/addon-links": "7.0.26",
"@storybook/addon-storysource": "7.0.26",
"@storybook/addons": "7.0.26",
"@storybook/blocks": "7.0.26",
"@storybook/core-events": "7.0.26",
"@storybook/addon-actions": "7.0.27",
"@storybook/addon-essentials": "7.0.27",
"@storybook/addon-interactions": "7.0.27",
"@storybook/addon-links": "7.0.27",
"@storybook/addon-storysource": "7.0.27",
"@storybook/addons": "7.0.27",
"@storybook/blocks": "7.0.27",
"@storybook/core-events": "7.0.27",
"@storybook/jest": "0.1.0",
"@storybook/manager-api": "7.0.26",
"@storybook/preview-api": "7.0.26",
"@storybook/react": "7.0.26",
"@storybook/react-vite": "7.0.26",
"@storybook/manager-api": "7.0.27",
"@storybook/preview-api": "7.0.27",
"@storybook/react": "7.0.27",
"@storybook/react-vite": "7.0.27",
"@storybook/testing-library": "0.2.0",
"@storybook/theming": "7.0.26",
"@storybook/types": "7.0.26",
"@storybook/vue3": "7.0.26",
"@storybook/vue3-vite": "7.0.26",
"@storybook/theming": "7.0.27",
"@storybook/types": "7.0.27",
"@storybook/vue3": "7.0.27",
"@storybook/vue3-vite": "7.0.27",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/vue": "7.0.0",
"@types/escape-regexp": "0.0.1",
@ -102,10 +102,10 @@
"@types/gulp-rename": "2.0.2",
"@types/matter-js": "0.18.5",
"@types/micromatch": "4.0.2",
"@types/node": "20.4.0",
"@types/node": "20.4.2",
"@types/punycode": "2.1.0",
"@types/sanitize-html": "2.9.0",
"@types/testing-library__jest-dom": "5.14.7",
"@types/testing-library__jest-dom": "5.14.8",
"@types/throttle-debounce": "5.0.0",
"@types/tinycolor2": "1.4.3",
"@types/uuid": "9.0.2",
@ -118,12 +118,12 @@
"acorn": "8.10.0",
"chokidar-cli": "3.0.0",
"cross-env": "7.0.3",
"cypress": "12.17.0",
"eslint": "8.44.0",
"cypress": "12.17.1",
"eslint": "8.45.0",
"eslint-plugin-import": "2.27.5",
"eslint-plugin-vue": "9.15.1",
"fast-glob": "3.3.0",
"happy-dom": "10.0.3",
"happy-dom": "10.3.2",
"micromatch": "4.0.5",
"msw": "1.2.2",
"msw-storybook-addon": "1.8.0",
@ -131,13 +131,13 @@
"react": "18.2.0",
"react-dom": "18.2.0",
"start-server-and-test": "2.0.0",
"storybook": "7.0.26",
"storybook": "7.0.27",
"storybook-addon-misskey-theme": "github:misskey-dev/storybook-addon-misskey-theme",
"summaly": "github:misskey-dev/summaly",
"vite-plugin-turbosnap": "1.0.2",
"vitest": "0.33.0",
"vitest-fetch-mock": "0.2.2",
"vue-eslint-parser": "9.3.1",
"vue-tsc": "1.8.4"
"vue-tsc": "1.8.5"
}
}

View file

@ -0,0 +1,60 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { StoryObj } from '@storybook/vue3';
import { rest } from 'msw';
import { userDetailed, inviteCode } from '../../.storybook/fakes';
import { commonHandlers } from '../../.storybook/mocks';
import MkInviteCode from './MkInviteCode.vue';
export const Default = {
render(args) {
return {
components: {
MkInviteCode,
},
setup() {
return {
args,
};
},
computed: {
props() {
return {
...this.args,
};
},
},
template: '<MkInviteCode v-bind="props" />',
};
},
args: {
invite: inviteCode() as any,
},
parameters: {
layout: 'centered',
msw: {
handlers: [
...commonHandlers,
rest.post('/api/users/show', (req, res, ctx) => {
return res(ctx.json(userDetailed(req.params.userId as string)));
}),
],
},
},
decorators: [() => ({
template: '<div style="width:100cqmin"><story/></div>',
})],
} satisfies StoryObj<typeof MkInviteCode>;
export const Used = {
...Default,
args: {
invite: inviteCode(true) as any
},
} satisfies StoryObj<typeof MkInviteCode>;
export const Expired = {
...Default,
args: {
invite: inviteCode(false, true, true) as any
},
} satisfies StoryObj<typeof MkInviteCode>;

View file

@ -0,0 +1,123 @@
<template>
<MkFolder>
<template #label>{{ invite.code }}</template>
<template #suffix>
<span v-if="invite.used">{{ i18n.ts.used }}</span>
<span v-else-if="isExpired" style="color: var(--error)">{{ i18n.ts.expired }}</span>
<span v-else style="color: var(--success)">{{ i18n.ts.unused }}</span>
</template>
<div class="_gaps_s" :class="$style.root">
<div :class="$style.items">
<div>
<div :class="$style.label">{{ i18n.ts.invitationCode }}</div>
<div>{{ invite.code }}</div>
</div>
<div v-if="moderator">
<div :class="$style.label">{{ i18n.ts.inviteCodeCreator }}</div>
<div v-if="invite.createdBy" :class="$style.user">
<MkAvatar :user="invite.createdBy" :class="$style.avatar" link preview/>
<MkUserName :user="invite.createdBy" :nowrap="false"/>
<div v-if="moderator">({{ invite.createdBy.id }})</div>
</div>
<div v-else>system</div>
</div>
<div v-if="invite.used">
<div :class="$style.label">{{ i18n.ts.registeredUserUsingInviteCode }}</div>
<div v-if="invite.usedBy" :class="$style.user">
<MkAvatar :user="invite.usedBy" :class="$style.avatar" link preview/>
<MkUserName :user="invite.usedBy" :nowrap="false"/>
<div v-if="moderator">({{ invite.usedBy.id }})</div>
</div>
<div v-else>{{ i18n.ts.unknown }} ({{ i18n.ts.waitingForMailAuth }})</div>
</div>
<div v-if="invite.expiresAt && !invite.used">
<div :class="$style.label">{{ i18n.ts.expirationDate }}</div>
<div><MkTime :time="invite.expiresAt" mode="absolute"/></div>
</div>
<div v-if="invite.usedAt">
<div :class="$style.label">{{ i18n.ts.inviteCodeUsedAt }}</div>
<div><MkTime :time="invite.usedAt" mode="absolute"/></div>
</div>
<div v-if="moderator">
<div :class="$style.label">{{ i18n.ts.createdAt }}</div>
<div><MkTime :time="invite.createdAt" mode="absolute"/></div>
</div>
</div>
<div :class="$style.buttons">
<MkButton v-if="!invite.used && !isExpired" primary rounded @click="copyInviteCode()">{{ i18n.ts.copy }}</MkButton>
<MkButton v-if="!invite.used || moderator" danger rounded @click="deleteCode()">{{ i18n.ts.delete }}</MkButton>
</div>
</div>
</MkFolder>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import * as misskey from 'misskey-js';
import MkFolder from '@/components/MkFolder.vue';
import MkButton from '@/components/MkButton.vue';
import copyToClipboard from '@/scripts/copy-to-clipboard';
import { i18n } from '@/i18n';
import * as os from '@/os';
const props = defineProps<{
invite: misskey.entities.Invite;
moderator?: boolean;
}>();
const emits = defineEmits<{
(event: 'deleted', value: string): void;
}>();
const isExpired = computed(() => {
return props.invite.expiresAt && new Date(props.invite.expiresAt) < new Date();
});
function deleteCode() {
os.apiWithDialog('invite/delete', {
inviteId: props.invite.id,
});
emits('deleted', props.invite.id);
}
function copyInviteCode() {
copyToClipboard(props.invite.code);
os.success();
}
</script>
<style lang="scss" module>
.root {
text-align: left;
}
.items {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
grid-gap: 12px;
}
.label {
font-size: 0.85em;
padding: 0 0 8px 0;
user-select: none;
}
.user {
display: flex;
align-items: center;
gap: 8px;
}
.avatar {
--height: 24px;
width: var(--height);
height: var(--height);
}
.buttons {
display: flex;
gap: 8px;
}
</style>

View file

@ -57,6 +57,9 @@ export const ROLE_POLICIES = [
'ltlAvailable',
'canPublicNote',
'canInvite',
'inviteLimit',
'inviteLimitCycle',
'inviteExpirationTime',
'canManageCustomEmojis',
'canSearchNotes',
'canHideAds',

View file

@ -80,7 +80,7 @@ const menuDef = $computed(() => [{
}, ...(instance.disableRegistration ? [{
type: 'button',
icon: 'ti ti-user-plus',
text: i18n.ts.invite,
text: i18n.ts.createInviteCode,
action: invite,
}] : [])],
}, {
@ -95,6 +95,11 @@ const menuDef = $computed(() => [{
text: i18n.ts.users,
to: '/admin/users',
active: currentPage?.route.name === 'users',
}, {
icon: 'ti ti-user-plus',
text: i18n.ts.invite,
to: '/admin/invites',
active: currentPage?.route.name === 'invites',
}, {
icon: 'ti ti-badges',
text: i18n.ts.roles,
@ -240,10 +245,10 @@ provideMetadataReceiver((info) => {
});
const invite = () => {
os.api('invite').then(x => {
os.api('admin/invite/create').then(x => {
os.alert({
type: 'info',
text: x.code,
text: x?.[0].code,
});
}).catch(err => {
os.alert({

View file

@ -0,0 +1,126 @@
<template>
<MkStickyContainer>
<template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800">
<div class="_gaps_m">
<MkFolder :expanded="false">
<template #icon><i class="ti ti-plus"></i></template>
<template #label>{{ i18n.ts.createInviteCode }}</template>
<div class="_gaps_m">
<MkSwitch v-model="noExpirationDate">
<template #label>{{ i18n.ts.noExpirationDate }}</template>
</MkSwitch>
<MkInput v-if="!noExpirationDate" v-model="expiresAt" type="datetime-local">
<template #label>{{ i18n.ts.expirationDate }}</template>
</MkInput>
<MkInput v-model="createCount" type="number">
<template #label>{{ i18n.ts.createCount }}</template>
</MkInput>
<MkButton primary rounded @click="createWithOptions">{{ i18n.ts.create }}</MkButton>
</div>
</MkFolder>
<div :class="$style.inputs">
<MkSelect v-model="type" :class="$style.input">
<template #label>{{ i18n.ts.state }}</template>
<option value="all">{{ i18n.ts.all }}</option>
<option value="unused">{{ i18n.ts.unused }}</option>
<option value="used">{{ i18n.ts.used }}</option>
<option value="expired">{{ i18n.ts.expired }}</option>
</MkSelect>
<MkSelect v-model="sort" :class="$style.input">
<template #label>{{ i18n.ts.sort }}</template>
<option value="+createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="-createdAt">{{ i18n.ts.createdAt }} ({{ i18n.ts.descendingOrder }})</option>
<option value="+usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.ascendingOrder }})</option>
<option value="-usedAt">{{ i18n.ts.usedAt }} ({{ i18n.ts.descendingOrder }})</option>
</MkSelect>
</div>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #default="{ items }">
<div class="_gaps_s">
<MkInviteCode v-for="item in items" :key="item.id" :invite="(item as any)" :onDeleted="deleted" moderator/>
</div>
</template>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef } from 'vue';
import XHeader from './_header_.vue';
import { i18n } from '@/i18n';
import * as os from '@/os';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSelect from '@/components/MkSelect.vue';
import MkInput from '@/components/MkInput.vue';
import MkSwitch from '@/components/MkSwitch.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import MkInviteCode from '@/components/MkInviteCode.vue';
import { definePageMetadata } from '@/scripts/page-metadata';
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
let type = ref('all');
let sort = ref('+createdAt');
const pagination: Paging = {
endpoint: 'admin/invite/list' as const,
limit: 10,
params: computed(() => ({
type: type.value,
sort: sort.value,
})),
offsetMode: true,
};
const expiresAt = ref('');
const noExpirationDate = ref(true);
const createCount = ref(1);
async function createWithOptions() {
const options = {
expiresAt: noExpirationDate.value ? null : expiresAt.value,
count: createCount.value,
};
const tickets = await os.api('admin/invite/create', options);
os.alert({
type: 'success',
title: i18n.ts.inviteCodeCreated,
text: tickets?.map(x => x.code).join('\n'),
});
tickets?.forEach(ticket => pagingComponent.value?.prepend(ticket));
}
function deleted(id: string) {
if (pagingComponent.value) {
pagingComponent.value.items.delete(id);
}
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.invite,
icon: 'ti ti-user-plus',
});
</script>
<style lang="scss" module>
.inputs {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.input {
flex: 1;
}
</style>

View file

@ -171,6 +171,65 @@
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
<template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
<template #suffix>
<span v-if="role.policies.inviteLimit.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.inviteLimit.value }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimit)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.inviteLimit.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.inviteLimit.value" :disabled="role.policies.inviteLimit.useDefault" type="number" :readonly="readonly">
</MkInput>
<MkRange v-model="role.policies.inviteLimit.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
<template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
<template #suffix>
<span v-if="role.policies.inviteLimitCycle.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.inviteLimitCycle.value + i18n.ts._time.minute }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteLimitCycle)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.inviteLimitCycle.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.inviteLimitCycle.value" :disabled="role.policies.inviteLimitCycle.useDefault" type="number" :readonly="readonly">
<template #suffix>{{ i18n.ts._time.minute }}</template>
</MkInput>
<MkRange v-model="role.policies.inviteLimitCycle.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
<template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
<template #suffix>
<span v-if="role.policies.inviteExpirationTime.useDefault" :class="$style.useDefaultLabel">{{ i18n.ts._role.useBaseValue }}</span>
<span v-else>{{ role.policies.inviteExpirationTime.value + i18n.ts._time.minute }}</span>
<span :class="$style.priorityIndicator"><i :class="getPriorityIcon(role.policies.inviteExpirationTime)"></i></span>
</template>
<div class="_gaps">
<MkSwitch v-model="role.policies.inviteExpirationTime.useDefault" :readonly="readonly">
<template #label>{{ i18n.ts._role.useBaseValue }}</template>
</MkSwitch>
<MkInput v-model="role.policies.inviteExpirationTime.value" :disabled="role.policies.inviteExpirationTime.useDefault" type="number" :readonly="readonly">
<template #suffix>{{ i18n.ts._time.minute }}</template>
</MkInput>
<MkRange v-model="role.policies.inviteExpirationTime.priority" :min="0" :max="2" :step="1" easing :textConverter="(v) => v === 0 ? i18n.ts._role._priority.low : v === 1 ? i18n.ts._role._priority.middle : v === 2 ? i18n.ts._role._priority.high : ''">
<template #label>{{ i18n.ts._role.priority }}</template>
</MkRange>
</div>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>

View file

@ -51,6 +51,29 @@
</MkSwitch>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimit, 'inviteLimit'])">
<template #label>{{ i18n.ts._role._options.inviteLimit }}</template>
<template #suffix>{{ policies.inviteLimit }}</template>
<MkInput v-model="policies.inviteLimit" type="number">
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteLimitCycle, 'inviteLimitCycle'])">
<template #label>{{ i18n.ts._role._options.inviteLimitCycle }}</template>
<template #suffix>{{ policies.inviteLimitCycle + i18n.ts._time.minute }}</template>
<MkInput v-model="policies.inviteLimitCycle" type="number">
<template #suffix>{{ i18n.ts._time.minute }}</template>
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.inviteExpirationTime, 'inviteExpirationTime'])">
<template #label>{{ i18n.ts._role._options.inviteExpirationTime }}</template>
<template #suffix>{{ policies.inviteExpirationTime + i18n.ts._time.minute }}</template>
<MkInput v-model="policies.inviteExpirationTime" type="number">
<template #suffix>{{ i18n.ts._time.minute }}</template>
</MkInput>
</MkFolder>
<MkFolder v-if="matchQuery([i18n.ts._role._options.canManageCustomEmojis, 'canManageCustomEmojis'])">
<template #label>{{ i18n.ts._role._options.canManageCustomEmojis }}</template>
<template #suffix>{{ policies.canManageCustomEmojis ? i18n.ts.yes : i18n.ts.no }}</template>

View file

@ -37,6 +37,13 @@
<template #label>{{ i18n.ts.cacheRemoteFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template>
</MkSwitch>
<template v-if="cacheRemoteFiles">
<MkSwitch v-model="cacheRemoteSensitiveFiles">
<template #label>{{ i18n.ts.cacheRemoteSensitiveFiles }}</template>
<template #caption>{{ i18n.ts.cacheRemoteSensitiveFilesDescription }}</template>
</MkSwitch>
</template>
</div>
</FormSection>
@ -104,7 +111,6 @@ import { fetchInstance } from '@/instance';
import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import MkButton from '@/components/MkButton.vue';
import MkColorInput from '@/components/MkColorInput.vue';
let name: string | null = $ref(null);
let description: string | null = $ref(null);
@ -112,13 +118,14 @@ let maintainerName: string | null = $ref(null);
let maintainerEmail: string | null = $ref(null);
let pinnedUsers: string = $ref('');
let cacheRemoteFiles: boolean = $ref(false);
let cacheRemoteSensitiveFiles: boolean = $ref(false);
let enableServiceWorker: boolean = $ref(false);
let swPublicKey: any = $ref(null);
let swPrivateKey: any = $ref(null);
let deeplAuthKey: string = $ref('');
let deeplIsPro: boolean = $ref(false);
async function init() {
async function init(): Promise<void> {
const meta = await os.api('admin/meta');
name = meta.name;
description = meta.description;
@ -126,6 +133,7 @@ async function init() {
maintainerEmail = meta.maintainerEmail;
pinnedUsers = meta.pinnedUsers.join('\n');
cacheRemoteFiles = meta.cacheRemoteFiles;
cacheRemoteSensitiveFiles = meta.cacheRemoteSensitiveFiles;
enableServiceWorker = meta.enableServiceWorker;
swPublicKey = meta.swPublickey;
swPrivateKey = meta.swPrivateKey;
@ -133,7 +141,7 @@ async function init() {
deeplIsPro = meta.deeplIsPro;
}
function save() {
function save(): void {
os.apiWithDialog('admin/update-meta', {
name,
description,
@ -141,6 +149,7 @@ function save() {
maintainerEmail,
pinnedUsers: pinnedUsers.split('\n'),
cacheRemoteFiles,
cacheRemoteSensitiveFiles,
enableServiceWorker,
swPublicKey,
swPrivateKey,

View file

@ -0,0 +1,114 @@
<template>
<MkStickyContainer>
<template #header>
<MkPageHeader/>
</template>
<MKSpacer v-if="!instance.disableRegistration || !($i && ($i.isAdmin || $i.policies.canInvite))" :contentMax="1200">
<div :class="$style.root">
<img :class="$style.img" :src="serverErrorImageUrl" class="_ghost"/>
<div :class="$style.text">
<i class="ti ti-alert-triangle"></i>
{{ i18n.ts.nothing }}
</div>
</div>
</MKSpacer>
<MkSpacer v-else :contentMax="800">
<div class="_gaps_m" style="text-align: center;">
<div v-if="resetCycle && inviteLimit">{{ i18n.t('inviteLimitResetCycle', { time: resetCycle, limit: inviteLimit }) }}</div>
<MkButton inline primary rounded :disabled="currentInviteLimit !== null && currentInviteLimit <= 0" @click="create"><i class="ti ti-user-plus"></i> {{ i18n.ts.createInviteCode }}</MkButton>
<div v-if="currentInviteLimit !== null">{{ i18n.t('createLimitRemaining', { limit: currentInviteLimit }) }}</div>
<MkPagination ref="pagingComponent" :pagination="pagination">
<template #default="{ items }">
<div class="_gaps_s">
<MkInviteCode v-for="item in (items as Invite[])" :key="item.id" :invite="item" :onDeleted="deleted"/>
</div>
</template>
</MkPagination>
</div>
</MkSpacer>
</MkStickyContainer>
</template>
<script lang="ts" setup>
import { computed, ref, shallowRef } from 'vue';
import type { Invite } from 'misskey-js/built/entities';
import { i18n } from '@/i18n';
import * as os from '@/os';
import MkButton from '@/components/MkButton.vue';
import MkPagination, { Paging } from '@/components/MkPagination.vue';
import MkInviteCode from '@/components/MkInviteCode.vue';
import { definePageMetadata } from '@/scripts/page-metadata';
import { serverErrorImageUrl, instance } from '@/instance';
import { $i } from '@/account';
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
const currentInviteLimit = ref<null | number>(null);
const inviteLimit = (($i != null && $i.policies.inviteLimit) || (($i == null && instance.policies.inviteLimit))) as number;
const inviteLimitCycle = (($i != null && $i.policies.inviteLimitCycle) || ($i == null && instance.policies.inviteLimitCycle)) as number;
const pagination: Paging = {
endpoint: 'invite/list' as const,
limit: 10,
};
const resetCycle = computed<null | string>(() => {
if (!inviteLimitCycle) return null;
const minutes = inviteLimitCycle;
if (minutes < 60) return minutes + i18n.ts._time.minute;
const hours = Math.floor(minutes / 60);
if (hours < 24) return hours + i18n.ts._time.hour;
return Math.floor(hours / 24) + i18n.ts._time.day;
});
async function create() {
const ticket = await os.api('invite/create');
os.alert({
type: 'success',
title: i18n.ts.inviteCodeCreated,
text: ticket.code,
});
pagingComponent.value?.prepend(ticket);
update();
}
function deleted(id: string) {
if (pagingComponent.value) {
pagingComponent.value.items.delete(id);
}
update();
}
async function update() {
currentInviteLimit.value = (await os.api('invite/limit')).remaining;
}
update();
definePageMetadata({
title: i18n.ts.invite,
icon: 'ti ti-user-plus',
});
</script>
<style lang="scss" module>
.root {
padding: 32px;
text-align: center;
align-items: center;
}
.text {
margin: 0 0 8px 0;
}
.img {
vertical-align: bottom;
width: 128px;
height: 128px;
margin-bottom: 16px;
border-radius: 16px;
}
</style>

View file

@ -14,7 +14,7 @@
<div v-if="items.length > 0" class="_gaps">
<MkA v-for="list in items" :key="list.id" class="_panel" :class="$style.list" :to="`/my/lists/${ list.id }`">
<div style="margin-bottom: 4px;">{{ list.name }}</div>
<div style="margin-bottom: 4px;">{{ list.name }} <span :class="$style.nUsers">({{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i?.policies['userEachUserListsLimit']}` }) }})</span></div>
<MkAvatars :userIds="list.userIds" :limit="10"/>
</MkA>
</div>
@ -32,6 +32,7 @@ import { i18n } from '@/i18n';
import { definePageMetadata } from '@/scripts/page-metadata';
import { userListsCache } from '@/cache';
import { infoImageUrl } from '@/instance';
import { $i } from '@/account';
const items = $computed(() => userListsCache.value.value ?? []);
@ -66,10 +67,6 @@ const headerTabs = $computed(() => []);
definePageMetadata({
title: i18n.ts.manageLists,
icon: 'ti ti-list',
action: {
icon: 'ti ti-plus',
handler: create,
},
});
onActivated(() => {
@ -90,4 +87,9 @@ onActivated(() => {
text-decoration: none;
}
}
.nUsers {
font-size: .9em;
opacity: .7;
}
</style>

View file

@ -20,6 +20,7 @@
<MkFolder defaultOpen>
<template #label>{{ i18n.ts.members }}</template>
<template #caption>{{ i18n.t('nUsers', { n: `${list.userIds.length}/${$i?.policies['userEachUserListsLimit']}` }) }}</template>
<div class="_gaps_s">
<MkButton rounded primary style="margin: 0 auto;" @click="addUser()">{{ i18n.ts.addUser }}</MkButton>
@ -29,6 +30,10 @@
</MkA>
<button class="_button" :class="$style.remove" @click="removeUser(user, $event)"><i class="ti ti-x"></i></button>
</div>
<MkButton v-if="!fetching && queueUserIds.length !== 0" v-appear="enableInfiniteScroll ? fetchMoreUsers : null" :class="$style.more" :style="{ cursor: 'pointer' }" primary rounded @click="fetchMoreUsers">
{{ i18n.ts.loadMore }}
</MkButton>
<MkLoading v-if="fetching" class="loading"/>
</div>
</MkFolder>
</div>
@ -49,34 +54,57 @@ import MkSwitch from '@/components/MkSwitch.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkInput from '@/components/MkInput.vue';
import { userListsCache } from '@/cache';
import { UserList, UserLite } from 'misskey-js/built/entities';
import { $i } from '@/account';
import { defaultStore } from '@/store';
const {
enableInfiniteScroll,
} = defaultStore.reactiveState;
const props = defineProps<{
listId: string;
}>();
let list = $ref(null);
let users = $ref([]);
const FETCH_USERS_LIMIT = 20;
let list = $ref<UserList | null>(null);
let users = $ref<UserLite[]>([]);
let queueUserIds = $ref<string[]>([]);
let fetching = $ref(true);
const isPublic = ref(false);
const name = ref('');
function fetchList() {
fetching = true;
os.api('users/lists/show', {
listId: props.listId,
}).then(_list => {
list = _list;
name.value = list.name;
isPublic.value = list.isPublic;
queueUserIds = list.userIds;
os.api('users/show', {
userIds: list.userIds,
}).then(_users => {
users = _users;
});
return fetchMoreUsers();
});
}
function fetchMoreUsers() {
if (!list) return;
if (fetching && users.length !== 0) return; // fetchingtrueusers
fetching = true;
os.api('users/show', {
userIds: queueUserIds.slice(0, FETCH_USERS_LIMIT),
}).then(_users => {
users = users.concat(_users);
queueUserIds = queueUserIds.slice(FETCH_USERS_LIMIT);
}).finally(() => {
fetching = false;
});
}
function addUser() {
os.selectUser().then(user => {
if (!list) return;
os.apiWithDialog('users/lists/push', {
listId: list.id,
userId: user.id,
@ -92,6 +120,7 @@ async function removeUser(user, ev) {
icon: 'ti ti-x',
danger: true,
action: async () => {
if (!list) return;
os.api('users/lists/pull', {
listId: list.id,
userId: user.id,
@ -103,6 +132,7 @@ async function removeUser(user, ev) {
}
async function deleteList() {
if (!list) return;
const { canceled } = await os.confirm({
type: 'warning',
text: i18n.t('removeAreYouSure', { x: list.name }),
@ -117,6 +147,7 @@ async function deleteList() {
}
async function updateSettings() {
if (!list) return;
await os.apiWithDialog('users/lists/update', {
listId: list.id,
name: name.value,
@ -166,6 +197,11 @@ definePageMetadata(computed(() => list ? {
align-self: center;
}
.more {
margin-left: auto;
margin-right: auto;
}
.footer {
-webkit-backdrop-filter: var(--blur, blur(15px));
backdrop-filter: var(--blur, blur(15px));

View file

@ -201,6 +201,10 @@ export const routes = [{
}, {
path: '/about-misskey',
component: page(() => import('./pages/about-misskey.vue')),
}, {
path: '/invite',
name: 'invite',
component: page(() => import('./pages/invite.vue')),
}, {
path: '/ads',
component: page(() => import('./pages/ads.vue')),
@ -428,6 +432,10 @@ export const routes = [{
path: '/server-rules',
name: 'server-rules',
component: page(() => import('./pages/admin/server-rules.vue')),
}, {
path: '/invites',
name: 'invites',
component: page(() => import('./pages/admin/invites.vue')),
}, {
path: '/',
component: page(() => import('./pages/_empty_.vue')),

View file

@ -132,9 +132,7 @@ export function play(type: 'noteMy' | 'note' | 'antenna' | 'channel' | 'notifica
}
export function playFile(file: string, volume: number) {
const masterVolume = soundConfigStore.state.sound_masterVolume;
if (masterVolume === 0) return;
const audio = setVolume(getAudio(file), volume);
if (audio.volume === 0) return;
audio.play();
}

View file

@ -33,7 +33,12 @@ export function openInstanceMenu(ev: MouseEvent) {
text: i18n.ts.ads,
icon: 'ti ti-ad',
to: '/ads',
}, {
}, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
type: 'link',
to: '/invite',
text: i18n.ts.invite,
icon: 'ti ti-user-plus',
} : undefined, {
type: 'parent',
text: i18n.ts.tools,
icon: 'ti ti-tool',
@ -52,23 +57,7 @@ export function openInstanceMenu(ev: MouseEvent) {
to: '/clicker',
text: '🍪👈',
icon: 'ti ti-cookie',
}, ($i && ($i.isAdmin || $i.policies.canInvite) && instance.disableRegistration) ? {
text: i18n.ts.invite,
icon: 'ti ti-user-plus',
action: () => {
os.api('invite').then(x => {
os.alert({
type: 'info',
text: x.code,
});
}).catch(err => {
os.alert({
type: 'error',
text: err,
});
});
},
} : undefined, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
}, ($i && ($i.isAdmin || $i.policies.canManageCustomEmojis)) ? {
type: 'link',
to: '/custom-emojis-manager',
text: i18n.ts.manageCustomEmojis,

View file

@ -271,6 +271,7 @@ type DetailedInstanceMetadata = LiteInstanceMetadata & {
pinnedPages: string[];
pinnedClipId: string | null;
cacheRemoteFiles: boolean;
cacheRemoteSensitiveFiles: boolean;
requireSetup: boolean;
proxyAccountName: string | null;
features: Record<string, any>;
@ -327,6 +328,10 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
'admin/meta': {
req: TODO;
res: TODO;
};
'admin/reset-password': {
req: TODO;
res: TODO;
@ -481,6 +486,14 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
'admin/invite/create': {
req: TODO;
res: TODO;
};
'admin/invite/list': {
req: TODO;
res: TODO;
};
'admin/moderators/add': {
req: TODO;
res: TODO;
@ -1549,6 +1562,28 @@ export type Endpoints = {
req: TODO;
res: TODO;
};
'invite/create': {
req: NoParams;
res: Invite;
};
'invite/delete': {
req: {
inviteId: Invite['id'];
};
res: null;
};
'invite/list': {
req: {
limit?: number;
sinceId?: Invite['id'];
untilId?: Invite['id'];
};
res: Invite[];
};
'invite/limit': {
req: NoParams;
res: InviteLimit;
};
'messaging/history': {
req: {
limit?: number;
@ -2210,6 +2245,8 @@ declare namespace entities {
Blocking,
Instance,
Signin,
Invite,
InviteLimit,
UserSorting,
OriginType
}
@ -2310,6 +2347,23 @@ type Instance = {
// @public (undocumented)
type InstanceMetadata = LiteInstanceMetadata | DetailedInstanceMetadata;
// @public (undocumented)
type Invite = {
id: ID;
code: string;
expiresAt: DateString | null;
createdAt: DateString;
createdBy: UserLite | null;
usedBy: UserLite | null;
usedAt: DateString | null;
used: boolean;
};
// @public (undocumented)
type InviteLimit = {
remaining: number;
};
// @public (undocumented)
function isAPIError(reason: any): reason is APIError;
@ -2756,7 +2810,7 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
//
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:620:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/api.types.ts:629:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package)

View file

@ -20,13 +20,13 @@
"url": "git+https://github.com/misskey-dev/misskey.js.git"
},
"devDependencies": {
"@microsoft/api-extractor": "7.36.1",
"@microsoft/api-extractor": "7.36.2",
"@swc/jest": "0.2.26",
"@types/jest": "29.5.2",
"@types/node": "20.4.0",
"@types/jest": "29.5.3",
"@types/node": "20.4.2",
"@typescript-eslint/eslint-plugin": "5.61.0",
"@typescript-eslint/parser": "5.61.0",
"eslint": "8.44.0",
"eslint": "8.45.0",
"jest": "29.6.1",
"jest-fetch-mock": "3.0.3",
"jest-websocket-mock": "2.4.0",
@ -39,7 +39,7 @@
],
"dependencies": {
"@swc/cli": "0.1.62",
"@swc/core": "1.3.68",
"@swc/core": "1.3.69",
"eventemitter3": "5.0.1",
"reconnecting-websocket": "4.4.0"
}

View file

@ -2,7 +2,7 @@ import type {
Ad, Announcement, Antenna, App, AuthSession, Blocking, Channel, Clip, DateString, DetailedInstanceMetadata, DriveFile, DriveFolder, Following, FollowingFolloweePopulated, FollowingFollowerPopulated, FollowRequest, GalleryPost, Instance,
LiteInstanceMetadata,
MeDetailed,
Note, NoteFavorite, OriginType, Page, ServerInfo, Stats, User, UserDetailed, MeSignup, UserGroup, UserList, UserSorting, Notification, NoteReaction, Signin, MessagingMessage,
Note, NoteFavorite, OriginType, Page, ServerInfo, Stats, User, UserDetailed, MeSignup, UserGroup, UserList, UserSorting, Notification, NoteReaction, Signin, MessagingMessage, Invite, InviteLimit,
} from './entities.js';
type TODO = Record<string, any> | null;
@ -20,6 +20,7 @@ export type Endpoints = {
'admin/get-table-stats': { req: TODO; res: TODO; };
'admin/invite': { req: TODO; res: TODO; };
'admin/logs': { req: TODO; res: TODO; };
'admin/meta': { req: TODO; res: TODO; };
'admin/reset-password': { req: TODO; res: TODO; };
'admin/resolve-abuse-user-report': { req: TODO; res: TODO; };
'admin/resync-chart': { req: TODO; res: TODO; };
@ -57,6 +58,8 @@ export type Endpoints = {
'admin/federation/refresh-remote-instance-metadata': { req: TODO; res: TODO; };
'admin/federation/remove-all-following': { req: TODO; res: TODO; };
'admin/federation/update-instance': { req: TODO; res: TODO; };
'admin/invite/create': { req: TODO; res: TODO; };
'admin/invite/list': { req: TODO; res: TODO; };
'admin/moderators/add': { req: TODO; res: TODO; };
'admin/moderators/remove': { req: TODO; res: TODO; };
'admin/promo/create': { req: TODO; res: TODO; };
@ -440,6 +443,12 @@ export type Endpoints = {
'i/2fa/remove-key': { req: TODO; res: TODO; };
'i/2fa/unregister': { req: TODO; res: TODO; };
// invite
'invite/create': { req: NoParams; res: Invite; };
'invite/delete': { req: { inviteId: Invite['id']; }; res: null; };
'invite/list': { req: { limit?: number; sinceId?: Invite['id']; untilId?: Invite['id'] }; res: Invite[]; };
'invite/limit': { req: NoParams; res: InviteLimit; };
// messaging
'messaging/history': { req: { limit?: number; group?: boolean; }; res: MessagingMessage[]; };
'messaging/messages': { req: { userId?: User['id']; groupId?: UserGroup['id']; limit?: number; sinceId?: MessagingMessage['id']; untilId?: MessagingMessage['id']; markAsRead?: boolean; }; res: MessagingMessage[]; };

View file

@ -338,6 +338,7 @@ export type DetailedInstanceMetadata = LiteInstanceMetadata & {
pinnedPages: string[];
pinnedClipId: string | null;
cacheRemoteFiles: boolean;
cacheRemoteSensitiveFiles: boolean;
requireSetup: boolean;
proxyAccountName: string | null;
features: Record<string, any>;
@ -516,6 +517,21 @@ export type Signin = {
success: boolean;
};
export type Invite = {
id: ID;
code: string;
expiresAt: DateString | null;
createdAt: DateString;
createdBy: UserLite | null;
usedBy: UserLite | null;
usedAt: DateString | null;
used: boolean;
}
export type InviteLimit = {
remaining: number;
}
export type UserSorting =
| '+follower'
| '-follower'

View file

@ -16,7 +16,7 @@
"devDependencies": {
"@typescript-eslint/parser": "5.61.0",
"@typescript/lib-webworker": "npm:@types/serviceworker@0.0.67",
"eslint": "8.44.0",
"eslint": "8.45.0",
"eslint-plugin-import": "2.27.5",
"typescript": "5.1.6"
},

View file

@ -199,6 +199,13 @@ async function composeNotification(data: PushNotificationDataMap[keyof PushNotif
tag: `achievement:${data.body.achievement}`,
}];
case 'pollEnded':
return [t('_notification.pollEnded'), {
body: data.body.note.text ?? '',
badge: iconUrl('chart-arrows'),
data,
}];
case 'app':
return [data.body.header ?? data.body.body, {
body: data.body.header ? data.body.body : '',

File diff suppressed because it is too large Load diff