1
0
mirror of https://github.com/misskey-dev/misskey.git synced 2026-05-22 14:04:08 +02:00

Merge branch 'develop' into mahjong

This commit is contained in:
syuilo
2026-01-22 11:47:13 +09:00
1550 changed files with 116596 additions and 65108 deletions

View File

@@ -3,12 +3,17 @@
"jsc": {
"parser": {
"syntax": "typescript",
"jsx": true,
"dynamicImport": true,
"decorators": true
},
"transform": {
"legacyDecorator": true,
"decoratorMetadata": true
"decoratorMetadata": true,
"react": {
"runtime": "automatic",
"importSource": "@kitajs/html"
}
},
"experimental": {
"keepImportAssertions": true

View File

@@ -9,7 +9,7 @@ window.onload = async () => {
const account = JSON.parse(localStorage.getItem('account'));
const i = account.token;
const api = (endpoint, data = {}) => {
const _api = (endpoint, data = {}) => {
const promise = new Promise((resolve, reject) => {
// Append a credential
if (i) data.i = i;

View File

@@ -0,0 +1,46 @@
(async () => {
const msg = document.getElementById('msg');
const successText = `\nSuccess Flush! <a href="/">Back to Misskey</a>\n成功しました。<a href="/">Misskeyを開き直してください。</a>`;
if (!document.cookie) {
message('Your site data is fully cleared by your browser.');
message(successText);
} else {
message('Your browser does not support Clear-Site-Data header. Start opportunistic flushing.');
try {
localStorage.clear();
message('localStorage cleared.');
const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => {
const delidb = indexedDB.deleteDatabase(name);
delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`));
delidb.onerror = e => rej(e)
}));
await Promise.all(idbPromises);
if (navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage('clear');
await navigator.serviceWorker.getRegistrations()
.then(registrations => {
return Promise.all(registrations.map(registration => registration.unregister()));
})
.catch(e => { throw new Error(e) });
}
message(successText);
} catch (e) {
message(`\n${e}\n\nFlush Failed. <a href="/flush">Please retry.</a>\n失敗しました。<a href="/flush">もう一度試してみてください。</a>`);
message(`\nIf you retry more than 3 times, try manually clearing the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを手動で消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`)
console.error(e);
setTimeout(() => {
location = '/';
}, 10000)
}
}
function message(text) {
msg.insertAdjacentHTML('beforeend', `<p>[${(new Date()).toString()}] ${text.replace(/\n/g,'<br>')}</p>`)
}
})();

View File

@@ -0,0 +1,35 @@
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
background: #fff;
}
#a {
display: block;
}
#banner {
background-size: cover;
background-position: center center;
}
#title {
display: inline-block;
margin: 24px;
padding: 0.5em 0.8em;
color: #fff;
background: rgba(0, 0, 0, 0.5);
font-weight: bold;
font-size: 1.3em;
}
#content {
overflow: auto;
color: #353c3e;
}
#description {
margin: 24px;
}

121
packages/backend/build.js Normal file
View File

@@ -0,0 +1,121 @@
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { build } from 'esbuild';
import { swcPlugin } from 'esbuild-plugin-swc';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const _package = JSON.parse(fs.readFileSync(_dirname + '/package.json', 'utf-8'));
const resolveTsPathsPlugin = {
name: 'resolve-ts-paths',
setup(build) {
build.onResolve({ filter: /^\.{1,2}\/.*\.js$/ }, (args) => {
if (args.importer) {
const absPath = join(args.resolveDir, args.path);
const tsPath = absPath.slice(0, -3) + '.ts';
if (fs.existsSync(tsPath)) return { path: tsPath };
const tsxPath = absPath.slice(0, -3) + '.tsx';
if (fs.existsSync(tsxPath)) return { path: tsxPath };
}
});
},
};
const externalIpaddrPlugin = {
name: 'external-ipaddr',
setup(build) {
build.onResolve({ filter: /^ipaddr\.js$/ }, (args) => {
return { path: args.path, external: true };
});
},
};
/** @type {import('esbuild').BuildOptions} */
const options = {
entryPoints: ['./src/boot/entry.ts'],
minify: true,
keepNames: true,
bundle: true,
outdir: './built/boot',
target: 'node22',
platform: 'node',
format: 'esm',
sourcemap: 'linked',
packages: 'external',
banner: {
js: 'import { createRequire as topLevelCreateRequire } from "module";' +
'import ___url___ from "url";' +
'const require = topLevelCreateRequire(import.meta.url);' +
'const __filename = ___url___.fileURLToPath(import.meta.url);' +
'const __dirname = ___url___.fileURLToPath(new URL(".", import.meta.url));',
},
plugins: [
externalIpaddrPlugin,
resolveTsPathsPlugin,
swcPlugin({
jsc: {
parser: {
syntax: 'typescript',
decorators: true,
dynamicImport: true,
},
transform: {
legacyDecorator: true,
decoratorMetadata: true,
},
experimental: {
keepImportAssertions: true,
},
baseUrl: join(_dirname, 'src'),
paths: {
'@/*': ['*'],
},
target: 'esnext',
keepClassNames: true,
},
}),
externalIpaddrPlugin,
],
// external: [
// 'slacc-*',
// 'class-transformer',
// 'class-validator',
// '@sentry/*',
// '@nestjs/websockets/socket-module',
// '@nestjs/microservices/microservices-module',
// '@nestjs/microservices',
// '@napi-rs/canvas-win32-x64-msvc',
// 'mock-aws-s3',
// 'aws-sdk',
// 'nock',
// 'sharp',
// 'jsdom',
// 're2',
// '@napi-rs/canvas',
// ],
};
const args = process.argv.slice(2).map(arg => arg.toLowerCase());
if (!args.includes('--no-clean')) {
fs.rmSync('./built', { recursive: true, force: true });
}
await buildSrc();
async function buildSrc() {
console.log(`[${_package.name}] start building...`);
await build(options)
.then(() => {
console.log(`[${_package.name}] build succeeded.`);
})
.catch((err) => {
process.stderr.write(err.stderr || err.message || err);
process.exit(1);
});
console.log(`[${_package.name}] finish building.`);
}

View File

@@ -1,4 +1,5 @@
import tsParser from '@typescript-eslint/parser';
import globals from 'globals';
import sharedConfig from '../shared/eslint.config.js';
export default [
@@ -6,6 +7,13 @@ export default [
{
ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'],
},
{
languageOptions: {
globals: {
...globals.node,
},
},
},
{
files: ['**/*.ts', '**/*.tsx'],
languageOptions: {

View File

@@ -205,7 +205,7 @@ module.exports = {
// Whether to use watchman for file crawling
// watchman: true,
extensionsToTreatAsEsm: ['.ts'],
extensionsToTreatAsEsm: ['.ts', '.tsx'],
testTimeout: 60000,

View File

@@ -7,6 +7,7 @@ const base = require('./jest.config.cjs')
module.exports = {
...base,
globalSetup: "<rootDir>/test/jest.setup.unit.cjs",
testMatch: [
"<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts",

31
packages/backend/jest.js Normal file
View File

@@ -0,0 +1,31 @@
#!/usr/bin/env node
import child_process from 'node:child_process';
import path from 'node:path';
import url from 'node:url';
import semver from 'semver';
const __filename = url.fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const args = [];
args.push(...[
...semver.satisfies(process.version, '^20.17.0 || ^22.0.0 || ^24.10.0') ? ['--no-experimental-require-module'] : [],
'--experimental-vm-modules',
'--experimental-import-meta-resolve',
path.join(__dirname, 'node_modules/jest/bin/jest.js'),
...process.argv.slice(2),
]);
const child = child_process.spawn(process.execPath, args, { stdio: 'inherit' });
child.on('error', (err) => {
console.error('Failed to start Jest:', err);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (code === null) {
process.exit(128 + signal);
} else {
process.exit(code);
}
});

View File

@@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"allowSyntheticDefaultImports": true
},
"exclude": [
"node_modules",
"jspm_packages",
"tmp",
"temp"
]
}

View File

@@ -0,0 +1,91 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class CreateNoteDraft1736686850345 {
name = 'CreateNoteDraft1736686850345'
async up(queryRunner) {
await queryRunner.query(`
CREATE TABLE "note_draft" (
"id" varchar NOT NULL,
"replyId" varchar NULL,
"renoteId" varchar NULL,
"text" text NULL,
"cw" varchar(512) NULL,
"userId" varchar NOT NULL,
"localOnly" boolean DEFAULT false,
"reactionAcceptance" varchar(64) NULL,
"visibility" varchar NOT NULL,
"fileIds" varchar[] DEFAULT '{}',
"visibleUserIds" varchar[] DEFAULT '{}',
"hashtag" varchar(128) NULL,
"channelId" varchar NULL,
"hasPoll" boolean DEFAULT false,
"pollChoices" varchar(256)[] DEFAULT '{}',
"pollMultiple" boolean NULL,
"pollExpiresAt" TIMESTAMP WITH TIME ZONE NULL,
"pollExpiredAfter" bigint NULL,
PRIMARY KEY ("id")
)`);
await queryRunner.query(`
CREATE INDEX "IDX_NOTE_DRAFT_REPLY_ID" ON "note_draft" ("replyId")
`);
await queryRunner.query(`
CREATE INDEX "IDX_NOTE_DRAFT_RENOTE_ID" ON "note_draft" ("renoteId")
`);
await queryRunner.query(`
CREATE INDEX "IDX_NOTE_DRAFT_USER_ID" ON "note_draft" ("userId")
`);
await queryRunner.query(`
CREATE INDEX "IDX_NOTE_DRAFT_FILE_IDS" ON "note_draft" USING GIN ("fileIds")
`);
await queryRunner.query(`
CREATE INDEX "IDX_NOTE_DRAFT_VISIBLE_USER_IDS" ON "note_draft" USING GIN ("visibleUserIds")
`);
await queryRunner.query(`
CREATE INDEX "IDX_NOTE_DRAFT_CHANNEL_ID" ON "note_draft" ("channelId")
`);
await queryRunner.query(`
ALTER TABLE "note_draft"
ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE
`);
await queryRunner.query(`
ALTER TABLE "note_draft"
ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE
`);
await queryRunner.query(`
ALTER TABLE "note_draft"
ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE
`);
await queryRunner.query(`
ALTER TABLE "note_draft"
ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE
`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`);
await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_CHANNEL_ID"`);
await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_VISIBLE_USER_IDS"`);
await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_FILE_IDS"`);
await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_USER_ID"`);
await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_RENOTE_ID"`);
await queryRunner.query(`DROP INDEX "IDX_NOTE_DRAFT_REPLY_ID"`);
await queryRunner.query(`DROP TABLE "note_draft"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class DeliverSuspendedSoftware1743403874305 {
name = 'DeliverSuspendedSoftware1743403874305'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "deliverSuspendedSoftware" jsonb NOT NULL DEFAULT '[]'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "deliverSuspendedSoftware"`);
}
}

View File

@@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
export class CompositeNoteIndex1745378064470 {
name = 'CompositeNoteIndex1745378064470';
transaction = isConcurrentIndexMigrationEnabled ? false : undefined;
async up(queryRunner) {
const concurrently = isConcurrentIndexMigrationEnabled;
if (concurrently) {
const hasValidIndex = await queryRunner.query(`SELECT indisvalid FROM pg_index INNER JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'IDX_724b311e6f883751f261ebe378'`);
if (hasValidIndex.length === 0 || hasValidIndex[0].indisvalid !== true) {
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX CONCURRENTLY "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
}
} else {
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
}
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_5b87d9d19127bd5d92026017a7"`);
// Flush all cached Linear Scan Plans and redo statistics for composite index
// this is important for Postgres to learn that even in highly complex queries, using this index first can reduce the result set significantly
await queryRunner.query(`ANALYZE "user", "note"`);
}
async down(queryRunner) {
const mayConcurrently = isConcurrentIndexMigrationEnabled ? 'CONCURRENTLY' : '';
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644 {
name = 'VisibleUserGeneratedContentsForNonLoggedInVisitors1746330901644'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "ugcVisibilityForVisitor" character varying(128) NOT NULL DEFAULT 'local'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "ugcVisibilityForVisitor"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SingleUserMode1746422049376 {
name = 'SingleUserMode1746422049376'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "singleUserMode" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "singleUserMode"`);
}
}

View File

@@ -0,0 +1,22 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MigrateSomeConfigFileSettingsToMeta1746949539915 {
name = 'MigrateSomeConfigFileSettingsToMeta1746949539915'
async up(queryRunner) {
// $1 cannot be used in ALTER TABLE queries
await queryRunner.query(`ALTER TABLE "meta" ADD "proxyRemoteFiles" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "signToActivityPubGet" boolean NOT NULL DEFAULT TRUE`);
await queryRunner.query(`ALTER TABLE "meta" ADD "allowExternalApRedirect" boolean NOT NULL DEFAULT TRUE`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "allowExternalApRedirect"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "signToActivityPubGet"`);
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "proxyRemoteFiles"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddUrlPreviewAllowRedirect1748310233000 {
name = 'AddUrlPreviewAllowRedirect1748310233000'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "urlPreviewAllowRedirect" boolean NOT NULL DEFAULT true`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "urlPreviewAllowRedirect"`);
}
}

View File

@@ -0,0 +1,15 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class FixAvatarUrl1750729939704 {
name = 'FixAvatarUrl1750729939704'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "avatarUrl" TYPE character varying(1024)`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user" ALTER COLUMN "avatarUrl" TYPE character varying(512)`);
}
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NoActionOnDraftRelation1752502434151 {
name = 'NoActionOnDraftRelation1752502434151'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_USER_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID"`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_e4983f28b4b18b03491536052f5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" DROP CONSTRAINT "FK_e4983f28b4b18b03491536052f5"`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_REPLY_ID" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_RENOTE_ID" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_USER_ID" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD CONSTRAINT "FK_NOTE_DRAFT_CHANNEL_ID" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View File

@@ -0,0 +1,79 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class MigrationCleanup1752509043847 {
name = 'MigrationCleanup1752509043847'
async up(queryRunner) {
// 1745378064470-composite-note-index.js created a index ON "note" ("userId", "id" DESC) as IDX_724b311e6f883751f261ebe378 but should be named IDX_a6f649630f55af3888e5a42919
await queryRunner.query(`ALTER INDEX "IDX_724b311e6f883751f261ebe378" RENAME TO "IDX_a6f649630f55af3888e5a42919"`);
// 1713656541000-abuse-report-notification.js generated system_webhook with hand-written SQL with CURRENT_TIMESTAMP as the default value, but its representation in TypeORM is `now()`
// see https://github.com/typeorm/typeorm/blob/f351757a15b9d2bd9d4222c69dcfd2316f46b5d1/src/driver/postgres/PostgresDriver.ts#L1575
await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT now()`);
// 1702718871541-ffVisibility.js defined a enum type "user_profile_followersVisibility_enum" but it should be "user_profile_followersvisibility_enum" (lowercase 'v') in typeorm
await queryRunner.query(`ALTER TYPE "public"."user_profile_followersVisibility_enum" RENAME TO "user_profile_followersvisibility_enum"`);
// 1713656541000-abuse-report-notification.js generated abuse_report_notification_recipient with hand-written SQL with CURRENT_TIMESTAMP as the default value, but its representation in TypeORM is `now()`
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT now()`);
// 1690796169261-play-visibility.js added visibility column to flash table but it forgot to set NOT NULL constraint
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" SET NOT NULL`);
// 1736686850345-createNoteDraft.js created note_draft with hand-written SQL but several types and comments are not correctly defined
await queryRunner.query(`CREATE TYPE "public"."note_draft_visibility_enum" AS ENUM('public', 'home', 'followers', 'specified')`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "id" SET DATA TYPE character varying(32)`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "replyId" SET DATA TYPE character varying(32)`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "renoteId" SET DATA TYPE character varying(32)`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "userId" SET DATA TYPE character varying(32)`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "channelId" SET DATA TYPE character varying(32)`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET DATA TYPE character varying(32) array`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET DATA TYPE character varying(32) array`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibility" SET DATA TYPE "public"."note_draft_visibility_enum" USING visibility::note_draft_visibility_enum`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."replyId" IS 'The ID of reply target.'`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."renoteId" IS 'The ID of renote target.'`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."userId" IS 'The ID of author.'`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."channelId" IS 'The ID of source channel.'`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "hasPoll" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollChoices" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollMultiple" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "localOnly" SET NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET NOT NULL`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "localOnly" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollMultiple" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "pollChoices" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "hasPoll" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" DROP NOT NULL`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."channelId" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."userId" IS 'The ID of author.'`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."renoteId" IS NULL`);
await queryRunner.query(`COMMENT ON COLUMN "note_draft"."replyId" IS NULL`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibility" SET DATA TYPE varchar`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "visibleUserIds" SET DATA TYPE varchar[]`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "fileIds" SET DATA TYPE varchar[]`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "channelId" SET DATA TYPE varchar`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "userId" SET DATA TYPE varchar`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "renoteId" SET DATA TYPE varchar`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "replyId" SET DATA TYPE varchar`);
await queryRunner.query(`ALTER TABLE "note_draft" ALTER COLUMN "id" SET DATA TYPE varchar`);
await queryRunner.query(`DROP TYPE "public"."note_draft_visibility_enum"`);
await queryRunner.query(`ALTER TABLE "flash" ALTER COLUMN "visibility" DROP NOT NULL`);
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`);
await queryRunner.query(`ALTER TYPE "public"."user_profile_followersvisibility_enum" RENAME TO "user_profile_followersVisibility_enum"`);
await queryRunner.query(`ALTER TABLE "system_webhook" ALTER COLUMN "updatedAt" SET DEFAULT CURRENT_TIMESTAMP`);
await queryRunner.query(`ALTER INDEX "IDX_a6f649630f55af3888e5a42919" RENAME TO "IDX_724b311e6f883751f261ebe378"`);
}
}

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RemoteNotesCleaning1753863104203 {
name = 'RemoteNotesCleaning1753863104203'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "enableRemoteNotesCleaning" boolean NOT NULL DEFAULT false`);
await queryRunner.query('ALTER TABLE "meta" ADD "remoteNotesCleaningMaxProcessingDurationInMinutes" integer NOT NULL DEFAULT \'60\'');
await queryRunner.query('ALTER TABLE "meta" ADD "remoteNotesCleaningExpiryDaysForEachNotes" integer NOT NULL DEFAULT \'90\'');
}
async down(queryRunner) {
await queryRunner.query('ALTER TABLE "meta" DROP COLUMN "remoteNotesCleaningExpiryDaysForEachNotes"');
await queryRunner.query('ALTER TABLE "meta" DROP COLUMN "remoteNotesCleaningMaxProcessingDurationInMinutes"');
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "enableRemoteNotesCleaning"`);
}
}

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RemoveNoteConstraints1753868431598 {
name = 'RemoveNoteConstraints1753868431598'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_52ccc804d7c69037d558bac4c96"`);
await queryRunner.query(`ALTER TABLE "note" DROP CONSTRAINT "FK_17cb3553c700a4985dff5a30ff5"`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_17cb3553c700a4985dff5a30ff5" FOREIGN KEY ("replyId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "note" ADD CONSTRAINT "FK_52ccc804d7c69037d558bac4c96" FOREIGN KEY ("renoteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class TweakDefaultFederationSettings1754019326356 {
name = 'TweakDefaultFederationSettings1754019326356'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "federation" SET DEFAULT 'none'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "federation" SET DEFAULT 'all'`);
}
}

View File

@@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class PageCountInNote1755168347001 {
name = 'PageCountInNote1755168347001'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" ADD "pageCount" smallint NOT NULL DEFAULT '0'`);
// Update existing notes
// block_list CTE collects all page blocks on the pages including child blocks in the section blocks.
// The clipped_notes CTE counts how many distinct pages each note block is referenced in.
// Finally, we update the note table with the count of pages for each referenced note.
await queryRunner.query(`
WITH RECURSIVE block_list AS (
(
SELECT
page.id as page_id,
block as block
FROM page
CROSS JOIN LATERAL jsonb_array_elements(page.content) block
WHERE block->>'type' = 'note' OR block->>'type' = 'section'
)
UNION ALL
(
SELECT
block_list.page_id,
child_block AS block
FROM LATERAL (
SELECT page_id, block
FROM block_list
WHERE block_list.block->>'type' = 'section'
) block_list
CROSS JOIN LATERAL jsonb_array_elements(block_list.block->'children') child_block
WHERE child_block->>'type' = 'note' OR child_block->>'type' = 'section'
)
),
clipped_notes AS (
SELECT
(block->>'note') AS note_id,
COUNT(distinct block_list.page_id) AS count
FROM block_list
WHERE block_list.block->>'type' = 'note'
GROUP BY block->>'note'
)
UPDATE note
SET "pageCount" = clipped_notes.count
FROM clipped_notes
WHERE note.id = clipped_notes.note_id;
`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "pageCount"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class EntrancePageStyle1755574887486 {
name = 'EntrancePageStyle1755574887486'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "clientOptions" jsonb NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "clientOptions"`);
}
}

View File

@@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NonCascadingPageEyeCatching1756062689648 {
name = 'NonCascadingPageEyeCatching1756062689648'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "page" DROP CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa"`);
await queryRunner.query(`ALTER TABLE "page" ADD CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE SET NULL ON UPDATE NO ACTION`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "page" DROP CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa"`);
await queryRunner.query(`ALTER TABLE "page" ADD CONSTRAINT "FK_a9ca79ad939bf06066b81c9d3aa" FOREIGN KEY ("eyeCatchingImageId") REFERENCES "drive_file"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class SensitiveAd1757823175259 {
name = 'SensitiveAd1757823175259'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" ADD "isSensitive" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "ad" DROP COLUMN "isSensitive"`);
}
}

View File

@@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class ScheduledPost1758677617888 {
name = 'ScheduledPost1758677617888'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" ADD "scheduledAt" TIMESTAMP WITH TIME ZONE`);
await queryRunner.query(`ALTER TABLE "note_draft" ADD "isActuallyScheduled" boolean NOT NULL DEFAULT false`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "isActuallyScheduled"`);
await queryRunner.query(`ALTER TABLE "note_draft" DROP COLUMN "scheduledAt"`);
}
}

View File

@@ -0,0 +1,16 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class RoleBadgesRemoteUsers1760607435831 {
name = 'RoleBadgesRemoteUsers1760607435831'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ADD "showRoleBadgesOfRemoteUsers" boolean NOT NULL DEFAULT false`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" DROP COLUMN "showRoleBadgesOfRemoteUsers"`);
}
}

View File

@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class UnnecessaryNullDefault1760790899857 {
name = 'UnnecessaryNullDefault1760790899857'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" DROP DEFAULT`);
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" DROP DEFAULT`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "meta" ALTER COLUMN "urlPreviewUserAgent" SET DEFAULT NULL`);
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "systemWebhookId" SET DEFAULT NULL`);
await queryRunner.query(`ALTER TABLE "abuse_report_notification_recipient" ALTER COLUMN "userId" SET DEFAULT NULL`);
}
}

View File

@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class AddChannelMuting1761569941833 {
name = 'AddChannelMuting1761569941833'
/**
* @param {QueryRunner} queryRunner
*/
async up(queryRunner) {
await queryRunner.query(`CREATE TABLE "channel_muting" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "channelId" character varying(32) NOT NULL, "expiresAt" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_aec842e98f332ebd8e12f85bad6" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE INDEX "IDX_34415e3062ae7a94617496e81c" ON "channel_muting" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_4d534d7177fc59879d942e96d0" ON "channel_muting" ("channelId") `);
await queryRunner.query(`CREATE INDEX "IDX_6dd314e96806b7df65ddadff72" ON "channel_muting" ("expiresAt") `);
await queryRunner.query(`CREATE INDEX "IDX_b96870ed326ccc7fa243970965" ON "channel_muting" ("userId", "channelId") `);
await queryRunner.query(`ALTER TABLE "note" ADD "renoteChannelId" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_34415e3062ae7a94617496e81c5" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "channel_muting" ADD CONSTRAINT "FK_4d534d7177fc59879d942e96d03" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
}
/**
* @param {QueryRunner} queryRunner
*/
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_4d534d7177fc59879d942e96d03"`);
await queryRunner.query(`ALTER TABLE "channel_muting" DROP CONSTRAINT "FK_34415e3062ae7a94617496e81c5"`);
await queryRunner.query(`COMMENT ON COLUMN "note"."renoteChannelId" IS '[Denormalized]'`);
await queryRunner.query(`ALTER TABLE "note" DROP COLUMN "renoteChannelId"`);
await queryRunner.query(`DROP INDEX "public"."IDX_b96870ed326ccc7fa243970965"`);
await queryRunner.query(`DROP INDEX "public"."IDX_6dd314e96806b7df65ddadff72"`);
await queryRunner.query(`DROP INDEX "public"."IDX_4d534d7177fc59879d942e96d0"`);
await queryRunner.query(`DROP INDEX "public"."IDX_34415e3062ae7a94617496e81c"`);
await queryRunner.query(`DROP TABLE "channel_muting"`);
}
}

View File

@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class BirthdayIndex1767169026317 {
name = 'BirthdayIndex1767169026317'
async up(queryRunner) {
await queryRunner.query(`DROP INDEX "public"."IDX_de22cd2b445eee31ae51cdbe99"`);
await queryRunner.query(`CREATE OR REPLACE FUNCTION get_birthday_date(birthday TEXT) RETURNS SMALLINT AS $$ BEGIN RETURN CAST((SUBSTR(birthday, 6, 2) || SUBSTR(birthday, 9, 2)) AS SMALLINT); END; $$ LANGUAGE plpgsql IMMUTABLE;`);
await queryRunner.query(`CREATE INDEX "IDX_USERPROFILE_BIRTHDAY_DATE" ON "user_profile" (get_birthday_date("birthday"))`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE INDEX "IDX_de22cd2b445eee31ae51cdbe99" ON "user_profile" (substr("birthday", 6, 5))`);
await queryRunner.query(`DROP INDEX "public"."IDX_USERPROFILE_BIRTHDAY_DATE"`);
await queryRunner.query(`DROP FUNCTION IF EXISTS get_birthday_date(birthday TEXT)`);
}
}

View File

@@ -1,6 +1,8 @@
import { DataSource } from 'typeorm';
import { loadConfig } from './built/config.js';
import { entities } from './built/postgres.js';
import { loadConfig } from './src-js/config.js';
import { entities } from './src-js/postgres.js';
const isConcurrentIndexMigrationEnabled = process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
const config = loadConfig();
@@ -14,4 +16,5 @@ export default new DataSource({
extra: config.db.extra,
entities: entities,
migrations: ['migration/*.js'],
migrationsTransactionMode: isConcurrentIndexMigrationEnabled ? 'each' : 'all',
});

View File

@@ -4,53 +4,57 @@
"private": true,
"type": "module",
"engines": {
"node": "^20.10.0 || ^22.0.0"
"node": "^22.15.0 || ^24.10.0"
},
"scripts": {
"start": "node ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm typeorm migration:revert -d ormconfig.js",
"check:connect": "node ./scripts/check_connect.js",
"build": "swc src -d built -D --strip-leading-paths",
"start": "pnpm compile-config && node ./built/boot/entry.js",
"start:inspect": "pnpm compile-config && node --inspect ./built/boot/entry.js",
"start:test": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./built/boot/entry.js",
"migrate": "pnpm compile-config && pnpm typeorm migration:run -d ormconfig.js",
"revert": "pnpm compile-config && pnpm typeorm migration:revert -d ormconfig.js",
"cli": "pnpm compile-config && node ./src-js/boot/cli.js",
"check:connect": "pnpm compile-config && node ./scripts/check_connect.js",
"compile-config": "node ./scripts/compile_config.js",
"build": "swc src -d src-js -D --strip-leading-paths && node ./build.js",
"build:test": "swc test-server -d built-test -D --config-file test-server/.swcrc --strip-leading-paths",
"watch:swc": "swc src -d built -D -w --strip-leading-paths",
"build:tsc": "tsc -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "node ./scripts/watch.mjs",
"build:tsc": "tsgo -p tsconfig.json && tsc-alias -p tsconfig.json",
"watch": "pnpm compile-config && node ./scripts/watch.mjs",
"restart": "pnpm build && pnpm start",
"dev": "node ./scripts/dev.mjs",
"typecheck": "tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
"dev": "pnpm compile-config && node ./scripts/dev.mjs",
"typecheck": "tsgo --noEmit && tsgo -p test --noEmit && tsgo -p test-federation --noEmit",
"eslint": "eslint --quiet \"{src,test-federation}/**/*.ts\"",
"lint": "pnpm typecheck && pnpm eslint",
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --clearCache",
"jest": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.unit.cjs",
"jest:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
"jest:fed": "pnpm compile-config && node ./jest.js --forceExit --config jest.config.fed.cjs",
"jest-and-coverage": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
"jest-and-coverage:e2e": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
"jest-clear": "cross-env NODE_ENV=test pnpm compile-config && cross-env NODE_ENV=test node ./jest.js --clearCache",
"test": "pnpm jest",
"test:e2e": "pnpm build && pnpm build:test && pnpm jest:e2e",
"test:fed": "pnpm jest:fed",
"test-and-coverage": "pnpm jest-and-coverage",
"test-and-coverage:e2e": "pnpm build && pnpm build:test && pnpm jest-and-coverage:e2e",
"generate-api-json": "node ./scripts/generate_api_json.js"
"check-migrations": "node scripts/check_migrations_clean.js",
"generate-api-json": "pnpm compile-config && node ./scripts/generate_api_json.js"
},
"optionalDependencies": {
"@swc/core-android-arm64": "1.3.11",
"@swc/core-darwin-arm64": "1.11.18",
"@swc/core-darwin-x64": "1.11.18",
"@swc/core-darwin-arm64": "1.15.7",
"@swc/core-darwin-x64": "1.15.7",
"@swc/core-freebsd-x64": "1.3.11",
"@swc/core-linux-arm-gnueabihf": "1.11.18",
"@swc/core-linux-arm64-gnu": "1.11.18",
"@swc/core-linux-arm64-musl": "1.11.18",
"@swc/core-linux-x64-gnu": "1.11.18",
"@swc/core-linux-x64-musl": "1.11.18",
"@swc/core-win32-arm64-msvc": "1.11.18",
"@swc/core-win32-ia32-msvc": "1.11.18",
"@swc/core-win32-x64-msvc": "1.11.18",
"@swc/core-linux-arm-gnueabihf": "1.15.7",
"@swc/core-linux-arm64-gnu": "1.15.7",
"@swc/core-linux-arm64-musl": "1.15.7",
"@swc/core-linux-x64-gnu": "1.15.7",
"@swc/core-linux-x64-musl": "1.15.7",
"@swc/core-win32-arm64-msvc": "1.15.7",
"@swc/core-win32-ia32-msvc": "1.15.7",
"@swc/core-win32-x64-msvc": "1.15.7",
"@tensorflow/tfjs": "4.22.0",
"@tensorflow/tfjs-node": "4.22.0",
"bufferutil": "4.0.9",
"bufferutil": "4.1.0",
"slacc-android-arm-eabi": "0.0.10",
"slacc-android-arm64": "0.0.10",
"slacc-darwin-arm64": "0.0.10",
@@ -64,181 +68,167 @@
"slacc-linux-x64-musl": "0.0.10",
"slacc-win32-arm64-msvc": "0.0.10",
"slacc-win32-x64-msvc": "0.0.10",
"utf-8-validate": "6.0.5"
"utf-8-validate": "6.0.6"
},
"dependencies": {
"@aws-sdk/client-s3": "3.782.0",
"@aws-sdk/lib-storage": "3.782.0",
"@discordapp/twemoji": "15.1.0",
"@fastify/accepts": "5.0.2",
"@fastify/cookie": "11.0.2",
"@fastify/cors": "10.1.0",
"@aws-sdk/client-s3": "3.958.0",
"@aws-sdk/lib-storage": "3.958.0",
"@discordapp/twemoji": "16.0.1",
"@fastify/accepts": "5.0.4",
"@fastify/cors": "11.2.0",
"@fastify/express": "4.0.2",
"@fastify/http-proxy": "10.0.2",
"@fastify/multipart": "9.0.3",
"@fastify/static": "8.1.1",
"@fastify/view": "10.0.2",
"@misskey-dev/sharp-read-bmp": "1.3.0",
"@misskey-dev/summaly": "5.2.0",
"@napi-rs/canvas": "0.1.69",
"@nestjs/common": "11.0.16",
"@nestjs/core": "11.0.15",
"@nestjs/testing": "11.0.15",
"@fastify/http-proxy": "11.4.1",
"@fastify/multipart": "9.3.0",
"@fastify/static": "8.3.0",
"@kitajs/html": "4.2.11",
"@misskey-dev/sharp-read-bmp": "1.2.0",
"@misskey-dev/summaly": "5.2.5",
"@napi-rs/canvas": "0.1.87",
"@nestjs/common": "11.1.10",
"@nestjs/core": "11.1.10",
"@nestjs/testing": "11.1.10",
"@peertube/http-signature": "1.7.0",
"@sentry/node": "8.55.0",
"@sentry/profiling-node": "8.55.0",
"@simplewebauthn/server": "12.0.0",
"@sinonjs/fake-timers": "11.3.1",
"@smithy/node-http-handler": "2.5.0",
"@swc/cli": "0.6.0",
"@swc/core": "1.11.18",
"@twemoji/parser": "15.1.1",
"@types/redis-info": "3.0.3",
"@sentry/node": "10.32.1",
"@sentry/profiling-node": "10.32.1",
"@simplewebauthn/server": "13.2.2",
"@sinonjs/fake-timers": "15.1.0",
"@smithy/node-http-handler": "4.4.7",
"@swc/cli": "0.7.9",
"@swc/core": "1.15.7",
"@twemoji/parser": "16.0.0",
"accepts": "1.3.8",
"ajv": "8.17.1",
"archiver": "7.0.1",
"async-mutex": "0.5.0",
"bcryptjs": "2.4.3",
"bcryptjs": "3.0.3",
"blurhash": "2.0.5",
"body-parser": "1.20.3",
"bullmq": "5.48.1",
"body-parser": "2.2.1",
"bullmq": "5.66.3",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.2",
"chalk": "5.4.1",
"chalk-template": "1.1.0",
"chokidar": "3.6.0",
"cli-highlight": "2.1.11",
"color-convert": "2.0.1",
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"chalk": "5.6.2",
"chalk-template": "1.1.2",
"chokidar": "5.0.0",
"color-convert": "3.1.3",
"content-disposition": "1.0.1",
"date-fns": "4.1.0",
"deep-email-validator": "0.1.21",
"fastify": "5.2.2",
"fastify": "5.6.2",
"fastify-raw-body": "5.0.0",
"feed": "4.2.2",
"file-type": "19.6.0",
"feed": "5.1.0",
"file-type": "21.2.0",
"fluent-ffmpeg": "2.1.3",
"form-data": "4.0.2",
"got": "14.4.7",
"happy-dom": "16.8.1",
"form-data": "4.0.5",
"got": "14.6.5",
"hpagent": "1.2.0",
"htmlescape": "1.1.1",
"http-link-header": "1.1.3",
"ioredis": "5.6.0",
"i18n": "workspace:*",
"ioredis": "5.8.2",
"ip-cidr": "4.0.2",
"ipaddr.js": "2.2.0",
"is-svg": "5.1.0",
"js-yaml": "4.1.0",
"jsdom": "26.0.0",
"ipaddr.js": "2.3.0",
"is-svg": "6.1.0",
"json5": "2.2.3",
"jsonld": "8.3.3",
"jsrsasign": "11.1.0",
"juice": "11.0.1",
"meilisearch": "0.49.0",
"mfm-js": "0.24.0",
"microformats-parser": "2.0.2",
"mime-types": "2.1.35",
"jsonld": "9.0.0",
"juice": "11.0.3",
"meilisearch": "0.54.0",
"mfm-js": "0.25.0",
"mime-types": "3.0.2",
"misskey-js": "workspace:*",
"misskey-reversi": "workspace:*",
"misskey-mahjong": "workspace:*",
"ms": "3.0.0-canary.1",
"nanoid": "5.1.5",
"ms": "3.0.0-canary.202508261828",
"nanoid": "5.1.6",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.10.0",
"node-html-parser": "7.0.1",
"nodemailer": "7.0.12",
"nsfwjs": "4.2.0",
"oauth": "0.10.2",
"oauth2orize": "1.12.0",
"oauth2orize-pkce": "0.1.2",
"os-utils": "0.0.14",
"otpauth": "9.4.0",
"parse5": "7.2.1",
"pg": "8.14.1",
"pkce-challenge": "4.1.0",
"otpauth": "9.4.1",
"pg": "8.16.3",
"pkce-challenge": "5.0.1",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
"pug": "3.0.3",
"qrcode": "1.5.4",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.21.4",
"redis-info": "3.1.0",
"redis-lock": "0.1.4",
"re2": "1.23.0",
"reflect-metadata": "0.2.2",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.2",
"sanitize-html": "2.15.0",
"secure-json-parse": "3.0.2",
"sharp": "0.34.1",
"sanitize-html": "2.17.0",
"secure-json-parse": "4.1.0",
"semver": "7.7.3",
"sharp": "0.33.5",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"systeminformation": "5.25.11",
"systeminformation": "5.28.1",
"tinycolor2": "1.6.0",
"tmp": "0.2.3",
"tsc-alias": "1.8.15",
"tsconfig-paths": "4.2.0",
"typeorm": "0.3.22",
"typescript": "5.8.3",
"ulid": "2.4.0",
"tmp": "0.2.5",
"tsc-alias": "1.8.16",
"typeorm": "0.3.28",
"ulid": "3.0.2",
"vary": "1.1.2",
"web-push": "3.6.7",
"ws": "8.18.1",
"ws": "8.18.3",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.7.0",
"@nestjs/platform-express": "10.4.15",
"@sentry/vue": "9.12.0",
"@kitajs/ts-html-plugin": "4.1.3",
"@nestjs/platform-express": "11.1.10",
"@sentry/vue": "10.32.1",
"@simplewebauthn/types": "12.0.0",
"@swc/jest": "0.2.37",
"@swc/jest": "0.2.39",
"@types/accepts": "1.3.7",
"@types/archiver": "6.0.3",
"@types/bcryptjs": "2.4.6",
"@types/body-parser": "1.19.5",
"@types/archiver": "7.0.0",
"@types/body-parser": "1.19.6",
"@types/color-convert": "2.0.4",
"@types/content-disposition": "0.5.8",
"@types/fluent-ffmpeg": "2.1.27",
"@types/htmlescape": "1.1.3",
"@types/content-disposition": "0.5.9",
"@types/fluent-ffmpeg": "2.1.28",
"@types/http-link-header": "1.0.7",
"@types/jest": "29.5.14",
"@types/js-yaml": "4.0.9",
"@types/jsdom": "21.1.7",
"@types/jsonld": "1.5.15",
"@types/jsrsasign": "10.5.15",
"@types/mime-types": "2.1.4",
"@types/ms": "0.7.34",
"@types/node": "22.14.0",
"@types/nodemailer": "6.4.17",
"@types/oauth": "0.9.6",
"@types/mime-types": "3.0.1",
"@types/ms": "2.1.0",
"@types/node": "24.10.4",
"@types/nodemailer": "7.0.4",
"@types/oauth2orize": "1.11.5",
"@types/oauth2orize-pkce": "0.1.2",
"@types/pg": "8.11.11",
"@types/pug": "2.0.10",
"@types/qrcode": "1.5.5",
"@types/pg": "8.16.0",
"@types/qrcode": "1.5.6",
"@types/random-seed": "0.3.5",
"@types/ratelimiter": "3.4.6",
"@types/rename": "1.0.7",
"@types/sanitize-html": "2.15.0",
"@types/semver": "7.7.0",
"@types/simple-oauth2": "5.0.7",
"@types/sinonjs__fake-timers": "8.1.5",
"@types/sanitize-html": "2.16.0",
"@types/semver": "7.7.1",
"@types/simple-oauth2": "5.0.8",
"@types/sinonjs__fake-timers": "15.0.1",
"@types/supertest": "6.0.3",
"@types/tinycolor2": "1.4.6",
"@types/tmp": "0.2.6",
"@types/vary": "1.1.3",
"@types/web-push": "3.6.4",
"@types/ws": "8.18.1",
"@typescript-eslint/eslint-plugin": "8.29.1",
"@typescript-eslint/parser": "8.29.1",
"@typescript-eslint/eslint-plugin": "8.50.1",
"@typescript-eslint/parser": "8.50.1",
"aws-sdk-client-mock": "4.1.0",
"cross-env": "7.0.3",
"eslint-plugin-import": "2.31.0",
"execa": "8.0.1",
"fkill": "9.0.0",
"cbor": "10.0.11",
"cross-env": "10.1.0",
"esbuild-plugin-swc": "1.0.1",
"eslint-plugin-import": "2.32.0",
"execa": "9.6.1",
"fkill": "10.0.1",
"jest": "29.7.0",
"jest-mock": "29.7.0",
"nodemon": "3.1.9",
"pid-port": "1.0.2",
"simple-oauth2": "5.1.0"
"js-yaml": "4.1.1",
"nodemon": "3.1.11",
"pid-port": "2.0.0",
"simple-oauth2": "5.1.0",
"supertest": "7.1.4",
"vite": "7.3.0"
}
}

View File

@@ -4,8 +4,8 @@
*/
import Redis from 'ioredis';
import { loadConfig } from '../built/config.js';
import { createPostgresDataSource } from '../built/postgres.js';
import { loadConfig } from '../src-js/config.js';
import { createPostgresDataSource } from '../src-js/postgres.js';
const config = loadConfig();
@@ -16,26 +16,22 @@ async function connectToPostgres() {
}
async function connectToRedis(redisOptions) {
return await new Promise(async (resolve, reject) => {
const redis = new Redis({
let redis;
try {
redis = new Redis({
...redisOptions,
lazyConnect: true,
reconnectOnError: false,
showFriendlyErrorStack: true,
});
redis.on('error', e => reject(e));
try {
await redis.connect();
resolve();
} catch (e) {
reject(e);
} finally {
redis.disconnect(false);
}
});
await Promise.race([
new Promise((_, reject) => redis.on('error', e => reject(e))),
redis.connect(),
]);
} finally {
redis.disconnect(false);
}
}
// If not all of these are defined, the default one gets reused.
@@ -50,7 +46,7 @@ const promises = Array
]))
.map(connectToRedis)
.concat([
connectToPostgres()
connectToPostgres(),
]);
await Promise.all(promises);

View File

@@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
// This script checks if the database migrations has been generated correctly.
import dataSource from '../ormconfig.js';
await dataSource.initialize();
const sqlInMemory = await dataSource.driver.createSchemaBuilder().log();
if (sqlInMemory.upQueries.length > 0 || sqlInMemory.downQueries.length > 0) {
console.error('There are several pending migrations. Please make sure you have generated the migrations correctly, or configured entities class correctly.');
for (const query of sqlInMemory.upQueries) {
console.error(`- ${query.query}`);
}
for (const query of sqlInMemory.downQueries) {
console.error(`- ${query.query}`);
}
process.exit(1);
} else {
console.log('All migrations are clean.');
process.exit(0);
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* YAMLファイルをJSONファイルに変換するスクリプト
* ビルド前に実行し、ランタイムにjs-yamlを含まないようにする
*/
import fs from 'node:fs';
import { resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import yaml from 'js-yaml';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const configDir = resolve(_dirname, '../../../.config');
const OUTPUT_PATH = resolve(_dirname, '../../../built/.config.json');
// TODO: yamlのパースに失敗したときのエラーハンドリング
/**
* YAMLファイルをJSONファイルに変換
* @param {string} ymlPath - YAMLファイルのパス
*/
function yamlToJson(ymlPath) {
if (!fs.existsSync(ymlPath)) {
console.warn(`YAML file not found: ${ymlPath}`);
return;
}
console.log(`${ymlPath}${OUTPUT_PATH}`);
const yamlContent = fs.readFileSync(ymlPath, 'utf-8');
const jsonContent = yaml.load(yamlContent);
if (!fs.existsSync(dirname(OUTPUT_PATH))) {
fs.mkdirSync(dirname(OUTPUT_PATH), { recursive: true });
}
fs.writeFileSync(OUTPUT_PATH, JSON.stringify({
'_NOTE_': 'This file is auto-generated from YAML file. DO NOT EDIT.',
...jsonContent,
}), 'utf-8');
}
if (process.env.MISSKEY_CONFIG_YML) {
const customYmlPath = resolve(configDir, process.env.MISSKEY_CONFIG_YML);
yamlToJson(customYmlPath);
} else {
yamlToJson(resolve(configDir, process.env.NODE_ENV === 'test' ? 'test.yml' : 'default.yml'));
}
console.log('Configuration compiled ✓');

View File

@@ -42,7 +42,7 @@ async function killProc() {
'./node_modules/nodemon/bin/nodemon.js',
[
'-w', 'src',
'-e', 'ts,js,mjs,cjs,json',
'-e', 'ts,js,mjs,cjs,tsx,json,pug',
'--exec', 'pnpm', 'run', 'build',
],
{

View File

@@ -3,8 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { writeFileSync, existsSync } from 'node:fs';
import { execa } from 'execa';
import { writeFileSync, existsSync } from "node:fs";
async function main() {
if (!process.argv.includes('--no-build')) {
@@ -19,10 +19,10 @@ async function main() {
}
/** @type {import('../src/config.js')} */
const { loadConfig } = await import('../built/config.js');
const { loadConfig } = await import('../src-js/config.js');
/** @type {import('../src/server/api/openapi/gen-spec.js')} */
const { genOpenapiSpec } = await import('../built/server/api/openapi/gen-spec.js');
const { genOpenapiSpec } = await import('../src-js/server/api/openapi/gen-spec.js');
const config = loadConfig();
const spec = genOpenapiSpec(config, true);

View File

@@ -0,0 +1,154 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* This script starts the Misskey backend server, waits for it to be ready,
* measures memory usage, and outputs the result as JSON.
*
* Usage: node scripts/measure-memory.mjs
*/
import { fork } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const STARTUP_TIMEOUT = 120000; // 120 seconds timeout for server startup
const MEMORY_SETTLE_TIME = 10000; // Wait 10 seconds after startup for memory to settle
async function measureMemory() {
const startTime = Date.now();
// Start the Misskey backend server using fork to enable IPC
const serverProcess = fork(join(__dirname, '../built/boot/entry.js'), ['expose-gc'], {
cwd: join(__dirname, '..'),
env: {
...process.env,
NODE_ENV: 'production',
MK_DISABLE_CLUSTERING: '1',
MK_FORCE_GC: '1',
},
stdio: ['pipe', 'pipe', 'pipe', 'ipc'],
});
let serverReady = false;
// Listen for the 'ok' message from the server indicating it's ready
serverProcess.on('message', (message) => {
if (message === 'ok') {
serverReady = true;
}
});
// Handle server output
serverProcess.stdout?.on('data', (data) => {
process.stderr.write(`[server stdout] ${data}`);
});
serverProcess.stderr?.on('data', (data) => {
process.stderr.write(`[server stderr] ${data}`);
});
// Handle server error
serverProcess.on('error', (err) => {
process.stderr.write(`[server error] ${err}\n`);
});
// Wait for server to be ready or timeout
const startupStartTime = Date.now();
while (!serverReady) {
if (Date.now() - startupStartTime > STARTUP_TIMEOUT) {
serverProcess.kill('SIGTERM');
throw new Error('Server startup timeout');
}
await setTimeout(100);
}
const startupTime = Date.now() - startupStartTime;
process.stderr.write(`Server started in ${startupTime}ms\n`);
// Wait for memory to settle
await setTimeout(MEMORY_SETTLE_TIME);
// Get memory usage from the server process via /proc
const pid = serverProcess.pid;
let memoryInfo;
try {
const fs = await import('node:fs/promises');
// Read /proc/[pid]/status for detailed memory info
const status = await fs.readFile(`/proc/${pid}/status`, 'utf-8');
const vmRssMatch = status.match(/VmRSS:\s+(\d+)\s+kB/);
const vmDataMatch = status.match(/VmData:\s+(\d+)\s+kB/);
const vmSizeMatch = status.match(/VmSize:\s+(\d+)\s+kB/);
memoryInfo = {
rss: vmRssMatch ? parseInt(vmRssMatch[1], 10) * 1024 : null,
heapUsed: vmDataMatch ? parseInt(vmDataMatch[1], 10) * 1024 : null,
vmSize: vmSizeMatch ? parseInt(vmSizeMatch[1], 10) * 1024 : null,
};
} catch (err) {
// Fallback: use ps command
process.stderr.write(`Warning: Could not read /proc/${pid}/status: ${err}\n`);
const { execSync } = await import('node:child_process');
try {
const ps = execSync(`ps -o rss= -p ${pid}`, { encoding: 'utf-8' });
const rssKb = parseInt(ps.trim(), 10);
memoryInfo = {
rss: rssKb * 1024,
heapUsed: null,
vmSize: null,
};
} catch {
memoryInfo = {
rss: null,
heapUsed: null,
vmSize: null,
error: 'Could not measure memory',
};
}
}
// Stop the server
serverProcess.kill('SIGTERM');
// Wait for process to exit
let exited = false;
await new Promise((resolve) => {
serverProcess.on('exit', () => {
exited = true;
resolve(undefined);
});
// Force kill after 10 seconds if not exited
setTimeout(10000).then(() => {
if (!exited) {
serverProcess.kill('SIGKILL');
}
resolve(undefined);
});
});
const result = {
timestamp: new Date().toISOString(),
startupTimeMs: startupTime,
memory: memoryInfo,
};
// Output as JSON to stdout
console.log(JSON.stringify(result, null, 2));
}
measureMemory().catch((err) => {
console.error(JSON.stringify({
error: err.message,
timestamp: new Date().toISOString(),
}));
process.exit(1);
});

View File

@@ -21,7 +21,7 @@ import { execa } from 'execa';
});
}, 3000);
execa('tsc', ['-w', '-p', 'tsconfig.json'], {
execa('tsgo', ['-w', '-p', 'tsconfig.json'], {
stdout: process.stdout,
stderr: process.stderr,
});

View File

@@ -1,13 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
declare module 'redis-lock' {
import type Redis from 'ioredis';
type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise<void>) => void;
function redisLock(client: Redis.Redis, retryDelay: number): Lock;
export = redisLock;
}

View File

@@ -24,8 +24,13 @@ const $config: Provider = {
const $db: Provider = {
provide: DI.db,
useFactory: async (config) => {
const db = createPostgresDataSource(config);
return await db.initialize();
try {
const db = createPostgresDataSource(config);
return await db.initialize();
} catch (e) {
console.log(e);
throw e;
}
},
inject: [DI.config],
};

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import 'reflect-metadata';
import { EventEmitter } from 'node:events';
import { NestFactory } from '@nestjs/core';
import { CommandModule } from '@/cli/CommandModule.js';
import { NestLogger } from '@/NestLogger.js';
import { CommandService } from '@/cli/CommandService.js';
process.title = 'Misskey Cli';
Error.stackTraceLimit = Infinity;
EventEmitter.defaultMaxListeners = 128;
const app = await NestFactory.createApplicationContext(CommandModule, {
logger: new NestLogger(),
});
const commandService = app.get(CommandService);
const command = process.argv[2] ?? 'help';
switch (command) {
case 'help': {
console.log('Available commands:');
console.log(' help - Displays this help message');
console.log(' reset-captcha - Resets the captcha');
break;
}
case 'ping': {
await commandService.ping();
break;
}
case 'reset-captcha': {
await commandService.resetCaptcha();
console.log('Captcha has been reset.');
break;
}
default: {
console.error(`Unrecognized command: ${command}`);
console.error('Use "help" to see available commands.');
process.exit(1);
}
}
process.exit(0);

View File

@@ -86,6 +86,10 @@ if (!envOption.disableClustering) {
ev.mount();
}
if (envOption.forceGc && global.gc != null) {
global.gc();
}
readyRef.value = true;
// ユニットテスト時にMisskeyが子プロセスで起動された時のため

View File

@@ -4,14 +4,10 @@
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import * as os from 'node:os';
import cluster from 'node:cluster';
import chalk from 'chalk';
import chalkTemplate from 'chalk-template';
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import Logger from '@/logger.js';
import { loadConfig } from '@/config.js';
import type { Config } from '@/config.js';
@@ -19,20 +15,15 @@ import { showMachineInfo } from '@/misc/show-machine-info.js';
import { envOption } from '@/env.js';
import { jobQueue, server } from './common.js';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
const logger = new Logger('core', 'cyan');
const bootLogger = logger.createSubLogger('boot', 'magenta');
const themeColor = chalk.hex('#86b300');
function greet() {
function greet(props: { version: string }) {
if (!envOption.quiet) {
//#region Misskey logo
const v = `v${meta.version}`;
const v = `v${props.version}`;
console.log(themeColor(' _____ _ _ '));
console.log(themeColor(' | |_|___ ___| |_ ___ _ _ '));
console.log(themeColor(' | | | | |_ -|_ -| \'_| -_| | |'));
@@ -41,14 +32,14 @@ function greet() {
//#endregion
console.log(' Misskey is an open-source decentralized microblogging platform.');
console.log(chalk.rgb(255, 136, 0)(' If you like Misskey, please donate to support development. https://www.patreon.com/syuilo'));
console.log(chalk.rgb(255, 136, 0)(' If you like Misskey, please consider donating to support dev. https://misskey-hub.net/docs/donate/'));
console.log('');
console.log(chalkTemplate`--- ${os.hostname()} {gray (PID: ${process.pid.toString()})} ---`);
}
bootLogger.info('Welcome to Misskey!');
bootLogger.info(`Misskey v${meta.version}`, null, true);
bootLogger.info(`Misskey v${props.version}`, null, true);
}
/**
@@ -59,21 +50,24 @@ export async function masterMain() {
// initialize app
try {
greet();
config = loadConfigBoot();
greet({ version: config.version });
showEnvironment();
await showMachineInfo(bootLogger);
showNodejsVersion();
config = loadConfigBoot();
//await connectDb();
if (config.pidFile) fs.writeFileSync(config.pidFile, process.pid.toString());
} catch (e) {
bootLogger.error('Fatal error occurred during initialization', null, true);
bootLogger.error('Fatal error occurred during initialization: ' + e, null, true);
process.exit(1);
}
bootLogger.succ('Misskey initialized');
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),

View File

@@ -4,8 +4,6 @@
*/
import cluster from 'node:cluster';
import * as Sentry from '@sentry/node';
import { nodeProfilingIntegration } from '@sentry/profiling-node';
import { envOption } from '@/env.js';
import { loadConfig } from '@/config.js';
import { jobQueue, server } from './common.js';
@@ -17,6 +15,9 @@ export async function workerMain() {
const config = loadConfig();
if (config.sentryForBackend) {
const Sentry = await import('@sentry/node');
const { nodeProfilingIntegration } = await import('@sentry/profiling-node');
Sentry.init({
integrations: [
...(config.sentryForBackend.enableNodeProfiling ? [nodeProfilingIntegration()] : []),

View File

@@ -0,0 +1,23 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CommandService } from './CommandService.js';
@Module({
imports: [
GlobalModule,
CoreModule,
],
providers: [
CommandService,
],
exports: [
CommandService,
],
})
export class CommandModule {}

View File

@@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
@Injectable()
export class CommandService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
private metaService: MetaService,
) {
}
@bindThis
public async ping() {
console.log('pong');
}
@bindThis
public async resetCaptcha() {
await this.metaService.update({
enableHcaptcha: false,
hcaptchaSiteKey: null,
hcaptchaSecretKey: null,
enableMcaptcha: false,
mcaptchaSitekey: null,
mcaptchaSecretKey: null,
mcaptchaInstanceUrl: null,
enableRecaptcha: false,
recaptchaSiteKey: null,
recaptchaSecretKey: null,
enableTurnstile: false,
turnstileSiteKey: null,
turnstileSecretKey: null,
enableTestcaptcha: false,
});
}
}

View File

@@ -6,10 +6,11 @@
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
import * as yaml from 'js-yaml';
import { type FastifyServerOptions } from 'fastify';
import type * as Sentry from '@sentry/node';
import type * as SentryVue from '@sentry/vue';
import type { RedisOptions } from 'ioredis';
import type { ManifestChunk } from 'vite';
type RedisOptionsSource = Partial<RedisOptions> & {
host: string;
@@ -27,7 +28,9 @@ type Source = {
url?: string;
port?: number;
socket?: string;
trustProxy?: FastifyServerOptions['trustProxy'];
chmodSocket?: string;
enableIpRateLimit?: boolean;
disableHsts?: boolean;
db: {
host: string;
@@ -79,7 +82,6 @@ type Source = {
proxyBypassHosts?: string[];
allowedPrivateNetworks?: string[];
disallowExternalApRedirect?: boolean;
maxFileSize?: number;
@@ -100,11 +102,8 @@ type Source = {
inboxJobMaxAttempts?: number;
mediaProxy?: string;
proxyRemoteFiles?: boolean;
videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean;
perChannelMaxNoteCacheCount?: number;
perUserNotificationsMaxCount?: number;
deactivateAntennaThreshold?: number;
@@ -122,7 +121,9 @@ export type Config = {
url: string;
port: number;
socket: string | undefined;
trustProxy: NonNullable<FastifyServerOptions['trustProxy']>;
chmodSocket: string | undefined;
enableIpRateLimit: boolean;
disableHsts: boolean | undefined;
db: {
host: string;
@@ -156,7 +157,6 @@ export type Config = {
proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined;
disallowExternalApRedirect: boolean;
maxFileSize: number;
clusterLimit: number | undefined;
id: string;
@@ -170,8 +170,6 @@ export type Config = {
relationshipJobPerSec: number | undefined;
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined;
signToActivityPubGet: boolean | undefined;
logging?: {
sql?: {
disableQueryTruncation?: boolean,
@@ -191,9 +189,9 @@ export type Config = {
authUrl: string;
driveUrl: string;
userAgent: string;
frontendEntry: string;
frontendEntry: ManifestChunk;
frontendManifestExists: boolean;
frontendEmbedEntry: string;
frontendEmbedEntry: ManifestChunk;
frontendEmbedManifestExists: boolean;
mediaProxy: string;
externalMediaProxyEnabled: boolean;
@@ -221,33 +219,45 @@ export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
/**
* Path of configuration directory
*/
const dir = `${_dirname}/../../../.config`;
/** Path of repository root directory */
let rootDir = _dirname;
// 見つかるまで上に遡る
while (!fs.existsSync(resolve(rootDir, 'packages'))) {
const parentDir = dirname(rootDir);
if (parentDir === rootDir) {
throw new Error('Cannot find root directory');
}
rootDir = parentDir;
}
/**
* Path of configuration file
*/
const path = process.env.MISSKEY_CONFIG_YML
? resolve(dir, process.env.MISSKEY_CONFIG_YML)
: process.env.NODE_ENV === 'test'
? resolve(dir, 'test.yml')
: resolve(dir, 'default.yml');
/** Path of configuration directory */
const configDir = resolve(rootDir, '.config');
/** Path of built directory */
const projectBuiltDir = resolve(rootDir, 'built');
const compiledConfigFilePathForTest = resolve(projectBuiltDir, '._config_.json');
export const compiledConfigFilePath = fs.existsSync(compiledConfigFilePathForTest)
? compiledConfigFilePathForTest
: resolve(projectBuiltDir, '.config.json');
export function loadConfig(): Config {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
if (!fs.existsSync(compiledConfigFilePath)) {
throw new Error('Compiled configuration file not found. Try running \'pnpm compile-config\'.');
}
const frontendManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_vite_/manifest.json');
const frontendEmbedManifestExists = fs.existsSync(_dirname + '/../../../built/_frontend_embed_vite_/manifest.json');
const meta = JSON.parse(fs.readFileSync(resolve(projectBuiltDir, 'meta.json'), 'utf-8'));
const frontendManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'));
const frontendEmbedManifestExists = fs.existsSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'));
const frontendManifest = frontendManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_vite_/manifest.json`, 'utf-8'))
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_vite_/manifest.json'), 'utf-8'))
: { 'src/_boot_.ts': { file: null } };
const frontendEmbedManifest = frontendEmbedManifestExists ?
JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_frontend_embed_vite_/manifest.json`, 'utf-8'))
: { 'src/boot.ts': { file: 'src/boot.ts' } };
JSON.parse(fs.readFileSync(resolve(projectBuiltDir, '_frontend_embed_vite_/manifest.json'), 'utf-8'))
: { 'src/boot.ts': { file: null } };
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
const config = JSON.parse(fs.readFileSync(compiledConfigFilePath, 'utf-8')) as Source;
const url = tryCreateUrl(config.url ?? process.env.MISSKEY_URL ?? '');
const version = meta.version;
@@ -273,8 +283,17 @@ export function loadConfig(): Config {
url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket,
trustProxy: config.trustProxy ?? [
'10.0.0.0/8',
'172.16.0.0/12',
'192.168.0.0/16',
'127.0.0.1/32',
'::1/128',
'fc00::/7',
],
chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts,
enableIpRateLimit: config.enableIpRateLimit ?? true,
host,
hostname,
scheme,
@@ -300,7 +319,6 @@ export function loadConfig(): Config {
proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks,
disallowExternalApRedirect: config.disallowExternalApRedirect ?? false,
maxFileSize: config.maxFileSize ?? 262144000,
clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress,
@@ -313,8 +331,6 @@ export function loadConfig(): Config {
relationshipJobPerSec: config.relationshipJobPerSec,
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles,
signToActivityPubGet: config.signToActivityPubGet ?? true,
mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
videoThumbnailGenerator: config.videoThumbnailGenerator ?
@@ -336,7 +352,7 @@ export function loadConfig(): Config {
function tryCreateUrl(url: string) {
try {
return new URL(url);
} catch (e) {
} catch (_) {
throw new Error(`url="${url}" is not a valid URL.`);
}
}

View File

@@ -75,7 +75,7 @@ export class AccountMoveService {
*/
@bindThis
public async moveFromLocal(src: MiLocalUser, dst: MiLocalUser | MiRemoteUser): Promise<unknown> {
const srcUri = this.userEntityService.getUserUri(src);
const _srcUri = this.userEntityService.getUserUri(src);
const dstUri = this.userEntityService.getUserUri(dst);
// add movedToUri to indicate that the user has moved

View File

@@ -9,87 +9,7 @@ import type { MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import { NotificationService } from '@/core/NotificationService.js';
export const ACHIEVEMENT_TYPES = [
'notes1',
'notes10',
'notes100',
'notes500',
'notes1000',
'notes5000',
'notes10000',
'notes20000',
'notes30000',
'notes40000',
'notes50000',
'notes60000',
'notes70000',
'notes80000',
'notes90000',
'notes100000',
'login3',
'login7',
'login15',
'login30',
'login60',
'login100',
'login200',
'login300',
'login400',
'login500',
'login600',
'login700',
'login800',
'login900',
'login1000',
'passedSinceAccountCreated1',
'passedSinceAccountCreated2',
'passedSinceAccountCreated3',
'loggedInOnBirthday',
'loggedInOnNewYearsDay',
'noteClipped1',
'noteFavorited1',
'myNoteFavorited1',
'profileFilled',
'markedAsCat',
'following1',
'following10',
'following50',
'following100',
'following300',
'followers1',
'followers10',
'followers50',
'followers100',
'followers300',
'followers500',
'followers1000',
'collectAchievements30',
'viewAchievements3min',
'iLoveMisskey',
'foundTreasure',
'client30min',
'client60min',
'noteDeletedWithin1min',
'postedAtLateNight',
'postedAt0min0sec',
'selfQuote',
'htl20npm',
'viewInstanceChart',
'outputHelloWorldOnScratchpad',
'open3windows',
'driveFolderCircularReference',
'reactWithoutRead',
'clickedClickHere',
'justPlainLucky',
'setNameToSyuilo',
'cookieClicked',
'brainDiver',
'smashTestNotificationButton',
'tutorialCompleted',
'bubbleGameExplodingHead',
'bubbleGameDoubleExplodingHead',
] as const;
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
@Injectable()
export class AchievementService {

View File

@@ -7,11 +7,10 @@ import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname } from 'node:path';
import { Injectable } from '@nestjs/common';
import * as nsfw from 'nsfwjs';
import si from 'systeminformation';
import { Mutex } from 'async-mutex';
import fetch from 'node-fetch';
import { bindThis } from '@/decorators.js';
import type { NSFWJS, PredictionType } from 'nsfwjs';
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@@ -21,7 +20,7 @@ let isSupportedCpu: undefined | boolean = undefined;
@Injectable()
export class AiService {
private model: nsfw.NSFWJS;
private model: NSFWJS;
private modelLoadMutex: Mutex = new Mutex();
constructor(
@@ -29,7 +28,7 @@ export class AiService {
}
@bindThis
public async detectSensitive(path: string): Promise<nsfw.PredictionType[] | null> {
public async detectSensitive(source: string | Buffer): Promise<PredictionType[] | null> {
try {
if (isSupportedCpu === undefined) {
isSupportedCpu = await this.computeIsSupportedCpu();
@@ -44,6 +43,7 @@ export class AiService {
tf.env().global.fetch = fetch;
if (this.model == null) {
const nsfw = await import('nsfwjs');
await this.modelLoadMutex.runExclusive(async () => {
if (this.model == null) {
this.model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
@@ -51,7 +51,7 @@ export class AiService {
});
}
const buffer = await fs.promises.readFile(path);
const buffer = source instanceof Buffer ? source : await fs.promises.readFile(source);
const image = await tf.node.decodeImage(buffer, 3) as any;
try {
const predictions = await this.model.classify(image);
@@ -83,6 +83,7 @@ export class AiService {
@bindThis
private async getCpuFlags(): Promise<string[]> {
const si = await import('systeminformation');
const str = await si.cpuFlags();
return str.split(/\s+/);
}

View File

@@ -205,7 +205,7 @@ export class AnnouncementService {
announcementId: announcementId,
userId: user.id,
});
} catch (e) {
} catch (_) {
return;
}

View File

@@ -1,44 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promisify } from 'node:util';
import { Inject, Injectable } from '@nestjs/common';
import redisLock from 'redis-lock';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
/**
* Retry delay (ms) for lock acquisition
*/
const retryDelay = 100;
@Injectable()
export class AppLockService {
private lock: (key: string, timeout?: number, _?: (() => Promise<void>) | undefined) => Promise<() => void>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
) {
this.lock = promisify(redisLock(this.redisClient, retryDelay));
}
/**
* Get AP Object lock
* @param uri AP object ID
* @param timeout Lock timeout (ms), The timeout releases previous lock.
* @returns Unlock function
*/
@bindThis
public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`ap-object:${uri}`, timeout);
}
@bindThis
public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> {
return this.lock(`chart-insert:${lockKey}`, timeout);
}
}

View File

@@ -39,7 +39,7 @@ export class AvatarDecorationService implements OnApplicationShutdown {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
const { type, body: _ } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'avatarDecorationCreated':
case 'avatarDecorationUpdated':

View File

@@ -6,7 +6,7 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { ChannelFollowingsRepository } from '@/models/_.js';
import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js';
import { MiChannel } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
@@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit {
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
private idService: IdService,
@@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit {
onModuleInit() {
}
/**
* フォローしているチャンネルの一覧を取得する.
* @param params
* @param [opts]
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
*/
@bindThis
public async list(
params: {
requestUserId: MiUser['id'],
},
opts?: {
idOnly?: boolean;
joinUser?: boolean;
joinBannerFile?: boolean;
},
): Promise<MiChannel[]> {
if (opts?.idOnly) {
const q = this.channelFollowingsRepository.createQueryBuilder('channel_following')
.select('channel_following.followeeId')
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
return q
.getRawMany<{ channel_following_followeeId: string }>()
.then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel)));
} else {
const q = this.channelsRepository.createQueryBuilder('channel')
.innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id')
.where('channel_following.followerId = :userId', { userId: params.requestUserId });
if (opts?.joinUser) {
q.innerJoinAndSelect('channel.user', 'user');
}
if (opts?.joinBannerFile) {
q.leftJoinAndSelect('channel.banner', 'drive_file');
}
return q.getMany();
}
}
@bindThis
public async follow(
requestUser: MiLocalUser,

View File

@@ -0,0 +1,224 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Brackets, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import { RedisKVCache } from '@/misc/cache.js';
@Injectable()
export class ChannelMutingService {
public mutingChannelsCache: RedisKVCache<Set<string>>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelMutingRepository)
private channelMutingRepository: ChannelMutingRepository,
private idService: IdService,
private globalEventService: GlobalEventService,
) {
this.mutingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'channelMutingChannels', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (userId) => this.channelMutingRepository.find({
where: { userId: userId },
select: ['channelId'],
}).then(xs => new Set(xs.map(x => x.channelId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.redisForSub.on('message', this.onMessage);
}
/**
* ミュートしているチャンネルの一覧を取得する.
* @param params
* @param [opts]
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
*/
@bindThis
public async list(
params: {
requestUserId: MiUser['id'],
},
opts?: {
idOnly?: boolean;
joinUser?: boolean;
joinBannerFile?: boolean;
},
): Promise<MiChannel[]> {
if (opts?.idOnly) {
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
.select('channel_muting.channelId')
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
.andWhere(new Brackets(qb => {
qb.where('channel_muting.expiresAt IS NULL')
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
}));
return q
.getRawMany<{ channel_muting_channelId: string }>()
.then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel)));
} else {
const q = this.channelsRepository.createQueryBuilder('channel')
.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id')
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
.andWhere(new Brackets(qb => {
qb.where('channel_muting.expiresAt IS NULL')
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
}));
if (opts?.joinUser) {
q.innerJoinAndSelect('channel.user', 'user');
}
if (opts?.joinBannerFile) {
q.leftJoinAndSelect('channel.banner', 'drive_file');
}
return q.getMany();
}
}
/**
* 期限切れのチャンネルミュート情報を取得する.
*
* @param [opts]
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない).
*/
public async findExpiredMutings(opts?: {
joinUser?: boolean;
joinChannel?: boolean;
}): Promise<MiChannelMuting[]> {
const now = new Date();
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
.where('channel_muting.expiresAt < :now', { now });
if (opts?.joinUser) {
q.innerJoinAndSelect('channel_muting.user', 'user');
}
if (opts?.joinChannel) {
q.leftJoinAndSelect('channel_muting.channel', 'channel');
}
return q.getMany();
}
/**
* 既にミュートされているかどうかをキャッシュから取得する.
* @param params
* @param params.requestUserId
*/
@bindThis
public async isMuted(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
}): Promise<boolean> {
const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId);
return (mutedChannels?.has(params.targetChannelId) ?? false);
}
/**
* チャンネルをミュートする.
* @param params
* @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限.
*/
@bindThis
public async mute(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
expiresAt?: Date | null,
}): Promise<void> {
await this.channelMutingRepository.insert({
id: this.idService.gen(),
userId: params.requestUserId,
channelId: params.targetChannelId,
expiresAt: params.expiresAt,
});
this.globalEventService.publishInternalEvent('muteChannel', {
userId: params.requestUserId,
channelId: params.targetChannelId,
});
}
/**
* チャンネルのミュートを解除する.
* @param params
*/
@bindThis
public async unmute(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
}): Promise<void> {
await this.channelMutingRepository.delete({
userId: params.requestUserId,
channelId: params.targetChannelId,
});
this.globalEventService.publishInternalEvent('unmuteChannel', {
userId: params.requestUserId,
channelId: params.targetChannelId,
});
}
/**
* 期限切れのチャンネルミュート情報を削除する.
*/
@bindThis
public async eraseExpiredMutings(): Promise<void> {
const expiredMutings = await this.findExpiredMutings();
await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) });
const userIds = [...new Set(expiredMutings.map(x => x.userId))];
for (const userId of userIds) {
this.mutingChannelsCache.refresh(userId).then();
}
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'muteChannel': {
this.mutingChannelsCache.refresh(body.userId).then();
break;
}
case 'unmuteChannel': {
this.mutingChannelsCache.delete(body.userId).then();
break;
}
}
}
}
@bindThis
public dispose(): void {
this.mutingChannelsCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View File

@@ -29,7 +29,7 @@ import { emojiRegex } from '@/misc/emoji-regex.js';
import { NotificationService } from '@/core/NotificationService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
const MAX_ROOM_MEMBERS = 30;
const MAX_ROOM_MEMBERS = 50;
const MAX_REACTIONS_PER_MESSAGE = 100;
const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
@@ -331,6 +331,16 @@ export class ChatService {
await redisPipeline.exec();
}
@bindThis
public async readAllChatMessages(
readerId: MiUser['id'],
): Promise<void> {
const redisPipeline = this.redisClient.pipeline();
// TODO: newUserChatMessageExists とか newRoomChatMessageExists も消したい(けどキーの列挙が必要になって面倒)
redisPipeline.del(`newChatMessagesExists:${readerId}`);
await redisPipeline.exec();
}
@bindThis
public findMessageById(messageId: MiChatMessage['id']) {
return this.chatMessagesRepository.findOneBy({ id: messageId });
@@ -578,6 +588,20 @@ export class ChatService {
@bindThis
public async deleteRoom(room: MiChatRoom, deleter?: MiUser) {
const memberships = (await this.chatRoomMembershipsRepository.findBy({ roomId: room.id })).map(m => ({
userId: m.userId,
})).concat({ // ownerはmembershipレコードを作らないため
userId: room.ownerId,
});
// 未読フラグ削除
const redisPipeline = this.redisClient.pipeline();
for (const membership of memberships) {
redisPipeline.del(`newRoomChatMessageExists:${membership.userId}:${room.id}`);
redisPipeline.srem(`newChatMessagesExists:${membership.userId}`, `room:${room.id}`);
}
await redisPipeline.exec();
await this.chatRoomsRepository.delete(room.id);
if (deleter) {
@@ -709,6 +733,12 @@ export class ChatService {
public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
await this.chatRoomMembershipsRepository.delete(membership.id);
// 未読フラグを消す (「既読にする」というわけでもないのでreadメソッドは使わないでおく)
const redisPipeline = this.redisClient.pipeline();
redisPipeline.del(`newRoomChatMessageExists:${userId}:${roomId}`);
redisPipeline.srem(`newChatMessagesExists:${userId}`, `room:${roomId}`);
await redisPipeline.exec();
}
@bindThis

View File

@@ -15,12 +15,12 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js';
import { UserSearchService } from '@/core/UserSearchService.js';
import { WebhookTestService } from '@/core/WebhookTestService.js';
import { FlashService } from '@/core/FlashService.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { AccountMoveService } from './AccountMoveService.js';
import { AccountUpdateService } from './AccountUpdateService.js';
import { AiService } from './AiService.js';
import { AnnouncementService } from './AnnouncementService.js';
import { AntennaService } from './AntennaService.js';
import { AppLockService } from './AppLockService.js';
import { AchievementService } from './AchievementService.js';
import { AvatarDecorationService } from './AvatarDecorationService.js';
import { CaptchaService } from './CaptchaService.js';
@@ -44,6 +44,7 @@ import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
import { NoteDraftService } from './NoteDraftService.js';
import { NotificationService } from './NotificationService.js';
import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js';
@@ -78,6 +79,7 @@ import { ChatService } from './ChatService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
import { MahjongService } from './MahjongService.js';
import { PageService } from './PageService.js';
import { ChartLoggerService } from './chart/ChartLoggerService.js';
import FederationChart from './chart/charts/federation.js';
@@ -119,6 +121,7 @@ import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.
import { NoteEntityService } from './entities/NoteEntityService.js';
import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js';
import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js';
import { NoteDraftEntityService } from './entities/NoteDraftEntityService.js';
import { NotificationEntityService } from './entities/NotificationEntityService.js';
import { PageEntityService } from './entities/PageEntityService.js';
import { PageLikeEntityService } from './entities/PageLikeEntityService.js';
@@ -139,7 +142,7 @@ import { ApLoggerService } from './activitypub/ApLoggerService.js';
import { ApMfmService } from './activitypub/ApMfmService.js';
import { ApRendererService } from './activitypub/ApRendererService.js';
import { ApRequestService } from './activitypub/ApRequestService.js';
import { ApResolverService } from './activitypub/ApResolverService.js';
import { ApResolverService, Resolver } from './activitypub/ApResolverService.js';
import { JsonLdService } from './activitypub/JsonLdService.js';
import { RemoteLoggerService } from './RemoteLoggerService.js';
import { RemoteUserResolveService } from './RemoteUserResolveService.js';
@@ -163,7 +166,6 @@ const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useEx
const $AiService: Provider = { provide: 'AiService', useExisting: AiService };
const $AnnouncementService: Provider = { provide: 'AnnouncementService', useExisting: AnnouncementService };
const $AntennaService: Provider = { provide: 'AntennaService', useExisting: AntennaService };
const $AppLockService: Provider = { provide: 'AppLockService', useExisting: AppLockService };
const $AchievementService: Provider = { provide: 'AchievementService', useExisting: AchievementService };
const $AvatarDecorationService: Provider = { provide: 'AvatarDecorationService', useExisting: AvatarDecorationService };
const $CaptchaService: Provider = { provide: 'CaptchaService', useExisting: CaptchaService };
@@ -186,6 +188,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteDraftService: Provider = { provide: 'NoteDraftService', useExisting: NoteDraftService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
@@ -222,10 +225,12 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService };
const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
const $MahjongService: Provider = { provide: 'MahjongService', useExisting: MahjongService };
const $PageService: Provider = { provide: 'PageService', useExisting: PageService };
const $ChartLoggerService: Provider = { provide: 'ChartLoggerService', useExisting: ChartLoggerService };
const $FederationChart: Provider = { provide: 'FederationChart', useExisting: FederationChart };
@@ -268,6 +273,7 @@ const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityServi
const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService };
const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService };
const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService };
const $NoteDraftEntityService: Provider = { provide: 'NoteDraftEntityService', useExisting: NoteDraftEntityService };
const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useExisting: NotificationEntityService };
const $PageEntityService: Provider = { provide: 'PageEntityService', useExisting: PageEntityService };
const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useExisting: PageLikeEntityService };
@@ -314,7 +320,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AiService,
AnnouncementService,
AntennaService,
AppLockService,
AchievementService,
AvatarDecorationService,
CaptchaService,
@@ -337,6 +342,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
NoteDraftService,
NotificationService,
PollService,
SystemAccountService,
@@ -373,10 +379,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
ChannelMutingService,
ChatService,
RegistryApiService,
ReversiService,
MahjongService,
PageService,
ChartLoggerService,
FederationChart,
@@ -419,6 +427,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteEntityService,
NoteFavoriteEntityService,
NoteReactionEntityService,
NoteDraftEntityService,
NotificationEntityService,
PageEntityService,
PageLikeEntityService,
@@ -441,6 +450,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
Resolver,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,
@@ -461,7 +471,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AiService,
$AnnouncementService,
$AntennaService,
$AppLockService,
$AchievementService,
$AvatarDecorationService,
$CaptchaService,
@@ -484,6 +493,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
$NoteDraftService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -520,10 +530,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$ChannelMutingService,
$ChatService,
$RegistryApiService,
$ReversiService,
$MahjongService,
$PageService,
$ChartLoggerService,
$FederationChart,
@@ -566,6 +578,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteEntityService,
$NoteFavoriteEntityService,
$NoteReactionEntityService,
$NoteDraftEntityService,
$NotificationEntityService,
$PageEntityService,
$PageLikeEntityService,
@@ -609,7 +622,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AiService,
AnnouncementService,
AntennaService,
AppLockService,
AchievementService,
AvatarDecorationService,
CaptchaService,
@@ -632,6 +644,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
NoteDraftService,
NotificationService,
PollService,
SystemAccountService,
@@ -668,10 +681,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
ChannelMutingService,
ChatService,
RegistryApiService,
ReversiService,
MahjongService,
PageService,
FederationChart,
NotesChart,
@@ -713,6 +728,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteEntityService,
NoteFavoriteEntityService,
NoteReactionEntityService,
NoteDraftEntityService,
NotificationEntityService,
PageEntityService,
PageLikeEntityService,
@@ -735,6 +751,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
ApRendererService,
ApRequestService,
ApResolverService,
Resolver,
JsonLdService,
RemoteLoggerService,
RemoteUserResolveService,
@@ -755,7 +772,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AiService,
$AnnouncementService,
$AntennaService,
$AppLockService,
$AchievementService,
$AvatarDecorationService,
$CaptchaService,
@@ -778,6 +794,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
$NoteDraftService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -813,10 +830,12 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
$ChannelMutingService,
$ChatService,
$RegistryApiService,
$ReversiService,
$MahjongService,
$PageService,
$FederationChart,
$NotesChart,
@@ -858,6 +877,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteEntityService,
$NoteFavoriteEntityService,
$NoteReactionEntityService,
$NoteDraftEntityService,
$NotificationEntityService,
$PageEntityService,
$PageLikeEntityService,

View File

@@ -8,7 +8,7 @@ import * as fs from 'node:fs';
import { Inject, Injectable } from '@nestjs/common';
import sharp from 'sharp';
import { sharpBmp } from '@misskey-dev/sharp-read-bmp';
import { IsNull } from 'typeorm';
import { In, IsNull } from 'typeorm';
import { DeleteObjectCommandInput, PutObjectCommandInput, NoSuchKey } from '@aws-sdk/client-s3';
import { DI } from '@/di-symbols.js';
import type { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository, MiMeta } from '@/models/_.js';
@@ -469,13 +469,14 @@ export class DriveService {
if (user && this.meta.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
const info = await this.fileInfoService.getFileInfo(path, {
fileName: name,
skipSensitiveDetection: skipNsfwCheck,
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
0.5,
this.meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
this.meta.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
this.meta.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
this.meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
0.5,
sensitiveThresholdForPorn: 0.75,
enableSensitiveMediaDetectionForVideos: this.meta.enableSensitiveMediaDetectionForVideos,
});
@@ -515,22 +516,44 @@ export class DriveService {
this.registerLogger.debug(`ADD DRIVE FILE: user ${user?.id ?? 'not set'}, name ${detectedName}, tmp ${path}`);
//#region Check drive usage
if (user && !isLink) {
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
//#region Check drive usage and mime type
if (user != null && !isLink) {
const isLocalUser = this.userEntityService.isLocalUser(user);
const isModerator = isLocalUser ? await this.roleService.isModerator(user) : false;
if (!isModerator) {
const policies = await this.roleService.getUserPolicies(user.id);
const policies = await this.roleService.getUserPolicies(user.id);
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
this.registerLogger.debug('drive capacity override applied');
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
// If usage limit exceeded
if (driveCapacity < usage + info.size) {
if (isLocalUser) {
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
const allowedMimeTypes = policies.uploadableFileTypes;
const isAllowed = allowedMimeTypes.some((mimeType) => {
if (mimeType === '*' || mimeType === '*/*') return true;
if (mimeType.endsWith('/*')) return info.type.mime.startsWith(mimeType.slice(0, -1));
return info.type.mime === mimeType;
});
if (!isAllowed) {
throw new IdentifiableError('bd71c601-f9b0-4808-9137-a330647ced9b', `Unallowed file type: ${info.type.mime}`);
}
const driveCapacity = 1024 * 1024 * policies.driveCapacityMb;
const maxFileSize = 1024 * 1024 * policies.maxFileSizeMb;
if (maxFileSize < info.size) {
if (isLocalUser) {
throw new IdentifiableError('f9e4e5f3-4df4-40b5-b400-f236945f7073', 'Max file size exceeded.');
}
}
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
this.registerLogger.debug('drive capacity override applied');
this.registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
// If usage limit exceeded
if (driveCapacity < usage + info.size) {
if (isLocalUser) {
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
}
await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size);
}
await this.expireOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as MiRemoteUser, driveCapacity - info.size);
}
}
//#endregion
@@ -713,6 +736,21 @@ export class DriveService {
return fileObj;
}
@bindThis
public async moveFiles(fileIds: MiDriveFile['id'][], folderId: MiDriveFolder['id'] | null, userId: MiUser['id']) {
const folder = folderId ? await this.driveFoldersRepository.findOneByOrFail({
id: folderId,
userId: userId,
}) : null;
await this.driveFilesRepository.update({
id: In(fileIds),
userId: userId,
}, {
folderId: folder ? folder.id : null,
});
}
@bindThis
public async deleteFile(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
if (file.storedInternal) {
@@ -768,14 +806,14 @@ export class DriveService {
await Promise.all(promises);
}
this.deletePostProcess(file, isExpired, deleter);
await this.deletePostProcess(file, isExpired, deleter);
}
@bindThis
private async deletePostProcess(file: MiDriveFile, isExpired = false, deleter?: MiUser) {
// リモートファイル期限切れ削除後は直リンクにする
if (isExpired && file.userHost !== null && file.uri != null) {
this.driveFilesRepository.update(file.id, {
await this.driveFilesRepository.update(file.id, {
isLink: true,
url: file.uri,
thumbnailUrl: null,
@@ -787,7 +825,7 @@ export class DriveService {
webpublicAccessKey: 'webpublic-' + randomUUID(),
});
} else {
this.driveFilesRepository.delete(file.id);
await this.driveFilesRepository.delete(file.id);
}
this.driveChart.update(file, false);

View File

@@ -145,7 +145,10 @@ export class EmailService {
try {
// TODO: htmlサニタイズ
const info = await transporter.sendMail({
from: this.meta.email!,
from: this.meta.name ? {
name: this.meta.name,
address: this.meta.email!,
} : this.meta.email!,
to: to,
subject: subject,
text: text,
@@ -363,7 +366,7 @@ export class EmailService {
valid: true,
reason: null,
};
} catch (error) {
} catch (_) {
return {
valid: false,
reason: 'network',

View File

@@ -8,15 +8,21 @@ import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js';
import type { MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import type { MiMeta } from '@/models/Meta.js';
import { Packed } from '@/misc/json-schema.js';
import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { FanoutTimelineName, FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
import { CacheService } from '@/core/CacheService.js';
import { isReply } from '@/misc/is-reply.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
import { isChannelRelated } from '@/misc/is-channel-related.js';
type NoteFilter = (note: MiNote) => boolean;
type TimelineOptions = {
untilId: string | null,
@@ -26,13 +32,16 @@ type TimelineOptions = {
me?: { id: MiUser['id'] } | undefined | null,
useDbFallback: boolean,
redisTimelines: FanoutTimelineName[],
noteFilter?: (note: MiNote) => boolean,
noteFilter?: NoteFilter,
alwaysIncludeMyNotes?: boolean;
ignoreAuthorFromBlock?: boolean;
ignoreAuthorFromMute?: boolean;
ignoreAuthorFromInstanceBlock?: boolean;
ignoreAuthorChannelFromMute?: boolean;
excludeNoFiles?: boolean;
excludeReplies?: boolean;
excludePureRenotes: boolean;
ignoreAuthorFromUserSuspension?: boolean;
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
};
@@ -42,9 +51,14 @@ export class FanoutTimelineEndpointService {
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.meta)
private meta: MiMeta,
private noteEntityService: NoteEntityService,
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
private utilityService: UtilityService,
private channelMutingService: ChannelMutingService,
) {
}
@@ -71,7 +85,7 @@ export class FanoutTimelineEndpointService {
const shouldFallbackToDb = noteIds.length === 0 || ps.sinceId != null && ps.sinceId < oldestNoteId;
if (!shouldFallbackToDb) {
let filter = ps.noteFilter ?? (_note => true);
let filter = ps.noteFilter ?? (_note => true) as NoteFilter;
if (ps.alwaysIncludeMyNotes && ps.me) {
const me = ps.me;
@@ -101,19 +115,50 @@ export class FanoutTimelineEndpointService {
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
userMutedChannels,
] = await Promise.all([
this.cacheService.userMutingsCache.fetch(ps.me.id),
this.cacheService.renoteMutingsCache.fetch(ps.me.id),
this.cacheService.userBlockedCache.fetch(ps.me.id),
this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)),
this.channelMutingService.mutingChannelsCache.fetch(me.id),
]);
const parentFilter = filter;
filter = (note) => {
if (isUserRelated(note, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (isUserRelated(note.renote, userIdsWhoBlockingMe, ps.ignoreAuthorFromBlock)) return false;
if (isUserRelated(note.renote, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false;
if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false;
if (isInstanceMuted(note, userMutedInstances)) return false;
if (isChannelRelated(note, userMutedChannels, ps.ignoreAuthorChannelFromMute)) return false;
return parentFilter(note);
};
}
{
const parentFilter = filter;
filter = (note) => {
if (!ps.ignoreAuthorFromInstanceBlock) {
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, note.userHost)) return false;
}
if (note.userId !== note.renoteUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.renoteUserHost)) return false;
if (note.userId !== note.replyUserId && this.utilityService.isBlockedHost(this.meta.blockedHosts, note.replyUserHost)) return false;
return parentFilter(note);
};
}
{
const parentFilter = filter;
filter = (note) => {
if (!ps.ignoreAuthorFromUserSuspension) {
if (note.user!.isSuspended) return false;
}
if (note.userId !== note.renoteUserId && note.renote?.user?.isSuspended) return false;
if (note.userId !== note.replyUserId && note.reply?.user?.isSuspended) return false;
return parentFilter(note);
};
@@ -160,7 +205,7 @@ export class FanoutTimelineEndpointService {
return await ps.dbFallback(ps.untilId, ps.sinceId, ps.limit);
}
private async getAndFilterFromDb(noteIds: string[], noteFilter: (note: MiNote) => boolean, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
private async getAndFilterFromDb(noteIds: string[], noteFilter: NoteFilter, idCompare: (a: string, b: string) => number): Promise<MiNote[]> {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')

View File

@@ -5,9 +5,9 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { JSDOM } from 'jsdom';
import tinycolor from 'tinycolor2';
import * as Redis from 'ioredis';
import * as htmlParser from 'node-html-parser';
import type { MiInstance } from '@/models/Instance.js';
import type Logger from '@/logger.js';
import { DI } from '@/di-symbols.js';
@@ -15,7 +15,6 @@ import { LoggerService } from '@/core/LoggerService.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import type { DOMWindow } from 'jsdom';
type NodeInfo = {
openRegistrations?: unknown;
@@ -59,7 +58,7 @@ export class FetchInstanceMetadataService {
return await this.redisClient.set(
`fetchInstanceMetadata:mutex:v2:${host}`, '1',
'EX', 30, // 30秒したら自動でロック解除 https://github.com/misskey-dev/misskey/issues/13506#issuecomment-1975375395
'GET' // 古い値を返すなかったらnull
'GET', // 古い値を返すなかったらnull
);
}
@@ -181,15 +180,14 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async fetchDom(instance: MiInstance): Promise<Document> {
private async fetchDom(instance: MiInstance): Promise<htmlParser.HTMLElement> {
this.logger.info(`Fetching HTML of ${instance.host} ...`);
const url = 'https://' + instance.host;
const html = await this.httpRequestService.getHtml(url);
const { window } = new JSDOM(html);
const doc = window.document;
const doc = htmlParser.parse(html);
return doc;
}
@@ -206,12 +204,12 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async fetchFaviconUrl(instance: MiInstance, doc: Document | null): Promise<string | null> {
private async fetchFaviconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null): Promise<string | null> {
const url = 'https://' + instance.host;
if (doc) {
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.attributes.rel === 'icon')?.attributes.href;
if (href) {
return (new URL(href, url)).href;
@@ -232,7 +230,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async fetchIconUrl(instance: MiInstance, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async fetchIconUrl(instance: MiInstance, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
const url = 'https://' + instance.host;
return (new URL(manifest.icons[0].src, url)).href;
@@ -246,9 +244,9 @@ export class FetchInstanceMetadataService {
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
const href =
[
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
links.find(link => link.relList.contains('icon'))?.href,
links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon-precomposed'))?.attributes.href,
links.find(link => link.attributes.rel?.split(/\s+/).includes('apple-touch-icon'))?.attributes.href,
links.find(link => link.attributes.rel?.split(/\s+/).includes('icon'))?.attributes.href,
]
.find(href => href);
@@ -261,7 +259,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async getThemeColor(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async getThemeColor(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
if (themeColor) {
@@ -273,7 +271,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async getSiteName(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async getSiteName(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeName === 'string') {
return info.metadata.nodeName;
@@ -298,7 +296,7 @@ export class FetchInstanceMetadataService {
}
@bindThis
private async getDescription(info: NodeInfo | null, doc: Document | null, manifest: Record<string, any> | null): Promise<string | null> {
private async getDescription(info: NodeInfo | null, doc: htmlParser.HTMLElement | null, manifest: Record<string, any> | null): Promise<string | null> {
if (info && info.metadata) {
if (typeof info.metadata.nodeDescription === 'string') {
return info.metadata.nodeDescription;

View File

@@ -20,6 +20,7 @@ import { AiService } from '@/core/AiService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { isMimeImage } from '@/misc/is-mime-image.js';
import type { PredictionType } from 'nsfwjs';
export type FileInfo = {
@@ -64,6 +65,7 @@ export class FileInfoService {
*/
@bindThis
public async getFileInfo(path: string, opts: {
fileName?: string | null;
skipSensitiveDetection: boolean;
sensitiveThreshold?: number;
sensitiveThresholdForPorn?: number;
@@ -76,6 +78,26 @@ export class FileInfoService {
let type = await this.detectType(path);
if (type.mime === TYPE_OCTET_STREAM.mime && opts.fileName != null) {
const ext = opts.fileName.split('.').pop();
if (ext === 'txt') {
type = {
mime: 'text/plain',
ext: 'txt',
};
} else if (ext === 'csv') {
type = {
mime: 'text/csv',
ext: 'csv',
};
} else if (ext === 'json') {
type = {
mime: 'application/json',
ext: 'json',
};
}
}
// image dimensions
let width: number | undefined;
let height: number | undefined;
@@ -183,16 +205,7 @@ export class FileInfoService {
return [sensitive, porn];
}
if ([
'image/jpeg',
'image/png',
'image/webp',
].includes(mime)) {
const result = await this.aiService.detectSensitive(source);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
} else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
const [outDir, disposeOutDir] = await createTempDir();
try {
const command = FFmpeg()
@@ -260,6 +273,23 @@ export class FileInfoService {
} finally {
disposeOutDir();
}
} else if (isMimeImage(mime, 'sharp-convertible-image-with-bmp')) {
/*
* tfjs-node は限られた画像形式しか受け付けないため、sharp で PNG に変換する
* せっかくなので内部処理で使われる最大サイズの299x299に事前にリサイズする
*/
const png = await (await sharpBmp(source, mime))
.resize(299, 299, {
withoutEnlargement: false,
})
.rotate()
.flatten({ background: { r: 119, g: 119, b: 119 } }) // 透過部分を18%グレーで塗りつぶす
.png()
.toBuffer();
const result = await this.aiService.detectSensitive(png);
if (result) {
[sensitive, porn] = judgePrediction(result);
}
}
return [sensitive, porn];
@@ -309,7 +339,7 @@ export class FileInfoService {
}
@bindThis
public fixMime(mime: string | fileType.MimeType): string {
public fixMime(mime: string): string {
// see https://github.com/misskey-dev/misskey/pull/10686
if (mime === 'audio/x-flac') {
return 'audio/flac';
@@ -438,12 +468,12 @@ export class FileInfoService {
*/
@bindThis
private async detectImageSize(path: string): Promise<{
width: number;
height: number;
wUnits: string;
hUnits: string;
orientation?: number;
}> {
width: number;
height: number;
wUnits: string;
hUnits: string;
orientation?: number;
}> {
const readable = fs.createReadStream(path);
const imageSize = await probeImageSize(readable);
readable.destroy();
@@ -454,25 +484,13 @@ export class FileInfoService {
* Calculate blurhash string of image
*/
@bindThis
private getBlurhash(path: string, type: string): Promise<string> {
return new Promise(async (resolve, reject) => {
(await sharpBmp(path, type))
.raw()
.ensureAlpha()
.resize(64, 64, { fit: 'inside' })
.toBuffer((err, buffer, info) => {
if (err) return reject(err);
let hash;
try {
hash = blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
} catch (e) {
return reject(e);
}
resolve(hash);
});
});
private async getBlurhash(path: string, type: string): Promise<string> {
const sharp = await sharpBmp(path, type);
const { data: buffer, info } = await sharp
.raw()
.ensureAlpha()
.resize(64, 64, { fit: 'inside' })
.toBuffer({ resolveWithObject: true });
return blurhash.encode(new Uint8ClampedArray(buffer), info.width, info.height, 5, 5);
}
}

View File

@@ -4,8 +4,11 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { type FlashsRepository } from '@/models/_.js';
import { type FlashLikesRepository, MiUser, type FlashsRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
/**
* MisskeyPlay関係のService
@@ -15,6 +18,11 @@ export class FlashService {
constructor(
@Inject(DI.flashsRepository)
private flashRepository: FlashsRepository,
@Inject(DI.flashLikesRepository)
private flashLikesRepository: FlashLikesRepository,
private queryService: QueryService,
) {
}
@@ -37,4 +45,43 @@ export class FlashService {
return await builder.getMany();
}
public async myLikes(meId: MiUser['id'], opts: { sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number, limit?: number, search?: string | null }) {
const query = this.queryService.makePaginationQuery(this.flashLikesRepository.createQueryBuilder('like'), opts.sinceId, opts.untilId, opts.sinceDate, opts.untilDate)
.andWhere('like.userId = :meId', { meId })
.leftJoinAndSelect('like.flash', 'flash');
if (opts.search != null) {
for (const word of opts.search.trim().split(' ')) {
query.andWhere(new Brackets(qb => {
qb.orWhere('flash.title ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
qb.orWhere('flash.summary ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
}));
}
}
const likes = await query
.limit(opts.limit)
.getMany();
return likes;
}
public async search(searchQuery: string, opts: { sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number, limit?: number }) {
const query = this.queryService.makePaginationQuery(this.flashRepository.createQueryBuilder('flash'), opts.sinceId, opts.untilId, opts.sinceDate, opts.untilDate)
.andWhere('flash.visibility = \'public\'');
for (const word of searchQuery.trim().split(' ')) {
query.andWhere(new Brackets(qb => {
qb.orWhere('flash.title ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
qb.orWhere('flash.summary ILIKE :search', { search: `%${sqlLikeEscape(word)}%` });
}));
}
const result = await query
.limit(opts.limit)
.getMany();
return result;
}
}

View File

@@ -39,11 +39,7 @@ export interface BroadcastTypes {
emojis: Packed<'EmojiDetailed'>[];
};
emojiDeleted: {
emojis: {
id?: string;
name: string;
[other: string]: any;
}[];
emojis: Packed<'EmojiDetailed'>[];
};
announcementCreated: {
announcement: Packed<'Announcement'>;
@@ -328,6 +324,8 @@ export interface InternalEventTypes {
metaUpdated: { before?: MiMeta; after: MiMeta; };
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
updateUserProfile: MiUserProfile;
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };

View File

@@ -6,6 +6,7 @@
import * as http from 'node:http';
import * as https from 'node:https';
import * as net from 'node:net';
import * as stream from 'node:stream';
import ipaddr from 'ipaddr.js';
import CacheableLookup from 'cacheable-lookup';
import fetch from 'node-fetch';
@@ -26,12 +27,6 @@ export type HttpRequestSendOptions = {
validators?: ((res: Response) => void)[];
};
declare module 'node:http' {
interface Agent {
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
}
}
class HttpRequestServiceAgent extends http.Agent {
constructor(
private config: Config,
@@ -41,18 +36,24 @@ class HttpRequestServiceAgent extends http.Agent {
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback)
.on('connect', () => {
public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex {
const socket = super.createConnection(options, callback);
if (socket == null) {
throw new Error('Failed to create socket');
}
socket.on('connect', () => {
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
});
}
});
return socket;
}
@@ -80,18 +81,24 @@ class HttpsRequestServiceAgent extends https.Agent {
}
@bindThis
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
const socket = super.createConnection(options, callback)
.on('connect', () => {
public createConnection(options: http.ClientRequestArgs, callback?: (err: Error | null, stream: stream.Duplex) => void): stream.Duplex {
const socket = super.createConnection(options, callback);
if (socket == null) {
throw new Error('Failed to create socket');
}
socket.on('connect', () => {
if (socket instanceof net.Socket && process.env.NODE_ENV === 'production') {
const address = socket.remoteAddress;
if (process.env.NODE_ENV === 'production') {
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
if (address && ipaddr.isValid(address)) {
if (this.isPrivateIp(address)) {
socket.destroy(new Error(`Blocked address: ${address}`));
}
}
});
}
});
return socket;
}

View File

@@ -34,6 +34,7 @@ export const webpDefault: sharp.WebpOptions = {
smartSubsample: true,
mixed: true,
effort: 2,
loop: 0,
};
export const avifDefault: sharp.AvifOptions = {

View File

@@ -5,26 +5,19 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
import { type Document, type HTMLParagraphElement, Window, XMLSerializer } from 'happy-dom';
import * as htmlParser from 'node-html-parser';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import type { DefaultTreeAdapterMap } from 'parse5';
import { escapeHtml } from '@/misc/escape-html.js';
import type * as mfm from 'mfm-js';
const treeAdapter = parse5.defaultTreeAdapter;
type Node = DefaultTreeAdapterMap['node'];
type ChildNode = DefaultTreeAdapterMap['childNode'];
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
@Injectable()
export class MfmService {
constructor(
@@ -40,68 +33,68 @@ export class MfmService {
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
const dom = parse5.parseFragment(html);
const doc = htmlParser.parse(`<div>${html}</div>`);
let text = '';
for (const n of dom.childNodes) {
for (const n of doc.childNodes) {
analyze(n);
}
return text.trim();
function getText(node: Node): string {
if (treeAdapter.isTextNode(node)) return node.value;
if (!treeAdapter.isElementNode(node)) return '';
if (node.nodeName === 'br') return '\n';
function getText(node: htmlParser.Node): string {
if (node instanceof htmlParser.TextNode) return node.textContent;
if (!(node instanceof htmlParser.HTMLElement)) return '';
if (node.tagName === 'BR') return '\n';
if (node.childNodes) {
if (node.childNodes != null) {
return node.childNodes.map(n => getText(n)).join('');
}
return '';
}
function appendChildren(childNodes: ChildNode[]): void {
if (childNodes) {
function analyzeChildren(childNodes: htmlParser.Node[] | null): void {
if (childNodes != null) {
for (const n of childNodes) {
analyze(n);
}
}
}
function analyze(node: Node) {
if (treeAdapter.isTextNode(node)) {
text += node.value;
function analyze(node: htmlParser.Node) {
if (node instanceof htmlParser.TextNode) {
text += node.textContent;
return;
}
// Skip comment or document type node
if (!treeAdapter.isElementNode(node)) {
if (!(node instanceof htmlParser.HTMLElement)) {
return;
}
switch (node.nodeName) {
case 'br': {
switch (node.tagName) {
case 'BR': {
text += '\n';
break;
}
case 'a': {
case 'A': {
const txt = getText(node);
const rel = node.attrs.find(x => x.name === 'rel');
const href = node.attrs.find(x => x.name === 'href');
const rel = node.attributes.rel;
const href = node.attributes.href;
// ハッシュタグ
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
if (normalizedHashtagNames && href != null && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += txt;
// メンション
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
} else if (txt.startsWith('@') && !(rel != null && rel.startsWith('me '))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
const acct = `${txt}@${(new URL(href.value)).hostname}`;
const acct = `${txt}@${(new URL(href)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
@@ -116,17 +109,17 @@ export class MfmService {
if (!href) {
return txt;
}
if (!txt || txt === href.value) { // #6383: Missing text node
if (href.value.match(urlRegexFull)) {
return href.value;
if (!txt || txt === href) { // #6383: Missing text node
if (href.match(urlRegexFull)) {
return href;
} else {
return `<${href.value}>`;
return `<${href}>`;
}
}
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
return `[${txt}](<${href.value}>)`; // #6846
if (href.match(urlRegex) && !href.match(urlRegexFull)) {
return `[${txt}](<${href}>)`; // #6846
} else {
return `[${txt}](${href.value})`;
return `[${txt}](${href})`;
}
};
@@ -135,60 +128,64 @@ export class MfmService {
break;
}
case 'h1': {
case 'H1': {
text += '【';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
text += '】\n';
break;
}
case 'b':
case 'strong': {
case 'B':
case 'STRONG': {
text += '**';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
text += '**';
break;
}
case 'small': {
case 'SMALL': {
text += '<small>';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
text += '</small>';
break;
}
case 's':
case 'del': {
case 'S':
case 'DEL': {
text += '~~';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
text += '~~';
break;
}
case 'i':
case 'em': {
case 'I':
case 'EM': {
text += '<i>';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
text += '</i>';
break;
}
case 'ruby': {
case 'RUBY': {
let ruby: [string, string][] = [];
for (const child of node.childNodes) {
if (child.nodeName === 'rp') {
if ((child instanceof htmlParser.TextNode) && !/\s|\[|\]/.test(child.textContent)) {
ruby.push([child.textContent, '']);
continue;
}
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
ruby.push([child.value, '']);
if (!(child instanceof htmlParser.HTMLElement)) continue;
if (child.tagName === 'RP') {
continue;
}
if (child.nodeName === 'rt' && ruby.length > 0) {
if (child.tagName === 'RT' && ruby.length > 0) {
const rt = getText(child);
if (/\s|\[|\]/.test(rt)) {
// If any space is included in rt, it is treated as a normal text
ruby = [];
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
break;
} else {
ruby.at(-1)![1] = rt;
@@ -197,7 +194,7 @@ export class MfmService {
}
// If any other element is included in ruby, it is treated as a normal text
ruby = [];
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
break;
}
for (const [base, rt] of ruby) {
@@ -207,26 +204,30 @@ export class MfmService {
}
// block code (<pre><code>)
case 'pre': {
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
case 'PRE': {
if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.HTMLElement) && node.childNodes[0].tagName === 'CODE') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
} else if (node.childNodes.length === 1 && (node.childNodes[0] instanceof htmlParser.TextNode) && node.childNodes[0].textContent.startsWith('<code>') && node.childNodes[0].textContent.endsWith('</code>')) {
text += '\n```\n';
text += node.childNodes[0].textContent.slice(6, -7);
text += '\n```\n';
} else {
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
}
break;
}
// inline code (<code>)
case 'code': {
case 'CODE': {
text += '`';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
text += '`';
break;
}
case 'blockquote': {
case 'BLOCKQUOTE': {
const t = getText(node);
if (t) {
text += '\n> ';
@@ -235,33 +236,33 @@ export class MfmService {
break;
}
case 'p':
case 'h2':
case 'h3':
case 'h4':
case 'h5':
case 'h6': {
case 'P':
case 'H2':
case 'H3':
case 'H4':
case 'H5':
case 'H6': {
text += '\n\n';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
break;
}
// other block elements
case 'div':
case 'header':
case 'footer':
case 'article':
case 'li':
case 'dt':
case 'dd': {
case 'DIV':
case 'HEADER':
case 'FOOTER':
case 'ARTICLE':
case 'LI':
case 'DT':
case 'DD': {
text += '\n';
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
break;
}
default: // includes inline elements
{
appendChildren(node.childNodes);
analyzeChildren(node.childNodes);
break;
}
}
@@ -269,52 +270,35 @@ export class MfmService {
}
@bindThis
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], additionalAppenders: Appender[] = []) {
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = [], extraHtml: string | null = null) {
if (nodes == null) {
return null;
}
const { happyDOM, window } = new Window();
const doc = window.document;
const body = doc.createElement('p');
function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
if (children) {
for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
}
function toHtml(children?: mfm.MfmNode[]): string {
if (children == null) return '';
return children.map(x => handlers[x.type](x)).join('');
}
function fnDefault(node: mfm.MfmFn) {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
return `<i>${toHtml(node.children)}</i>`;
}
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
const handlers = {
bold: (node) => {
const el = doc.createElement('b');
appendChildren(node.children, el);
return el;
return `<b>${toHtml(node.children)}</b>`;
},
small: (node) => {
const el = doc.createElement('small');
appendChildren(node.children, el);
return el;
return `<small>${toHtml(node.children)}</small>`;
},
strike: (node) => {
const el = doc.createElement('del');
appendChildren(node.children, el);
return el;
return `<del>${toHtml(node.children)}</del>`;
},
italic: (node) => {
const el = doc.createElement('i');
appendChildren(node.children, el);
return el;
return `<i>${toHtml(node.children)}</i>`;
},
fn: (node) => {
@@ -323,11 +307,8 @@ export class MfmService {
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try {
const date = new Date(parseInt(text, 10) * 1000);
const el = doc.createElement('time');
el.setAttribute('datetime', date.toISOString());
el.textContent = date.toISOString();
return el;
} catch (err) {
return `<time datetime="${escapeHtml(date.toISOString())}">${escapeHtml(date.toISOString())}</time>`;
} catch (_) {
return fnDefault(node);
}
}
@@ -336,21 +317,9 @@ export class MfmService {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
// ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
return `<ruby>${escapeHtml(text.split(' ')[0])}<rp>(</rp><rt>${escapeHtml(text.split(' ')[1])}</rt><rp>)</rp></ruby>`;
} else {
const rt = node.children.at(-1);
@@ -359,21 +328,9 @@ export class MfmService {
}
const text = rt.type === 'text' ? rt.props.text : '';
const rubyEl = doc.createElement('ruby');
const rtEl = doc.createElement('rt');
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
const rpStartEl = doc.createElement('rp');
rpStartEl.appendChild(doc.createTextNode('('));
const rpEndEl = doc.createElement('rp');
rpEndEl.appendChild(doc.createTextNode(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
rtEl.appendChild(doc.createTextNode(text.trim()));
rubyEl.appendChild(rpStartEl);
rubyEl.appendChild(rtEl);
rubyEl.appendChild(rpEndEl);
return rubyEl;
// ruby未対応のHTMLサニタイザーを通したときにルビが「対象テキスト(ルビテキスト)」にフォールバックするようにする
return `<ruby>${toHtml(node.children.slice(0, node.children.length - 1))}<rp>(</rp><rt>${escapeHtml(text.trim())}</rt><rp>)</rp></ruby>`;
}
}
@@ -384,125 +341,98 @@ export class MfmService {
},
blockCode: (node) => {
const pre = doc.createElement('pre');
const inner = doc.createElement('code');
inner.textContent = node.props.code;
pre.appendChild(inner);
return pre;
return `<pre><code>${escapeHtml(node.props.code)}</code></pre>`;
},
center: (node) => {
const el = doc.createElement('div');
appendChildren(node.children, el);
return el;
return `<div style="text-align: center;">${toHtml(node.children)}</div>`;
},
emojiCode: (node) => {
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
return `\u200B:${escapeHtml(node.props.name)}:\u200B`;
},
unicodeEmoji: (node) => {
return doc.createTextNode(node.props.emoji);
return node.props.emoji;
},
hashtag: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
return a;
return `<a href="${escapeHtml(`${this.config.url}/tags/${encodeURIComponent(node.props.hashtag)}`)}" rel="tag">#${escapeHtml(node.props.hashtag)}</a>`;
},
inlineCode: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.code;
return el;
return `<code>${escapeHtml(node.props.code)}</code>`;
},
mathInline: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
return `<code>${escapeHtml(node.props.formula)}</code>`;
},
mathBlock: (node) => {
const el = doc.createElement('code');
el.textContent = node.props.formula;
return el;
return `<pre><code>${escapeHtml(node.props.formula)}</code></pre>`;
},
link: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
appendChildren(node.children, a);
return a;
try {
const url = new URL(node.props.url);
return `<a href="${escapeHtml(url.href)}">${toHtml(node.children)}</a>`;
} catch (_) {
return `[${toHtml(node.children)}](${escapeHtml(node.props.url)})`;
}
},
mention: (node) => {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
a.setAttribute('href', remoteUserInfo
const href = remoteUserInfo
? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
a.className = 'u-url mention';
a.textContent = acct;
return a;
: `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`;
try {
const url = new URL(href);
return `<a href="${escapeHtml(url.href)}" class="u-url mention">${escapeHtml(acct)}</a>`;
} catch (_) {
return escapeHtml(acct);
}
},
quote: (node) => {
const el = doc.createElement('blockquote');
appendChildren(node.children, el);
return el;
return `<blockquote>${toHtml(node.children)}</blockquote>`;
},
text: (node) => {
if (!node.props.text.match(/[\r\n]/)) {
return doc.createTextNode(node.props.text);
return escapeHtml(node.props.text);
}
const el = doc.createElement('span');
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
let html = '';
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
el.appendChild(x === 'br' ? doc.createElement('br') : x);
const lines = node.props.text.split(/\r\n|\r|\n/).map(x => escapeHtml(x));
for (const x of intersperse<FIXME | 'br'>('br', lines)) {
html += x === 'br' ? '<br />' : x;
}
return el;
return html;
},
url: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', node.props.url);
a.textContent = node.props.url;
return a;
try {
const url = new URL(node.props.url);
return `<a href="${escapeHtml(url.href)}">${escapeHtml(node.props.url)}</a>`;
} catch (_) {
return escapeHtml(node.props.url);
}
},
search: (node) => {
const a = doc.createElement('a');
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
return a;
return `<a href="${escapeHtml(`https://www.google.com/search?q=${encodeURIComponent(node.props.query)}`)}">${escapeHtml(node.props.content)}</a>`;
},
plain: (node) => {
const el = doc.createElement('span');
appendChildren(node.children, el);
return el;
return `<span>${toHtml(node.children)}</span>`;
},
};
} satisfies { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => string } as { [K in mfm.MfmNode['type']]: (node: mfm.MfmNode) => string };
appendChildren(nodes, body);
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
// Remove the unnecessary namespace
const serialized = new XMLSerializer().serializeToString(body).replace(/^\s*<p xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">/, '<p>');
happyDOM.close().catch(err => {});
return serialized;
return `${toHtml(nodes)}${extraHtml ?? ''}`;
}
}

View File

@@ -13,7 +13,7 @@ import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mf
import { extractHashtags } from '@/misc/extract-hashtags.js';
import type { IMentionedRemoteUsers } from '@/models/Note.js';
import { MiNote } from '@/models/Note.js';
import type { ChannelFollowingsRepository, ChannelsRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { BlockingsRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, FollowingsRepository, InstancesRepository, MiFollowing, MiMeta, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserListMembershipsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiApp } from '@/models/App.js';
import { concat } from '@/misc/prelude/array.js';
@@ -56,6 +56,7 @@ import { trackPromise } from '@/misc/promise-tracker.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { CollapsedQueue } from '@/misc/collapsed-queue.js';
import { CacheService } from '@/core/CacheService.js';
import { isQuote, isRenote } from '@/misc/is-renote.js';
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
@@ -192,6 +193,12 @@ export class NoteCreateService implements OnApplicationShutdown {
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
private userEntityService: UserEntityService,
private noteEntityService: NoteEntityService,
private idService: IdService,
@@ -221,6 +228,167 @@ export class NoteCreateService implements OnApplicationShutdown {
this.updateNotesCountQueue = new CollapsedQueue(process.env.NODE_ENV !== 'test' ? 60 * 1000 * 5 : 0, this.collapseNotesCount, this.performUpdateNotesCount);
}
@bindThis
public async fetchAndCreate(user: {
id: MiUser['id'];
username: MiUser['username'];
host: MiUser['host'];
isBot: MiUser['isBot'];
isCat: MiUser['isCat'];
}, data: {
createdAt: Date;
replyId: MiNote['id'] | null;
renoteId: MiNote['id'] | null;
fileIds: MiDriveFile['id'][];
text: string | null;
cw: string | null;
visibility: string;
visibleUserIds: MiUser['id'][];
channelId: MiChannel['id'] | null;
localOnly: boolean;
reactionAcceptance: MiNote['reactionAcceptance'];
poll: IPoll | null;
apMentions?: MinimumUser[] | null;
apHashtags?: string[] | null;
apEmojis?: string[] | null;
}): Promise<MiNote> {
const visibleUsers = data.visibleUserIds.length > 0 ? await this.usersRepository.findBy({
id: In(data.visibleUserIds),
}) : [];
let files: MiDriveFile[] = [];
if (data.fileIds.length > 0) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: user.id,
fileIds: data.fileIds,
})
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds: data.fileIds })
.getMany();
if (files.length !== data.fileIds.length) {
throw new IdentifiableError('801c046c-5bf5-4234-ad2b-e78fc20a2ac7', 'No such file');
}
}
let renote: MiNote | null = null;
if (data.renoteId != null) {
// Fetch renote to note
renote = await this.notesRepository.findOne({
where: { id: data.renoteId },
relations: ['user', 'renote', 'reply'],
});
if (renote == null) {
throw new IdentifiableError('53983c56-e163-45a6-942f-4ddc485d4290', 'No such renote target');
} else if (isRenote(renote) && !isQuote(renote)) {
throw new IdentifiableError('bde24c37-121f-4e7d-980d-cec52f599f02', 'Cannot renote pure renote');
}
// Check blocking
if (renote.userId !== user.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: renote.userId,
blockeeId: user.id,
},
});
if (blockExist) {
throw new IdentifiableError('2b4fe776-4414-4a2d-ae39-f3418b8fd4d3', 'You have been blocked by the user');
}
}
if (renote.visibility === 'followers' && renote.userId !== user.id) {
// 他人のfollowers noteはreject
throw new IdentifiableError('90b9d6f0-893a-4fef-b0f1-e9a33989f71a', 'Renote target visibility');
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new IdentifiableError('48d7a997-da5c-4716-b3c3-92db3f37bf7d', 'Renote target visibility');
}
if (renote.channelId && renote.channelId !== data.channelId) {
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
if (renoteChannel == null) {
// リノートしたいノートが書き込まれているチャンネルが無い
throw new IdentifiableError('b060f9a6-8909-4080-9e0b-94d9fa6f6a77', 'No such channel');
} else if (!renoteChannel.allowRenoteToExternal) {
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
throw new IdentifiableError('7e435f4a-780d-4cfc-a15a-42519bd6fb67', 'Channel does not allow renote to external');
}
}
}
let reply: MiNote | null = null;
if (data.replyId != null) {
// Fetch reply
reply = await this.notesRepository.findOne({
where: { id: data.replyId },
relations: ['user'],
});
if (reply == null) {
throw new IdentifiableError('60142edb-1519-408e-926d-4f108d27bee0', 'No such reply target');
} else if (isRenote(reply) && !isQuote(reply)) {
throw new IdentifiableError('f089e4e2-c0e7-4f60-8a23-e5a6bf786b36', 'Cannot reply to pure renote');
} else if (!await this.noteEntityService.isVisibleForMe(reply, user.id)) {
throw new IdentifiableError('11cd37b3-a411-4f77-8633-c580ce6a8dce', 'No such reply target');
} else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
throw new IdentifiableError('ced780a1-2012-4caf-bc7e-a95a291294cb', 'Cannot reply to specified note with different visibility');
}
// Check blocking
if (reply.userId !== user.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: reply.userId,
blockeeId: user.id,
},
});
if (blockExist) {
throw new IdentifiableError('b0df6025-f2e8-44b4-a26a-17ad99104612', 'You have been blocked by the user');
}
}
}
if (data.poll) {
if (data.poll.expiresAt != null) {
if (data.poll.expiresAt.getTime() < Date.now()) {
throw new IdentifiableError('0c11c11e-0c8d-48e7-822c-76ccef660068', 'Poll expiration must be future time');
}
}
}
let channel: MiChannel | null = null;
if (data.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
if (channel == null) {
throw new IdentifiableError('bfa3905b-25f5-4894-b430-da331a490e4b', 'No such channel');
}
}
return this.create(user, {
createdAt: data.createdAt,
files: files,
poll: data.poll,
text: data.text,
reply,
renote,
cw: data.cw,
localOnly: data.localOnly,
reactionAcceptance: data.reactionAcceptance,
visibility: data.visibility,
visibleUsers,
channel,
apMentions: data.apMentions,
apHashtags: data.apHashtags,
apEmojis: data.apEmojis,
});
}
@bindThis
public async create(user: {
id: MiUser['id'];
@@ -421,7 +589,7 @@ export class NoteCreateService implements OnApplicationShutdown {
emojis,
userId: user.id,
localOnly: data.localOnly!,
reactionAcceptance: data.reactionAcceptance,
reactionAcceptance: data.reactionAcceptance ?? null,
visibility: data.visibility as any,
visibleUserIds: data.visibility === 'specified'
? data.visibleUsers
@@ -436,6 +604,7 @@ export class NoteCreateService implements OnApplicationShutdown {
replyUserHost: data.reply ? data.reply.userHost : null,
renoteUserId: data.renote ? data.renote.userId : null,
renoteUserHost: data.renote ? data.renote.userHost : null,
renoteChannelId: data.renote ? data.renote.channelId : null,
userHost: user.host,
});
@@ -483,7 +652,11 @@ export class NoteCreateService implements OnApplicationShutdown {
await this.notesRepository.insert(insert);
}
return insert;
return {
...insert,
reply: data.reply ?? null,
renote: data.renote ?? null,
};
} catch (e) {
// duplicate key error
if (isDuplicateKeyValueError(e)) {

View File

@@ -62,7 +62,6 @@ export class NoteDeleteService {
*/
async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) {
const deletedAt = new Date();
const cascadingNotes = await this.findCascadingNotes(note);
if (note.replyId) {
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
@@ -90,15 +89,6 @@ export class NoteDeleteService {
this.deliverToConcerned(user, note, content);
}
// also deliver delete activity to cascaded notes
const federatedLocalCascadingNotes = (cascadingNotes).filter(note => !note.localOnly && note.userHost == null); // filter out local-only notes
for (const cascadingNote of federatedLocalCascadingNotes) {
if (!cascadingNote.user) continue;
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
this.deliverToConcerned(cascadingNote.user, cascadingNote, content);
}
//#endregion
this.notesChart.update(note, false);
@@ -118,9 +108,6 @@ export class NoteDeleteService {
}
}
for (const cascadingNote of cascadingNotes) {
this.searchService.unindexNote(cascadingNote);
}
this.searchService.unindexNote(note);
await this.notesRepository.delete({
@@ -140,29 +127,6 @@ export class NoteDeleteService {
}
}
@bindThis
private async findCascadingNotes(note: MiNote): Promise<MiNote[]> {
const recursive = async (noteId: string): Promise<MiNote[]> => {
const query = this.notesRepository.createQueryBuilder('note')
.where('note.replyId = :noteId', { noteId })
.orWhere(new Brackets(q => {
q.where('note.renoteId = :noteId', { noteId })
.andWhere('note.text IS NOT NULL');
}))
.leftJoinAndSelect('note.user', 'user');
const replies = await query.getMany();
return [
replies,
...await Promise.all(replies.map(reply => recursive(reply.id))),
].flat();
};
const cascadingNotes: MiNote[] = await recursive(note.id);
return cascadingNotes;
}
@bindThis
private async getMentionedRemoteUsers(note: MiNote) {
const where = [] as any[];

View File

@@ -0,0 +1,339 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiNoteDraft, NoteDraftsRepository, MiNote, MiDriveFile, MiChannel, UsersRepository, DriveFilesRepository, NotesRepository, BlockingsRepository, ChannelsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { isRenote, isQuote } from '@/misc/is-renote.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { QueueService } from '@/core/QueueService.js';
export type NoteDraftOptions = Omit<MiNoteDraft, 'id' | 'userId' | 'user' | 'reply' | 'renote' | 'channel'>;
@Injectable()
export class NoteDraftService {
constructor(
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.noteDraftsRepository)
private noteDraftsRepository: NoteDraftsRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
private roleService: RoleService,
private idService: IdService,
private noteEntityService: NoteEntityService,
private queueService: QueueService,
) {
}
@bindThis
public async get(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise<MiNoteDraft | null> {
const draft = await this.noteDraftsRepository.findOneBy({
id: draftId,
userId: me.id,
});
return draft;
}
@bindThis
public async create(me: MiLocalUser, data: NoteDraftOptions): Promise<MiNoteDraft> {
//#region check draft limit
const policies = await this.roleService.getUserPolicies(me.id);
const currentCount = await this.noteDraftsRepository.countBy({
userId: me.id,
});
if (currentCount >= policies.noteDraftLimit) {
throw new IdentifiableError('9ee33bbe-fde3-4c71-9b51-e50492c6b9c8', 'Too many drafts');
}
if (data.isActuallyScheduled) {
const currentScheduledCount = await this.noteDraftsRepository.countBy({
userId: me.id,
isActuallyScheduled: true,
});
if (currentScheduledCount >= policies.scheduledNoteLimit) {
throw new IdentifiableError('c3275f19-4558-4c59-83e1-4f684b5fab66', 'Too many scheduled notes');
}
}
//#endregion
await this.validate(me, data);
const draft = await this.noteDraftsRepository.insertOne({
...data,
id: this.idService.gen(),
userId: me.id,
});
if (draft.scheduledAt && draft.isActuallyScheduled) {
this.schedule(draft);
}
return draft;
}
@bindThis
public async update(me: MiLocalUser, draftId: MiNoteDraft['id'], data: Partial<NoteDraftOptions>): Promise<MiNoteDraft> {
const draft = await this.noteDraftsRepository.findOneBy({
id: draftId,
userId: me.id,
});
if (draft == null) {
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
}
//#region check draft limit
const policies = await this.roleService.getUserPolicies(me.id);
if (!draft.isActuallyScheduled && data.isActuallyScheduled) {
const currentScheduledCount = await this.noteDraftsRepository.countBy({
userId: me.id,
isActuallyScheduled: true,
});
if (currentScheduledCount >= policies.scheduledNoteLimit) {
throw new IdentifiableError('bacdf856-5c51-4159-b88a-804fa5103be5', 'Too many scheduled notes');
}
}
//#endregion
await this.validate(me, data);
const updatedDraft = await this.noteDraftsRepository.createQueryBuilder().update()
.set(data)
.where('id = :id', { id: draftId })
.returning('*')
.execute()
.then((response) => response.raw[0]);
this.clearSchedule(draftId).then(() => {
if (updatedDraft.scheduledAt != null && updatedDraft.isActuallyScheduled) {
this.schedule(updatedDraft);
}
});
return updatedDraft;
}
@bindThis
public async delete(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise<void> {
const draft = await this.noteDraftsRepository.findOneBy({
id: draftId,
userId: me.id,
});
if (draft == null) {
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
}
await this.noteDraftsRepository.delete(draft.id);
this.clearSchedule(draftId);
}
@bindThis
public async getDraft(me: MiLocalUser, draftId: MiNoteDraft['id']): Promise<MiNoteDraft> {
const draft = await this.noteDraftsRepository.findOneBy({
id: draftId,
userId: me.id,
});
if (draft == null) {
throw new IdentifiableError('49cd6b9d-848e-41ee-b0b9-adaca711a6b1', 'No such note draft');
}
return draft;
}
@bindThis
public async validate(
me: MiLocalUser,
data: Partial<NoteDraftOptions>,
): Promise<void> {
if (data.isActuallyScheduled) {
if (data.scheduledAt == null) {
throw new IdentifiableError('94a89a43-3591-400a-9c17-dd166e71fdfa', 'scheduledAt is required when isActuallyScheduled is true');
} else if (data.scheduledAt.getTime() < Date.now()) {
throw new IdentifiableError('b34d0c1b-996f-4e34-a428-c636d98df457', 'scheduledAt must be in the future');
}
}
if (data.pollExpiresAt != null) {
if (data.pollExpiresAt.getTime() < Date.now()) {
throw new IdentifiableError('04da457d-b083-4055-9082-955525eda5a5', 'Cannot create expired poll');
}
}
//#region visibleUsers
let _visibleUsers: MiUser[] = [];
if (data.visibleUserIds != null && data.visibleUserIds.length > 0) {
_visibleUsers = await this.usersRepository.findBy({
id: In(data.visibleUserIds),
});
}
//#endregion
//#region files
let files: MiDriveFile[] = [];
const fileIds = data.fileIds ?? null;
if (fileIds != null && fileIds.length > 0) {
files = await this.driveFilesRepository.createQueryBuilder('file')
.where('file.userId = :userId AND file.id IN (:...fileIds)', {
userId: me.id,
fileIds: fileIds,
})
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds })
.getMany();
if (files.length !== fileIds.length) {
throw new IdentifiableError('b6992544-63e7-67f0-fa7f-32444b1b5306', 'No such drive file');
}
}
//#endregion
//#region renote
let renote: MiNote | null = null;
if (data.renoteId != null) {
renote = await this.notesRepository.findOneBy({ id: data.renoteId });
if (renote == null) {
throw new IdentifiableError('64929870-2540-4d11-af41-3b484d78c956', 'No such renote');
} else if (isRenote(renote) && !isQuote(renote)) {
throw new IdentifiableError('76cc5583-5a14-4ad3-8717-0298507e32db', 'Cannot renote');
}
// Check blocking
if (renote.userId !== me.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: renote.userId,
blockeeId: me.id,
},
});
if (blockExist) {
throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user');
}
}
if (renote.visibility === 'followers' && renote.userId !== me.id) {
// 他人のfollowers noteはreject
throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility');
} else if (renote.visibility === 'specified') {
// specified / direct noteはreject
throw new IdentifiableError('81eb8188-aea1-4e35-9a8f-3334a3be9855', 'Cannot Renote Due to Visibility');
}
if (renote.channelId && renote.channelId !== data.channelId) {
// チャンネルのノートに対しリノート要求がきたとき、チャンネル外へのリノート可否をチェック
// リートのユースケースのうち、チャンネル内→チャンネル外は少数だと考えられるため、JOINはせず必要な時に都度取得する
const renoteChannel = await this.channelsRepository.findOneBy({ id: renote.channelId });
if (renoteChannel == null) {
// リノートしたいノートが書き込まれているチャンネルがない
throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel');
} else if (!renoteChannel.allowRenoteToExternal) {
// リノート作成のリクエストだが、対象チャンネルがリノート禁止だった場合
throw new IdentifiableError('ed1952ac-2d26-4957-8b30-2deda76bedf7', 'Cannot Renote to External');
}
}
}
//#endregion
//#region reply
let reply: MiNote | null = null;
if (data.replyId != null) {
// Fetch reply
reply = await this.notesRepository.findOneBy({ id: data.replyId });
if (reply == null) {
throw new IdentifiableError('c4721841-22fc-4bb7-ad3d-897ef1d375b5', 'No such reply');
} else if (isRenote(reply) && !isQuote(reply)) {
throw new IdentifiableError('e6c10b57-2c09-4da3-bd4d-eda05d51d140', 'Cannot reply To Pure Renote');
} else if (!await this.noteEntityService.isVisibleForMe(reply, me.id)) {
throw new IdentifiableError('593c323c-6b6a-4501-a25c-2f36bd2a93d6', 'Cannot reply To Invisible Note');
} else if (reply.visibility === 'specified' && data.visibility !== 'specified') {
throw new IdentifiableError('215dbc76-336c-4d2a-9605-95766ba7dab0', 'Cannot reply To Specified Note With Extended Visibility');
}
// Check blocking
if (reply.userId !== me.id) {
const blockExist = await this.blockingsRepository.exists({
where: {
blockerId: reply.userId,
blockeeId: me.id,
},
});
if (blockExist) {
throw new IdentifiableError('075ca298-e6e7-485a-b570-51a128bb5168', 'You have been blocked by the user');
}
}
}
//#endregion
//#region channel
let channel: MiChannel | null = null;
if (data.channelId != null) {
channel = await this.channelsRepository.findOneBy({ id: data.channelId, isArchived: false });
if (channel == null) {
throw new IdentifiableError('6815399a-6f13-4069-b60d-ed5156249d12', 'No such channel');
}
}
//#endregion
}
@bindThis
public async schedule(draft: MiNoteDraft): Promise<void> {
if (!draft.isActuallyScheduled) return;
if (draft.scheduledAt == null) return;
if (draft.scheduledAt.getTime() <= Date.now()) return;
const delay = draft.scheduledAt.getTime() - Date.now();
this.queueService.postScheduledNoteQueue.add(draft.id, {
noteDraftId: draft.id,
}, {
delay,
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 100,
},
});
}
@bindThis
public async clearSchedule(draftId: MiNoteDraft['id']): Promise<void> {
// TODO: 線形探索なのをどうにかする
const jobs = await this.queueService.postScheduledNoteQueue.getJobs(['delayed', 'waiting', 'active']);
for (const job of jobs) {
if (job.data.noteDraftId === draftId) {
await job.remove();
}
}
}
}

View File

@@ -202,7 +202,7 @@ export class NotificationService implements OnApplicationShutdown {
}
// TODO
//const locales = await import('../../../../locales/index.js');
//const locales = await import('i18n');
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
@@ -271,7 +271,7 @@ export class NotificationService implements OnApplicationShutdown {
let untilTime = untilId ? this.toXListId(untilId) : null;
let notifications: MiNotification[];
for (;;) {
for (; ;) {
let notificationsRes: [id: string, fields: string[]][];
// sinceidのみの場合は古い順、そうでない場合は新しい順。 QueryService.makePaginationQueryも参照

View File

@@ -0,0 +1,223 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, In, Not } from 'typeorm';
import { DI } from '@/di-symbols.js';
import {
type NotesRepository,
MiPage,
type PagesRepository,
MiDriveFile,
type UsersRepository,
MiNote,
} from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import { IdService } from '@/core/IdService.js';
import type { MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
export interface PageBody {
title: string;
name: string;
summary: string | null;
content: Array<Record<string, any>>;
variables: Array<Record<string, any>>;
script: string;
eyeCatchingImage?: MiDriveFile | null;
font: 'serif' | 'sans-serif';
alignCenter: boolean;
hideTitleWhenPinned: boolean;
}
@Injectable()
export class PageService {
constructor(
@Inject(DI.db)
private db: DataSource,
@Inject(DI.pagesRepository)
private pagesRepository: PagesRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
private moderationLogService: ModerationLogService,
private idService: IdService,
) {
}
@bindThis
public async create(
me: MiUser,
body: PageBody,
): Promise<MiPage> {
await this.pagesRepository.findBy({
userId: me.id,
name: body.name,
}).then(result => {
if (result.length > 0) {
throw new IdentifiableError('1a79e38e-3d83-4423-845b-a9d83ff93b61');
}
});
const page = await this.pagesRepository.insertOne(new MiPage({
id: this.idService.gen(),
updatedAt: new Date(),
title: body.title,
name: body.name,
summary: body.summary,
content: body.content,
variables: body.variables,
script: body.script,
eyeCatchingImageId: body.eyeCatchingImage ? body.eyeCatchingImage.id : null,
userId: me.id,
visibility: 'public',
alignCenter: body.alignCenter,
hideTitleWhenPinned: body.hideTitleWhenPinned,
font: body.font,
}));
const referencedNotes = this.collectReferencedNotes(page.content);
if (referencedNotes.length > 0) {
await this.notesRepository.increment({ id: In(referencedNotes) }, 'pageCount', 1);
}
return page;
}
@bindThis
public async update(
me: MiUser,
pageId: MiPage['id'],
body: Partial<PageBody>,
): Promise<void> {
await this.db.transaction(async (transaction) => {
const page = await transaction.findOne(MiPage, {
where: {
id: pageId,
},
lock: { mode: 'for_no_key_update' },
});
if (page == null) {
throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
}
if (page.userId !== me.id) {
throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
}
if (body.name != null) {
await transaction.findBy(MiPage, {
id: Not(pageId),
userId: me.id,
name: body.name,
}).then(result => {
if (result.length > 0) {
throw new IdentifiableError('d05bfe24-24b6-4ea2-a3ec-87cc9bf4daa4');
}
});
}
await transaction.update(MiPage, page.id, {
updatedAt: new Date(),
title: body.title,
name: body.name,
summary: body.summary === undefined ? page.summary : body.summary,
content: body.content,
variables: body.variables,
script: body.script,
alignCenter: body.alignCenter,
hideTitleWhenPinned: body.hideTitleWhenPinned,
font: body.font,
eyeCatchingImageId: body.eyeCatchingImage === undefined ? undefined : (body.eyeCatchingImage?.id ?? null),
});
console.log('page.content', page.content);
if (body.content != null) {
const beforeReferencedNotes = this.collectReferencedNotes(page.content);
const afterReferencedNotes = this.collectReferencedNotes(body.content);
const removedNotes = beforeReferencedNotes.filter(noteId => !afterReferencedNotes.includes(noteId));
const addedNotes = afterReferencedNotes.filter(noteId => !beforeReferencedNotes.includes(noteId));
if (removedNotes.length > 0) {
await transaction.decrement(MiNote, { id: In(removedNotes) }, 'pageCount', 1);
}
if (addedNotes.length > 0) {
await transaction.increment(MiNote, { id: In(addedNotes) }, 'pageCount', 1);
}
}
});
}
@bindThis
public async delete(me: MiUser, pageId: MiPage['id']): Promise<void> {
await this.db.transaction(async (transaction) => {
const page = await transaction.findOne(MiPage, {
where: {
id: pageId,
},
lock: { mode: 'pessimistic_write' }, // same lock level as DELETE
});
if (page == null) {
throw new IdentifiableError('66aefd3c-fdb2-4a71-85ae-cc18bea85d3f');
}
if (!await this.roleService.isModerator(me) && page.userId !== me.id) {
throw new IdentifiableError('d0017699-8256-46f1-aed4-bc03bed73616');
}
await transaction.delete(MiPage, page.id);
if (page.userId !== me.id) {
const user = await this.usersRepository.findOneByOrFail({ id: page.userId });
this.moderationLogService.log(me, 'deletePage', {
pageId: page.id,
pageUserId: page.userId,
pageUserUsername: user.username,
page,
});
}
const referencedNotes = this.collectReferencedNotes(page.content);
if (referencedNotes.length > 0) {
await transaction.decrement(MiNote, { id: In(referencedNotes) }, 'pageCount', 1);
}
});
}
collectReferencedNotes(content: MiPage['content']): string[] {
const referencingNotes = new Set<string>();
const recursiveCollect = (content: unknown[]) => {
for (const contentElement of content) {
if (typeof contentElement === 'object'
&& contentElement !== null
&& 'type' in contentElement) {
if (contentElement.type === 'note'
&& 'note' in contentElement
&& typeof contentElement.note === 'string') {
referencingNotes.add(contentElement.note);
}
if (contentElement.type === 'section'
&& 'children' in contentElement
&& Array.isArray(contentElement.children)) {
recursiveCollect(contentElement.children);
}
}
}
};
recursiveCollect(content);
return [...referencingNotes];
}
}

View File

@@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, MiMeta } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm';
@@ -36,40 +36,92 @@ export class QueryService {
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
@Inject(DI.meta)
private meta: MiMeta,
private idService: IdService,
) {
}
public makePaginationQuery<T extends ObjectLiteral>(q: SelectQueryBuilder<T>, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder<T> {
public makePaginationQuery<T extends ObjectLiteral>(
q: SelectQueryBuilder<T>,
sinceId?: string | null,
untilId?: string | null,
sinceDate?: number | null,
untilDate?: number | null,
targetColumn = 'id',
): SelectQueryBuilder<T> {
if (sinceId && untilId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceId) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
q.orderBy(`${q.alias}.id`, 'ASC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
} else if (untilId) {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceDate && untilDate) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceDate) {
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.orderBy(`${q.alias}.id`, 'ASC');
q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
} else if (untilDate) {
q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.id`, 'DESC');
q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else {
q.orderBy(`${q.alias}.id`, 'DESC');
q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
}
return q;
}
/**
* ミュートやブロックのようにすべてのタイムラインで共通に使用するフィルターを定義します。
*
* 特別な事情がない限り、各タイムラインはこの関数を呼び出してフィルターを適用してください。
*
* Notes for future maintainers:
* 1) この関数で生成するクエリと同等の処理が FanoutTimelineEndpointService にあります。
* この関数を変更した場合、FanoutTimelineEndpointService の方も変更する必要があります。
* 2) 以下のエンドポイントでは特別な事情があるため queryService のそれぞれの関数を呼び出しています。
* この関数を変更した場合、以下のエンドポイントの方も変更する必要があることがあります。
* - packages/backend/src/server/api/endpoints/clips/notes.ts
*/
@bindThis
public generateBaseNoteFilteringQuery(
query: SelectQueryBuilder<any>,
me: { id: MiUser['id'] } | null,
{
excludeUserFromMute,
excludeAuthor,
}: {
excludeUserFromMute?: MiUser['id'],
excludeAuthor?: boolean,
} = {},
): void {
this.generateBlockedHostQueryForNote(query, excludeAuthor);
this.generateSuspendedUserQueryForNote(query, excludeAuthor);
if (me) {
this.generateMutedUserQueryForNotes(query, me, { excludeUserFromMute });
this.generateBlockedUserQueryForNotes(query, me);
this.generateMutedUserQueryForNotes(query, me, { noteColumn: 'renote', excludeUserFromMute });
this.generateBlockedUserQueryForNotes(query, me, { noteColumn: 'renote' });
}
}
// ここでいうBlockedは被Blockedの意
@bindThis
public generateBlockedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
public generateBlockedUserQueryForNotes(
q: SelectQueryBuilder<any>,
me: { id: MiUser['id'] },
{
noteColumn = 'note',
}: {
noteColumn?: string,
} = {},
): void {
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
.select('blocking.blockerId')
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
@@ -78,16 +130,20 @@ export class QueryService {
// 投稿の返信先の作者にブロックされていない かつ
// 投稿の引用元の作者にブロックされていない
q
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
.where(`${noteColumn}.userId IS NULL`)
.orWhere(`${noteColumn}.userId NOT IN (${ blockingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
.where(`${noteColumn}.replyUserId IS NULL`)
.orWhere(`${noteColumn}.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where(`${noteColumn}.renoteUserId IS NULL`)
.orWhere(`${noteColumn}.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
}));
q.setParameters(blockingQuery.getParameters());
@@ -127,13 +183,23 @@ export class QueryService {
}
@bindThis
public generateMutedUserQueryForNotes(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }, exclude?: { id: MiUser['id'] }): void {
public generateMutedUserQueryForNotes(
q: SelectQueryBuilder<any>,
me: { id: MiUser['id'] },
{
excludeUserFromMute,
noteColumn = 'note',
}: {
excludeUserFromMute?: MiUser['id'],
noteColumn?: string,
} = {},
): void {
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
if (exclude) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
if (excludeUserFromMute) {
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: excludeUserFromMute });
}
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
@@ -144,32 +210,36 @@ export class QueryService {
// 投稿の返信先の作者をミュートしていない かつ
// 投稿の引用元の作者をミュートしていない
q
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserId IS NULL')
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
.where(`${noteColumn}.userId IS NULL`)
.orWhere(`${noteColumn}.userId NOT IN (${ mutingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserId IS NULL')
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
.where(`${noteColumn}.replyUserId IS NULL`)
.orWhere(`${noteColumn}.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
.andWhere(new Brackets(qb => {
qb
.where(`${noteColumn}.renoteUserId IS NULL`)
.orWhere(`${noteColumn}.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
}))
// mute instances
.andWhere(new Brackets(qb => {
qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
.andWhere(`${noteColumn}.userHost IS NULL`)
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.userHost)`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.replyUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
.where(`${noteColumn}.replyUserHost IS NULL`)
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.replyUserHost)`);
}))
.andWhere(new Brackets(qb => {
qb
.where('note.renoteUserHost IS NULL')
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
.where(`${noteColumn}.renoteUserHost IS NULL`)
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? ${noteColumn}.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
@@ -251,4 +321,59 @@ export class QueryService {
q.setParameters(mutingQuery.getParameters());
}
@bindThis
public generateBlockedHostQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
let nonBlockedHostQuery: (part: string) => string;
if (this.meta.blockedHosts.length === 0) {
nonBlockedHostQuery = () => '1=1';
} else {
nonBlockedHostQuery = (match: string) => `${match} NOT ILIKE ALL(ARRAY[:...blocked])`;
q.setParameters({ blocked: this.meta.blockedHosts.flatMap(x => [x, `%.${x}`]) });
}
if (excludeAuthor) {
const instanceSuspension = (user: string) => new Brackets(qb => qb
.where(`note.${user}Id IS NULL`) // no corresponding user
.orWhere(`note.userId = note.${user}Id`)
.orWhere(`note.${user}Host IS NULL`) // local
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
q
.andWhere(instanceSuspension('replyUser'))
.andWhere(instanceSuspension('renoteUser'));
} else {
const instanceSuspension = (user: string) => new Brackets(qb => qb
.where(`note.${user}Id IS NULL`) // no corresponding user
.orWhere(`note.${user}Host IS NULL`) // local
.orWhere(nonBlockedHostQuery(`note.${user}Host`)));
q
.andWhere(instanceSuspension('user'))
.andWhere(instanceSuspension('replyUser'))
.andWhere(instanceSuspension('renoteUser'));
}
}
// Requirements: user replyUser renoteUser must be joined
@bindThis
public generateSuspendedUserQueryForNote(q: SelectQueryBuilder<any>, excludeAuthor?: boolean): void {
if (excludeAuthor) {
const brakets = (user: string) => new Brackets(qb => qb
.where(`${user}.id IS NULL`) // そもそもreplyやrenoteではない、もしくはleftjoinなどでuserが存在しなかった場合を考慮
.orWhere(`user.id = ${user}.id`)
.orWhere(`${user}.isSuspended = FALSE`));
q
.andWhere(brakets('replyUser'))
.andWhere(brakets('renoteUser'));
} else {
const brakets = (user: string) => new Brackets(qb => qb
.where(`${user}.id IS NULL`) // そもそもreplyやrenoteではない、もしくはleftjoinなどでuserが存在しなかった場合を考慮
.orWhere(`${user}.isSuspended = FALSE`));
q
.andWhere('user.isSuspended = FALSE')
.andWhere(brakets('replyUser'))
.andWhere(brakets('renoteUser'));
}
}
}

View File

@@ -16,11 +16,13 @@ import {
RelationshipJobData,
UserWebhookDeliverJobData,
SystemWebhookDeliverJobData,
PostScheduledNoteJobData,
} from '../queue/types.js';
import type { Provider } from '@nestjs/common';
export type SystemQueue = Bull.Queue<Record<string, unknown>>;
export type EndedPollNotificationQueue = Bull.Queue<EndedPollNotificationJobData>;
export type PostScheduledNoteQueue = Bull.Queue<PostScheduledNoteJobData>;
export type DeliverQueue = Bull.Queue<DeliverJobData>;
export type InboxQueue = Bull.Queue<InboxJobData>;
export type DbQueue = Bull.Queue;
@@ -41,6 +43,12 @@ const $endedPollNotification: Provider = {
inject: [DI.config],
};
const $postScheduledNote: Provider = {
provide: 'queue:postScheduledNote',
useFactory: (config: Config) => new Bull.Queue(QUEUE.POST_SCHEDULED_NOTE, baseQueueOptions(config, QUEUE.POST_SCHEDULED_NOTE)),
inject: [DI.config],
};
const $deliver: Provider = {
provide: 'queue:deliver',
useFactory: (config: Config) => new Bull.Queue(QUEUE.DELIVER, baseQueueOptions(config, QUEUE.DELIVER)),
@@ -89,6 +97,7 @@ const $systemWebhookDeliver: Provider = {
providers: [
$system,
$endedPollNotification,
$postScheduledNote,
$deliver,
$inbox,
$db,
@@ -100,6 +109,7 @@ const $systemWebhookDeliver: Provider = {
exports: [
$system,
$endedPollNotification,
$postScheduledNote,
$deliver,
$inbox,
$db,
@@ -113,6 +123,7 @@ export class QueueModule implements OnApplicationShutdown {
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@@ -129,6 +140,7 @@ export class QueueModule implements OnApplicationShutdown {
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
this.postScheduledNoteQueue.close(),
this.deliverQueue.close(),
this.inboxQueue.close(),
this.dbQueue.close(),

View File

@@ -6,7 +6,6 @@
import { randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { MetricsTime, type JobType } from 'bullmq';
import { parse as parseRedisInfo } from 'redis-info';
import type { IActivity } from '@/core/activitypub/type.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiWebhook, WebhookEventTypes } from '@/models/Webhook.js';
@@ -17,6 +16,7 @@ import { bindThis } from '@/decorators.js';
import type { Antenna } from '@/server/api/endpoints/i/import-antennas.js';
import { ApRequestCreator } from '@/core/activitypub/ApRequestService.js';
import { type SystemWebhookPayload } from '@/core/SystemWebhookService.js';
import type { Packed } from '@/misc/json-schema.js';
import { type UserWebhookPayload } from './UserWebhookService.js';
import type {
DbJobData,
@@ -30,6 +30,7 @@ import type {
DbQueue,
DeliverQueue,
EndedPollNotificationQueue,
PostScheduledNoteQueue,
InboxQueue,
ObjectStorageQueue,
RelationshipQueue,
@@ -43,6 +44,7 @@ import type * as Bull from 'bullmq';
export const QUEUE_TYPES = [
'system',
'endedPollNotification',
'postScheduledNote',
'deliver',
'inbox',
'db',
@@ -52,6 +54,50 @@ export const QUEUE_TYPES = [
'systemWebhookDeliver',
] as const;
const REPEATABLE_SYSTEM_JOB_DEF = [{
name: 'tickCharts',
pattern: '55 * * * *',
}, {
name: 'resyncCharts',
pattern: '0 0 * * *',
}, {
name: 'cleanCharts',
pattern: '0 0 * * *',
}, {
name: 'aggregateRetention',
pattern: '0 0 * * *',
}, {
name: 'clean',
pattern: '0 0 * * *',
}, {
name: 'checkExpiredMutings',
pattern: '*/5 * * * *',
}, {
name: 'bakeBufferedReactions',
pattern: '0 0 * * *',
}, {
name: 'checkModeratorsActivity',
// 毎時30分に起動
pattern: '30 * * * *',
}, {
name: 'cleanRemoteNotes',
// 毎日午前4時に起動(最も人の少ない時間帯)
pattern: '0 4 * * *',
}];
function parseRedisInfo(infoText: string): Record<string, string> {
const fields = infoText
.split('\n')
.filter(line => line.length > 0 && !line.startsWith('#'))
.map(line => line.trim().split(':'));
const result: Record<string, string> = {};
for (const [key, value] of fields) {
result[key] = value;
}
return result;
}
@Injectable()
export class QueueService {
constructor(
@@ -60,6 +106,7 @@ export class QueueService {
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@Inject('queue:postScheduledNote') public postScheduledNoteQueue: PostScheduledNoteQueue,
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
@Inject('queue:inbox') public inboxQueue: InboxQueue,
@Inject('queue:db') public dbQueue: DbQueue,
@@ -68,61 +115,31 @@ export class QueueService {
@Inject('queue:userWebhookDeliver') public userWebhookDeliverQueue: UserWebhookDeliverQueue,
@Inject('queue:systemWebhookDeliver') public systemWebhookDeliverQueue: SystemWebhookDeliverQueue,
) {
this.systemQueue.add('tickCharts', {
}, {
repeat: { pattern: '55 * * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
for (const def of REPEATABLE_SYSTEM_JOB_DEF) {
this.systemQueue.upsertJobScheduler(def.name, {
pattern: def.pattern,
immediately: false,
}, {
name: def.name,
opts: {
// 期限ではなくcountで設定したいが、ジョブごとではなくキュー全体でカウントされるため、高頻度で実行されるジョブによって低頻度で実行されるジョブのログが消えることになる
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
},
removeOnFail: {
age: 3600 * 24 * 7, // keep up to 7 days
},
},
});
}
this.systemQueue.add('resyncCharts', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('cleanCharts', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('aggregateRetention', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('clean', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('checkExpiredMutings', {
}, {
repeat: { pattern: '*/5 * * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('bakeBufferedReactions', {
}, {
repeat: { pattern: '0 0 * * *' },
removeOnComplete: 10,
removeOnFail: 30,
});
this.systemQueue.add('checkModeratorsActivity', {
}, {
// 毎時30分に起動
repeat: { pattern: '30 * * * *' },
removeOnComplete: 10,
removeOnFail: 30,
// 古いバージョンで作成され現在使われなくなったrepeatableジョブをクリーンアップ
this.systemQueue.getJobSchedulers().then(schedulers => {
for (const scheduler of schedulers) {
if (!REPEATABLE_SYSTEM_JOB_DEF.some(def => def.name === scheduler.key)) {
this.systemQueue.removeJobScheduler(scheduler.key);
}
}
});
}
@@ -715,6 +732,7 @@ export class QueueService {
switch (type) {
case 'system': return this.systemQueue;
case 'endedPollNotification': return this.endedPollNotificationQueue;
case 'postScheduledNote': return this.postScheduledNoteQueue;
case 'deliver': return this.deliverQueue;
case 'inbox': return this.inboxQueue;
case 'db': return this.dbQueue;
@@ -754,8 +772,8 @@ export class QueueService {
@bindThis
public async queueRetryJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
const job: Bull.Job | null = await queue.getJob(jobId);
if (job) {
const job = await queue.getJob(jobId);
if (job != null) {
if (job.finishedOn != null) {
await job.retry();
} else {
@@ -767,20 +785,20 @@ export class QueueService {
@bindThis
public async queueRemoveJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
const job: Bull.Job | null = await queue.getJob(jobId);
if (job) {
const job = await queue.getJob(jobId);
if (job != null) {
await job.remove();
}
}
@bindThis
private packJobData(job: Bull.Job) {
private packJobData(job: Bull.Job): Packed<'QueueJob'> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const stacktrace = job.stacktrace ? job.stacktrace.filter(Boolean) : [];
stacktrace.reverse();
return {
id: job.id,
id: job.id!,
name: job.name,
data: job.data,
opts: job.opts,
@@ -801,14 +819,21 @@ export class QueueService {
@bindThis
public async queueGetJob(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
const job: Bull.Job | null = await queue.getJob(jobId);
if (job) {
const job = await queue.getJob(jobId);
if (job != null) {
return this.packJobData(job);
} else {
throw new Error(`Job not found: ${jobId}`);
}
}
@bindThis
public async queueGetJobLogs(queueType: typeof QUEUE_TYPES[number], jobId: string) {
const queue = this.getQueue(queueType);
const result = await queue.getJobLogs(jobId);
return result.logs;
}
@bindThis
public async queueGetJobs(queueType: typeof QUEUE_TYPES[number], jobTypes: JobType[], search?: string) {
const RETURN_LIMIT = 100;

View File

@@ -31,6 +31,7 @@ import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { NotificationService } from '@/core/NotificationService.js';
import type { OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
// misskey-js の rolePolicies と同期すべし
export type RolePolicies = {
gtlAvailable: boolean;
ltlAvailable: boolean;
@@ -43,9 +44,11 @@ export type RolePolicies = {
canManageCustomEmojis: boolean;
canManageAvatarDecorations: boolean;
canSearchNotes: boolean;
canSearchUsers: boolean;
canUseTranslator: boolean;
canHideAds: boolean;
driveCapacityMb: number;
maxFileSizeMb: number;
alwaysMarkNsfw: boolean;
canUpdateBioMedia: boolean;
pinLimit: number;
@@ -64,6 +67,10 @@ export type RolePolicies = {
canImportMuting: boolean;
canImportUserLists: boolean;
chatAvailability: 'available' | 'readonly' | 'unavailable';
uploadableFileTypes: string[];
noteDraftLimit: number;
scheduledNoteLimit: number;
watermarkAvailable: boolean;
};
export const DEFAULT_POLICIES: RolePolicies = {
@@ -78,9 +85,11 @@ export const DEFAULT_POLICIES: RolePolicies = {
canManageCustomEmojis: false,
canManageAvatarDecorations: false,
canSearchNotes: false,
canSearchUsers: true,
canUseTranslator: true,
canHideAds: false,
driveCapacityMb: 100,
maxFileSizeMb: 30,
alwaysMarkNsfw: false,
canUpdateBioMedia: true,
pinLimit: 5,
@@ -93,12 +102,22 @@ export const DEFAULT_POLICIES: RolePolicies = {
userEachUserListsLimit: 50,
rateLimitFactor: 1,
avatarDecorationLimit: 1,
canImportAntennas: true,
canImportBlocking: true,
canImportFollowing: true,
canImportMuting: true,
canImportUserLists: true,
canImportAntennas: false,
canImportBlocking: false,
canImportFollowing: false,
canImportMuting: false,
canImportUserLists: false,
chatAvailability: 'available',
uploadableFileTypes: [
'text/*',
'application/json',
'image/*',
'video/*',
'audio/*',
],
noteDraftLimit: 10,
scheduledNoteLimit: 1,
watermarkAvailable: true,
};
@Injectable()
@@ -295,7 +314,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
default:
return false;
}
} catch (err) {
} catch (_) {
// TODO: log error
return false;
}
@@ -388,9 +407,11 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canManageCustomEmojis: calc('canManageCustomEmojis', vs => vs.some(v => v === true)),
canManageAvatarDecorations: calc('canManageAvatarDecorations', vs => vs.some(v => v === true)),
canSearchNotes: calc('canSearchNotes', vs => vs.some(v => v === true)),
canSearchUsers: calc('canSearchUsers', vs => vs.some(v => v === true)),
canUseTranslator: calc('canUseTranslator', vs => vs.some(v => v === true)),
canHideAds: calc('canHideAds', vs => vs.some(v => v === true)),
driveCapacityMb: calc('driveCapacityMb', vs => Math.max(...vs)),
maxFileSizeMb: calc('maxFileSizeMb', vs => Math.max(...vs)),
alwaysMarkNsfw: calc('alwaysMarkNsfw', vs => vs.some(v => v === true)),
canUpdateBioMedia: calc('canUpdateBioMedia', vs => vs.some(v => v === true)),
pinLimit: calc('pinLimit', vs => Math.max(...vs)),
@@ -409,6 +430,19 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
chatAvailability: calc('chatAvailability', aggregateChatAvailability),
uploadableFileTypes: calc('uploadableFileTypes', vs => {
const set = new Set<string>();
for (const v of vs) {
for (const type of v) {
if (type.trim() === '') continue;
set.add(type.trim());
}
}
return [...set];
}),
noteDraftLimit: calc('noteDraftLimit', vs => Math.max(...vs)),
scheduledNoteLimit: calc('scheduledNoteLimit', vs => Math.max(...vs)),
watermarkAvailable: calc('watermarkAvailable', vs => vs.some(v => v === true)),
};
}

View File

@@ -190,8 +190,7 @@ export class SearchService {
return this.searchNoteByMeiliSearch(q, me, opts, pagination);
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const typeCheck: never = this.provider;
const _: never = this.provider;
return [];
}
}
@@ -227,15 +226,14 @@ export class SearchService {
if (opts.host) {
if (opts.host === '.') {
query.andWhere('user.host IS NULL');
query.andWhere('note.userHost IS NULL');
} else {
query.andWhere('user.host = :host', { host: opts.host });
query.andWhere('note.userHost = :host', { host: opts.host });
}
}
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateBaseNoteFilteringQuery(query, me);
return query.limit(pagination.limit).getMany();
}
@@ -295,9 +293,20 @@ export class SearchService {
this.cacheService.userBlockedCache.fetch(me.id),
])
: [new Set<string>(), new Set<string>()];
const notes = (await this.notesRepository.findBy({
id: In(res.hits.map(x => x.id)),
})).filter(note => {
const query = this.notesRepository.createQueryBuilder('note')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) });
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
if (me && isUserRelated(note, userIdsWhoMeMuting)) return false;
return true;

View File

@@ -93,6 +93,11 @@ export class SignupService {
if (isPreserved) {
throw new Error('USED_USERNAME');
}
const hasProhibitedWords = this.utilityService.isKeyWordIncluded(username.toLowerCase(), this.meta.prohibitedWordsForNameOfUser);
if (hasProhibitedWords) {
throw new Error('USED_USERNAME');
}
}
const keyPair = await new Promise<string[]>((res, rej) =>

View File

@@ -91,7 +91,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
}
@bindThis
public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
public async addMember(target: MiUser, list: MiUserList, me: MiUser, options: { withReplies?: boolean } = {}) {
const currentCount = await this.userListMembershipsRepository.countBy({
userListId: list.id,
});
@@ -104,6 +104,7 @@ export class UserListService implements OnApplicationShutdown, OnModuleInit {
userId: target.id,
userListId: list.id,
userListUserId: list.userId,
withReplies: options.withReplies ?? false,
} as MiUserListMembership);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });

View File

@@ -49,8 +49,8 @@ export class UserSuspendService {
});
(async () => {
await this.postSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {});
await this.postSuspend(user).catch(_ => {});
await this.unFollowAll(user).catch(_ => {});
})();
}
@@ -67,7 +67,7 @@ export class UserSuspendService {
});
(async () => {
await this.postUnsuspend(user).catch(e => {});
await this.postUnsuspend(user).catch(_ => {});
})();
}

View File

@@ -6,10 +6,12 @@
import { URL, domainToASCII } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2';
import semver from 'semver';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiMeta } from '@/models/Meta.js';
import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
import { MiInstance } from '@/models/Instance.js';
@Injectable()
export class UtilityService {
@@ -96,7 +98,7 @@ export class UtilityService {
try {
// TODO: RE2インスタンスをキャッシュ
return new RE2(regexp[1], regexp[2]).test(text);
} catch (err) {
} catch (_) {
// This should never happen due to input sanitisation.
return false;
}
@@ -131,6 +133,7 @@ export class UtilityService {
@bindThis
public isFederationAllowedHost(host: string): boolean {
if (this.isSelfHost(host)) return true;
if (this.meta.federation === 'none') return false;
if (this.meta.federation === 'specified' && !this.meta.federationHosts.some(x => `.${host.toLowerCase()}`.endsWith(`.${x}`))) return false;
if (this.isBlockedHost(this.meta.blockedHosts, host)) return false;
@@ -143,4 +146,20 @@ export class UtilityService {
const host = this.extractDbHost(uri);
return this.isFederationAllowedHost(host);
}
@bindThis
public isDeliverSuspendedSoftware(software: Pick<MiInstance, 'softwareName' | 'softwareVersion'>): SoftwareSuspension | undefined {
if (software.softwareName == null) return undefined;
if (software.softwareVersion == null) {
// software version is null; suspend iff versionRange is *
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& x.versionRange.trim() === '*');
} else {
const softwareVersion = software.softwareVersion;
return this.meta.deliverSuspendedSoftware.find(x =>
x.software === software.softwareName
&& semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
}
}
}

View File

@@ -66,7 +66,6 @@ export class WebAuthnService {
userID: isoUint8Array.fromUTF8String(userId),
userName: userName,
userDisplayName: userDisplayName,
attestationType: 'indirect',
excludeCredentials: keys.map(key => (<{ id: string; transports?: AuthenticatorTransportFuture[]; }>{
id: key.id,
transports: key.transports ?? undefined,

View File

@@ -85,6 +85,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
renoteCount: 10,
repliesCount: 5,
clippedCount: 0,
pageCount: 0,
reactions: {},
visibility: 'public',
uri: null,
@@ -105,6 +106,7 @@ function generateDummyNote(override?: Partial<MiNote>): MiNote {
replyUserHost: null,
renoteUserId: null,
renoteUserHost: null,
renoteChannelId: null,
...override,
};
}
@@ -243,7 +245,6 @@ export class WebhookTestService {
case 'reaction':
return;
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
@@ -326,7 +327,6 @@ export class WebhookTestService {
break;
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _exhaustiveAssertion: never = params.type;
return;
}
@@ -411,7 +411,7 @@ export class WebhookTestService {
name: user.name,
username: user.username,
host: user.host,
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
avatarUrl: (user.avatarId == null ? null : user.avatarUrl) ?? '',
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({
id: it.id,

View File

@@ -5,6 +5,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { UserFollowingService } from '@/core/UserFollowingService.js';
@@ -14,8 +15,8 @@ import { NotePiningService } from '@/core/NotePiningService.js';
import { UserBlockingService } from '@/core/UserBlockingService.js';
import { NoteDeleteService } from '@/core/NoteDeleteService.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import { acquireApObjectLock } from '@/misc/distributed-lock.js';
import { concat, toArray, toSingle, unique } from '@/misc/prelude/array.js';
import { AppLockService } from '@/core/AppLockService.js';
import type Logger from '@/logger.js';
import { IdService } from '@/core/IdService.js';
import { StatusError } from '@/misc/status-error.js';
@@ -48,8 +49,8 @@ export class ApInboxService {
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -76,7 +77,6 @@ export class ApInboxService {
private userBlockingService: UserBlockingService,
private noteCreateService: NoteCreateService,
private noteDeleteService: NoteDeleteService,
private appLockService: AppLockService,
private apResolverService: ApResolverService,
private apDbResolverService: ApDbResolverService,
private apLoggerService: ApLoggerService,
@@ -95,7 +95,7 @@ export class ApInboxService {
if (isCollectionOrOrderedCollection(activity)) {
const results = [] as [string, string | void][];
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
if (items.length >= resolver.getRecursionLimit()) {
@@ -221,7 +221,7 @@ export class ApInboxService {
this.logger.info(`Accept: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(err => {
this.logger.error(`Resolution failed: ${err}`);
@@ -284,7 +284,7 @@ export class ApInboxService {
this.logger.info(`Announce: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
if (!activity.object) return 'skip: activity has no object property';
const targetUri = getApId(activity.object);
@@ -311,7 +311,7 @@ export class ApInboxService {
// アナウンス先が許可されているかチェック
if (!this.utilityService.isFederationAllowedUri(uri)) return;
const unlock = await this.appLockService.getApLock(uri);
const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
// 既に同じURIを持つものが登録されていないかチェック
@@ -406,7 +406,7 @@ export class ApInboxService {
}
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -438,7 +438,7 @@ export class ApInboxService {
}
}
const unlock = await this.appLockService.getApLock(uri);
const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
const exist = await this.apNoteService.fetchNote(note);
@@ -522,7 +522,7 @@ export class ApInboxService {
private async deleteNote(actor: MiRemoteUser, uri: string): Promise<string> {
this.logger.info(`Deleting the Note: ${uri}`);
const unlock = await this.appLockService.getApLock(uri);
const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
const note = await this.apDbResolverService.getNoteFromApId(uri);
@@ -575,7 +575,7 @@ export class ApInboxService {
this.logger.info(`Reject: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -642,7 +642,7 @@ export class ApInboxService {
this.logger.info(`Undo: ${uri}`);
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);
@@ -774,7 +774,7 @@ export class ApInboxService {
this.logger.debug('Update');
// eslint-disable-next-line no-param-reassign
resolver ??= this.apResolverService.createResolver();
resolver ??= await this.apResolverService.createResolver();
const object = await resolver.resolve(activity.object).catch(e => {
this.logger.error(`Resolution failed: ${e}`);

View File

@@ -5,7 +5,7 @@
import { Injectable } from '@nestjs/common';
import * as mfm from 'mfm-js';
import { MfmService, Appender } from '@/core/MfmService.js';
import { MfmService } from '@/core/MfmService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
import { extractApHashtagObjects } from './models/tag.js';
@@ -25,17 +25,17 @@ export class ApMfmService {
}
@bindThis
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, additionalAppender: Appender[] = []) {
public getNoteHtml(note: Pick<MiNote, 'text' | 'mentionedRemoteUsers'>, extraHtml: string | null = null) {
let noMisskeyContent = false;
const srcMfm = (note.text ?? '');
const parsed = mfm.parse(srcMfm);
if (!additionalAppender.length && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
if (extraHtml == null && parsed.every(n => ['text', 'unicodeEmoji', 'emojiCode', 'mention', 'hashtag', 'url'].includes(n.type))) {
noMisskeyContent = true;
}
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), additionalAppender);
const content = this.mfmService.toHtml(parsed, JSON.parse(note.mentionedRemoteUsers), extraHtml);
return {
content,

View File

@@ -19,7 +19,7 @@ import type { MiEmoji } from '@/models/Emoji.js';
import type { MiPoll } from '@/models/Poll.js';
import type { MiPollVote } from '@/models/PollVote.js';
import { UserKeypairService } from '@/core/UserKeypairService.js';
import { MfmService, type Appender } from '@/core/MfmService.js';
import { MfmService } from '@/core/MfmService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import type { MiUserKeypair } from '@/models/UserKeypair.js';
@@ -28,6 +28,7 @@ import { bindThis } from '@/decorators.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { IdService } from '@/core/IdService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { escapeHtml } from '@/misc/escape-html.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@@ -384,7 +385,7 @@ export class ApRendererService {
inReplyTo = null;
}
let quote;
let quote: string | undefined;
if (note.renoteId) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
@@ -430,29 +431,18 @@ export class ApRendererService {
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
}
const apAppend: Appender[] = [];
let extraHtml: string | null = null;
if (quote) {
if (quote != null) {
// Append quote link as `<br><br><span class="quote-inline">RE: <a href="...">...</a></span>`
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
// the class name `quote-inline` is used in non-misskey clients for styling quote notes.
// For compatibility, the span part should be kept as possible.
apAppend.push((doc, body) => {
body.appendChild(doc.createElement('br'));
body.appendChild(doc.createElement('br'));
const span = doc.createElement('span');
span.className = 'quote-inline';
span.appendChild(doc.createTextNode('RE: '));
const link = doc.createElement('a');
link.setAttribute('href', quote);
link.textContent = quote;
span.appendChild(link);
body.appendChild(span);
});
extraHtml = `<br><br><span class="quote-inline">RE: <a href="${escapeHtml(quote)}">${escapeHtml(quote)}</a></span>`;
}
const summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, apAppend);
const { content, noMisskeyContent } = this.apMfmService.getNoteHtml(note, extraHtml);
const emojis = await this.getEmojis(note.emojis);
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
@@ -525,7 +515,7 @@ export class ApRendererService {
const restPart = maybeUrl.slice(match[0].length);
return `<a href="${urlPartParsed.href}" rel="me nofollow noopener" target="_blank">${urlPart}</a>${restPart}`;
} catch (e) {
} catch (_) {
return maybeUrl;
}
};

View File

@@ -6,7 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { Window } from 'happy-dom';
import * as htmlParser from 'node-html-parser';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@@ -215,29 +215,9 @@ export class ApRequestService {
_followAlternate === true
) {
const html = await res.text();
const { window, happyDOM } = new Window({
settings: {
disableJavaScriptEvaluation: true,
disableJavaScriptFileLoading: true,
disableCSSFileLoading: true,
disableComputedStyleRendering: true,
handleDisabledFileLoadingAsSuccess: true,
navigation: {
disableMainFrameNavigation: true,
disableChildFrameNavigation: true,
disableChildPageNavigation: true,
disableFallbackToSetURL: true,
},
timer: {
maxTimeout: 0,
maxIntervalTime: 0,
maxIntervalIterations: 0,
},
},
});
const document = window.document;
try {
document.documentElement.innerHTML = html;
const document = htmlParser.parse(html);
const alternate = document.querySelector('head > link[rel="alternate"][type="application/activity+json"]');
if (alternate) {
@@ -246,10 +226,8 @@ export class ApRequestService {
return await this.signedGet(href, user, allowSoftfail, false);
}
}
} catch (e) {
} catch (_) {
// something went wrong parsing the HTML, ignore the whole thing
} finally {
happyDOM.close().catch(err => {});
}
}
//#endregion

View File

@@ -3,10 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Scope } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
import type {
FollowRequestsRepository,
MiMeta,
NoteReactionsRepository,
NotesRepository,
PollsRepository,
UsersRepository
} from '@/models/_.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { DI } from '@/di-symbols.js';
@@ -16,26 +23,43 @@ import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { ICollection, IObject, IOrderedCollection } from './type.js';
import { isCollectionOrOrderedCollection } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
import { ApRequestService } from './ApRequestService.js';
import { FetchAllowSoftFailMask } from './misc/check-against-url.js';
import type { IObject, ICollection, IOrderedCollection } from './type.js';
import { ModuleRef } from '@nestjs/core';
@Injectable({ scope: Scope.TRANSIENT })
export class Resolver {
private history: Set<string>;
private user?: MiLocalUser;
private logger: Logger;
private recursionLimit = 256;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
@@ -43,7 +67,6 @@ export class Resolver {
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private recursionLimit = 256,
) {
this.history = new Set();
this.logger = this.loggerService.getLogger('ap-resolve');
@@ -104,7 +127,7 @@ export class Resolver {
throw new IdentifiableError('09d79f9e-64f1-4316-9cfa-e75c4d091574', 'Instance is blocked');
}
if (this.config.signToActivityPubGet && !this.user) {
if (this.meta.signToActivityPubGet && !this.user) {
this.user = await this.systemAccountService.fetch('actor');
}
@@ -180,54 +203,12 @@ export class Resolver {
@Injectable()
export class ApResolverService {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository,
@Inject(DI.followRequestsRepository)
private followRequestsRepository: FollowRequestsRepository,
private utilityService: UtilityService,
private systemAccountService: SystemAccountService,
private apRequestService: ApRequestService,
private httpRequestService: HttpRequestService,
private apRendererService: ApRendererService,
private apDbResolverService: ApDbResolverService,
private loggerService: LoggerService,
private moduleRef: ModuleRef,
) {
}
@bindThis
public createResolver(): Resolver {
return new Resolver(
this.config,
this.meta,
this.usersRepository,
this.notesRepository,
this.pollsRepository,
this.noteReactionsRepository,
this.followRequestsRepository,
this.utilityService,
this.systemAccountService,
this.apRequestService,
this.httpRequestService,
this.apRendererService,
this.apDbResolverService,
this.loggerService,
);
public async createResolver(): Promise<Resolver> {
return await this.moduleRef.create(Resolver);
}
}

View File

@@ -46,7 +46,7 @@ export class ApImageService {
throw new Error('actor has been suspended');
}
const image = await this.apResolverService.createResolver().resolve(value);
const image = await (await this.apResolverService.createResolver()).resolve(value);
if (!isDocument(image)) return null;

View File

@@ -5,14 +5,15 @@
import { forwardRef, Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import * as Redis from 'ioredis';
import { DI } from '@/di-symbols.js';
import type { PollsRepository, EmojisRepository, MiMeta } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { MiRemoteUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import { acquireApObjectLock } from '@/misc/distributed-lock.js';
import { toArray, toSingle, unique } from '@/misc/prelude/array.js';
import type { MiEmoji } from '@/models/Emoji.js';
import { AppLockService } from '@/core/AppLockService.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import { NoteCreateService } from '@/core/NoteCreateService.js';
import type Logger from '@/logger.js';
@@ -48,6 +49,9 @@ export class ApNoteService {
@Inject(DI.meta)
private meta: MiMeta,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.pollsRepository)
private pollsRepository: PollsRepository,
@@ -67,7 +71,6 @@ export class ApNoteService {
private apMentionService: ApMentionService,
private apImageService: ApImageService,
private apQuestionService: ApQuestionService,
private appLockService: AppLockService,
private pollService: PollService,
private noteCreateService: NoteCreateService,
private apDbResolverService: ApDbResolverService,
@@ -125,7 +128,7 @@ export class ApNoteService {
@bindThis
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = await resolver.resolve(value);
@@ -354,7 +357,7 @@ export class ApNoteService {
throw new StatusError('blocked host', 451);
}
const unlock = await this.appLockService.getApLock(uri);
const unlock = await acquireApObjectLock(this.redisClient, uri);
try {
//#region このサーバーに既に登録されていたらそれを返す

View File

@@ -310,7 +310,7 @@ export class ApPersonService implements OnModuleInit {
}
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = await resolver.resolve(uri);
if (object.id == null) throw new Error('invalid object.id: ' + object.id);
@@ -500,7 +500,7 @@ export class ApPersonService implements OnModuleInit {
//#endregion
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const object = hint ?? await resolver.resolve(uri);
@@ -678,7 +678,7 @@ export class ApPersonService implements OnModuleInit {
// リモートサーバーからフェッチしてきて登録
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
return await this.createPerson(uri, resolver);
}
@@ -707,7 +707,7 @@ export class ApPersonService implements OnModuleInit {
this.logger.info(`Updating the featured: ${user.uri}`);
const _resolver = resolver ?? this.apResolverService.createResolver();
const _resolver = resolver ?? await this.apResolverService.createResolver();
// Resolve to (Ordered)Collection Object
const collection = await _resolver.resolveCollection(user.featured);

View File

@@ -45,7 +45,7 @@ export class ApQuestionService {
@bindThis
public async extractPollFromQuestion(source: string | IObject, resolver?: Resolver): Promise<IPoll> {
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const question = await resolver.resolve(source);
if (!isQuestion(question)) throw new Error('invalid type');
@@ -91,7 +91,7 @@ export class ApQuestionService {
// resolve new Question object
// eslint-disable-next-line no-param-reassign
if (resolver == null) resolver = this.apResolverService.createResolver();
if (resolver == null) resolver = await this.apResolverService.createResolver();
const question = await resolver.resolve(value);
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);

Some files were not shown because too many files have changed in this diff Show More