Merge branch Sharkey:develop into trackeropt
This commit is contained in:
commit
9dbd2a6bb4
292 changed files with 5783 additions and 3323 deletions
|
|
@ -1,12 +1,19 @@
|
|||
import tsParser from '@typescript-eslint/parser';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
import globals from 'globals';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
|
||||
export default [
|
||||
...sharedConfig,
|
||||
{
|
||||
ignores: ['**/node_modules', 'built', '@types/**/*', 'migration'],
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts', '**/*.tsx'],
|
||||
languageOptions: {
|
||||
|
|
|
|||
31
packages/backend/jest.js
Normal file
31
packages/backend/jest.js
Normal 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') ? ['--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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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"`);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,11 +3,25 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { isConcurrentIndexMigrationEnabled } from "./js/migration-config.js";
|
||||
|
||||
export class CompositeNoteIndex1745378064470 {
|
||||
name = 'CompositeNoteIndex1745378064470';
|
||||
transaction = isConcurrentIndexMigrationEnabled() ? false : undefined;
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`CREATE INDEX "IDX_724b311e6f883751f261ebe378" ON "note" ("userId", "id" DESC)`);
|
||||
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
|
||||
|
|
@ -15,7 +29,8 @@ export class CompositeNoteIndex1745378064470 {
|
|||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
const mayConcurrently = isConcurrentIndexMigrationEnabled() ? 'CONCURRENTLY' : '';
|
||||
await queryRunner.query(`DROP INDEX IF EXISTS "IDX_724b311e6f883751f261ebe378"`);
|
||||
await queryRunner.query(`CREATE INDEX "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
|
||||
await queryRunner.query(`CREATE INDEX ${mayConcurrently} "IDX_5b87d9d19127bd5d92026017a7" ON "note" ("userId")`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
/**
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class AddNoteThreadMutingIsPostMute1749523586531 {
|
||||
name = 'AddNoteThreadMutingIsPostMute1749523586531'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_ae7aab18a2641d3e5f25e0c4ea"`);
|
||||
await queryRunner.query(`ALTER TABLE "note_thread_muting" ADD "isPostMute" boolean NOT NULL DEFAULT false`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_01f7ab05099400012e9a7fd42b" ON "note_thread_muting" ("userId", "threadId", "isPostMute") `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_01f7ab05099400012e9a7fd42b"`);
|
||||
await queryRunner.query(`COMMENT ON COLUMN "note_thread_muting"."isPostMute" IS 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.'`);
|
||||
await queryRunner.query(`ALTER TABLE "note_thread_muting" DROP COLUMN "isPostMute"`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_ae7aab18a2641d3e5f25e0c4ea" ON "note_thread_muting" ("userId", "threadId") `);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: Lillychan and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class UserDescriptionText1750541176036 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE TEXT`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "description" TYPE character varying(2048)`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: dakkar and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export class RegistryUniqueConstraints1750591589187 {
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DELETE FROM "registry_item" WHERE "id" IN (
|
||||
SELECT t."id" FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY "userId","key","scope","domain" ORDER BY "updatedAt" DESC) rn
|
||||
FROM "registry_item"
|
||||
) t WHERE t.rn>1)`);
|
||||
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_d9c48d580287308f8c1f674946" ON "registry_item" ("userId", "key", "scope", "domain") NULLS NOT DISTINCT`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`DROP INDEX "public"."IDX_d9c48d580287308f8c1f674946"`);
|
||||
}
|
||||
}
|
||||
8
packages/backend/migration/js/migration-config.js
Normal file
8
packages/backend/migration/js/migration-config.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
export function isConcurrentIndexMigrationEnabled() {
|
||||
return process.env.MISSKEY_MIGRATION_CREATE_INDEX_CONCURRENTLY === '1';
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { DataSource } from 'typeorm';
|
||||
import { loadConfig } from './built/config.js';
|
||||
import { entities } from './built/postgres.js';
|
||||
import { isConcurrentIndexMigrationEnabled } from "./migration/js/migration-config.js";
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
|
|
@ -14,8 +15,9 @@ export default new DataSource({
|
|||
extra: {
|
||||
...config.db.extra,
|
||||
// migrations may be very slow, give them longer to run (that 10*1000 comes from postgres.ts)
|
||||
statement_timeout: (config.db.extra?.statement_timeout ?? 1000 * 10) * 10,
|
||||
statement_timeout: (config.db.extra?.statement_timeout ?? 1000 * 10) * 100,
|
||||
},
|
||||
entities: entities,
|
||||
migrations: ['migration/*.js'],
|
||||
migrationsTransactionMode: isConcurrentIndexMigrationEnabled() ? 'each' : 'all',
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@
|
|||
"typecheck": "pnpm --filter megalodon build && tsc --noEmit && tsc -p test --noEmit && tsc -p test-federation --noEmit",
|
||||
"eslint": "eslint --quiet \"{src,test-federation,test,js,@types}/**/*.{js,jsx,ts,tsx,vue}\" --cache",
|
||||
"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 node ./jest.js --forceExit --config jest.config.unit.cjs",
|
||||
"jest:e2e": "cross-env NODE_ENV=test node ./jest.js --forceExit --config jest.config.e2e.cjs",
|
||||
"jest:fed": "node ./jest.js --forceExit --config jest.config.fed.cjs",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.unit.cjs",
|
||||
"jest-and-coverage:e2e": "cross-env NODE_ENV=test node ./jest.js --coverage --forceExit --config jest.config.e2e.cjs",
|
||||
"jest-clear": "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",
|
||||
|
|
@ -80,7 +80,7 @@
|
|||
"@fastify/static": "8.1.1",
|
||||
"@fastify/view": "10.0.2",
|
||||
"@misskey-dev/sharp-read-bmp": "1.3.0",
|
||||
"@misskey-dev/summaly": "5.2.1",
|
||||
"@misskey-dev/summaly": "npm:@transfem-org/summaly@5.2.2",
|
||||
"@nestjs/common": "11.1.0",
|
||||
"@nestjs/core": "11.1.0",
|
||||
"@nestjs/testing": "11.1.0",
|
||||
|
|
@ -90,33 +90,30 @@
|
|||
"@simplewebauthn/server": "12.0.0",
|
||||
"@sinonjs/fake-timers": "11.3.1",
|
||||
"@smithy/node-http-handler": "2.5.0",
|
||||
"@swc/cli": "0.7.3",
|
||||
"@swc/core": "1.11.24",
|
||||
"@transfem-org/sfm-js": "0.24.6",
|
||||
"mfm-js": "npm:@transfem-org/sfm-js@0.24.8",
|
||||
"@twemoji/parser": "15.1.1",
|
||||
"accepts": "1.3.8",
|
||||
"ajv": "8.17.1",
|
||||
"archiver": "7.0.1",
|
||||
"argon2": "^0.40.1",
|
||||
"argon2": "0.43.0",
|
||||
"axios": "1.7.4",
|
||||
"async-mutex": "0.5.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"blurhash": "2.0.5",
|
||||
"body-parser": "1.20.3",
|
||||
"bullmq": "5.51.1",
|
||||
"cacheable-lookup": "7.0.0",
|
||||
"canvas": "^3.1.0",
|
||||
"canvas": "3.1.0",
|
||||
"cbor": "9.0.2",
|
||||
"chalk": "5.4.1",
|
||||
"chalk-template": "1.1.0",
|
||||
"cheerio": "1.0.0",
|
||||
"chokidar": "3.6.0",
|
||||
"cli-highlight": "2.1.11",
|
||||
"cli-highlight": "npm:@transfem-org/cli-highlight@2.1.12",
|
||||
"color-convert": "2.0.1",
|
||||
"content-disposition": "0.5.4",
|
||||
"date-fns": "2.30.0",
|
||||
"deep-email-validator": "0.1.21",
|
||||
"fast-xml-parser": "4.4.1",
|
||||
"dom-serializer": "2.0.0",
|
||||
"domhandler": "5.0.3",
|
||||
"domutils": "3.2.2",
|
||||
"fastify": "5.3.2",
|
||||
"fastify-raw-body": "5.0.0",
|
||||
"feed": "4.2.2",
|
||||
|
|
@ -125,10 +122,9 @@
|
|||
"form-data": "4.0.2",
|
||||
"glob": "11.0.0",
|
||||
"got": "14.4.7",
|
||||
"happy-dom": "16.8.1",
|
||||
"hpagent": "1.2.0",
|
||||
"htmlescape": "1.1.1",
|
||||
"http-link-header": "1.1.3",
|
||||
"htmlparser2": "9.1.0",
|
||||
"ioredis": "5.6.1",
|
||||
"ip-cidr": "4.0.2",
|
||||
"ipaddr.js": "2.2.0",
|
||||
|
|
@ -136,49 +132,40 @@
|
|||
"js-yaml": "4.1.0",
|
||||
"json5": "2.2.3",
|
||||
"jsonld": "8.3.3",
|
||||
"jsrsasign": "11.1.0",
|
||||
"juice": "11.0.1",
|
||||
"megalodon": "workspace:*",
|
||||
"meilisearch": "0.50.0",
|
||||
"microformats-parser": "2.0.2",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "workspace:*",
|
||||
"misskey-reversi": "workspace:*",
|
||||
"moment": "^2.30.1",
|
||||
"moment": "2.30.1",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"nanoid": "5.1.5",
|
||||
"nested-property": "4.0.0",
|
||||
"node-fetch": "3.3.2",
|
||||
"nodemailer": "6.10.1",
|
||||
"oauth": "0.10.2",
|
||||
"oauth2orize": "1.12.0",
|
||||
"oauth2orize-pkce": "0.1.2",
|
||||
"os-utils": "0.0.14",
|
||||
"otpauth": "9.4.0",
|
||||
"parse5": "7.3.0",
|
||||
"pg": "8.15.6",
|
||||
"pkce-challenge": "4.1.0",
|
||||
"probe-image-size": "7.2.3",
|
||||
"promise-limit": "2.7.0",
|
||||
"proxy-addr": "^2.0.7",
|
||||
"psl": "^1.13.0",
|
||||
"proxy-addr": "2.0.7",
|
||||
"psl": "1.15.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",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rename": "1.0.4",
|
||||
"rss-parser": "3.13.0",
|
||||
"rxjs": "7.8.2",
|
||||
"sanitize-html": "2.16.0",
|
||||
"secure-json-parse": "3.0.2",
|
||||
"sharp": "0.34.1",
|
||||
"semver": "7.7.1",
|
||||
"slacc": "0.0.10",
|
||||
"strict-event-emitter-types": "2.0.0",
|
||||
"stringz": "2.1.0",
|
||||
"systeminformation": "5.25.11",
|
||||
"tinycolor2": "1.6.0",
|
||||
"tmp": "0.2.3",
|
||||
|
|
@ -187,7 +174,7 @@
|
|||
"typeorm": "0.3.22",
|
||||
"typescript": "5.8.3",
|
||||
"ulid": "2.4.0",
|
||||
"uuid": "^9.0.1",
|
||||
"uuid": "11.1.0",
|
||||
"vary": "1.1.2",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.18.1",
|
||||
|
|
@ -198,16 +185,16 @@
|
|||
"@nestjs/platform-express": "11.1.0",
|
||||
"@sentry/vue": "9.14.0",
|
||||
"@simplewebauthn/types": "12.0.0",
|
||||
"@swc/cli": "0.7.3",
|
||||
"@swc/core": "1.11.24",
|
||||
"@swc/jest": "0.2.38",
|
||||
"@types/accepts": "1.3.7",
|
||||
"@types/archiver": "6.0.3",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"@types/body-parser": "1.19.5",
|
||||
"@types/color-convert": "2.0.4",
|
||||
"@types/content-disposition": "0.5.8",
|
||||
"@types/fluent-ffmpeg": "2.1.27",
|
||||
"@types/htmlescape": "1.1.3",
|
||||
"@types/http-link-header": "1.0.7",
|
||||
"@types/jest": "29.5.14",
|
||||
"@types/js-yaml": "4.0.9",
|
||||
"@types/jsonld": "1.5.15",
|
||||
|
|
@ -220,12 +207,11 @@
|
|||
"@types/oauth2orize": "1.11.5",
|
||||
"@types/oauth2orize-pkce": "0.1.2",
|
||||
"@types/pg": "8.11.14",
|
||||
"@types/proxy-addr": "^2.0.3",
|
||||
"@types/psl": "^1.1.3",
|
||||
"@types/proxy-addr": "2.0.3",
|
||||
"@types/psl": "1.1.3",
|
||||
"@types/pug": "2.0.10",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/random-seed": "0.3.5",
|
||||
"@types/ratelimiter": "3.4.6",
|
||||
"@types/redis-info": "3.0.3",
|
||||
"@types/rename": "1.0.7",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
|
|
@ -235,7 +221,6 @@
|
|||
"@types/supertest": "6.0.3",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"@types/tmp": "0.2.6",
|
||||
"@types/uuid": "^9.0.4",
|
||||
"@types/vary": "1.1.3",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.18.1",
|
||||
|
|
@ -244,7 +229,7 @@
|
|||
"aws-sdk-client-mock": "4.1.0",
|
||||
"cross-env": "7.0.3",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"execa": "8.0.1",
|
||||
"execa": "9.5.2",
|
||||
"fkill": "9.0.0",
|
||||
"jest": "29.7.0",
|
||||
"jest-mock": "29.7.0",
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { createPostgresDataSource } from './postgres.js';
|
|||
import { RepositoryModule } from './models/RepositoryModule.js';
|
||||
import { allSettled } from './misc/promise-tracker.js';
|
||||
import { GlobalEvents } from './core/GlobalEventService.js';
|
||||
import Logger from './logger.js';
|
||||
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const $config: Provider = {
|
||||
|
|
@ -24,8 +25,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.error('failed to initialize database connection', e);
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
inject: [DI.config],
|
||||
};
|
||||
|
|
@ -164,6 +170,8 @@ const $meta: Provider = {
|
|||
exports: [$config, $db, $meta, $meilisearch, $redis, $redisForPub, $redisForSub, $redisForTimelines, $redisForReactions, $redisForRateLimit, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger('global');
|
||||
|
||||
constructor(
|
||||
@Inject(DI.db) private db: DataSource,
|
||||
@Inject(DI.redis) private redisClient: Redis.Redis,
|
||||
|
|
@ -176,8 +184,10 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||
|
||||
public async dispose(): Promise<void> {
|
||||
// Wait for all potential DB queries
|
||||
this.logger.info('Finalizing active promises...');
|
||||
await allSettled();
|
||||
// And then disconnect from DB
|
||||
this.logger.info('Disconnected from data sources...');
|
||||
await this.db.destroy();
|
||||
this.redisClient.disconnect();
|
||||
this.redisForPub.disconnect();
|
||||
|
|
@ -185,6 +195,7 @@ export class GlobalModule implements OnApplicationShutdown {
|
|||
this.redisForTimelines.disconnect();
|
||||
this.redisForReactions.disconnect();
|
||||
this.redisForRateLimit.disconnect();
|
||||
this.logger.info('Global module disposed.');
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ export async function server() {
|
|||
const app = await NestFactory.createApplicationContext(MainModule, {
|
||||
logger: new NestLogger(),
|
||||
});
|
||||
app.enableShutdownHooks();
|
||||
|
||||
const serverService = app.get(ServerService);
|
||||
await serverService.launch();
|
||||
|
|
@ -39,6 +40,7 @@ export async function jobQueue() {
|
|||
const jobQueue = await NestFactory.createApplicationContext(QueueProcessorModule, {
|
||||
logger: new NestLogger(),
|
||||
});
|
||||
jobQueue.enableShutdownHooks();
|
||||
|
||||
jobQueue.get(QueueProcessorService).start();
|
||||
jobQueue.get(ChartManagementService).start();
|
||||
|
|
|
|||
|
|
@ -64,17 +64,35 @@ async function main() {
|
|||
}
|
||||
|
||||
// Display detail of uncaught exception
|
||||
process.on('uncaughtException', err => {
|
||||
process.on('uncaughtExceptionMonitor', ((err, origin) => {
|
||||
try {
|
||||
logger.error('Uncaught exception:', err);
|
||||
logger.error(`Uncaught exception (${origin}):`, err);
|
||||
} catch {
|
||||
console.error('Uncaught exception:', err);
|
||||
console.error(`Uncaught exception (${origin}):`, err);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// Dying away...
|
||||
process.on('disconnect', () => {
|
||||
try {
|
||||
logger.warn('IPC channel disconnected! The process may soon die.');
|
||||
} catch {
|
||||
console.warn('IPC channel disconnected! The process may soon die.');
|
||||
}
|
||||
});
|
||||
process.on('beforeExit', code => {
|
||||
try {
|
||||
logger.warn(`Event loop died! Process will exit with code ${code}.`);
|
||||
} catch {
|
||||
console.warn(`Event loop died! Process will exit with code ${code}.`);
|
||||
}
|
||||
});
|
||||
process.on('exit', code => {
|
||||
logger.info(`The process is going to exit with code ${code}`);
|
||||
try {
|
||||
logger.info(`The process is going to exit with code ${code}`);
|
||||
} catch {
|
||||
console.info(`The process is going to exit with code ${code}`);
|
||||
}
|
||||
});
|
||||
//#endregion
|
||||
|
||||
|
|
|
|||
|
|
@ -96,6 +96,8 @@ type Source = {
|
|||
maxRemoteNoteLength?: number;
|
||||
maxAltTextLength?: number;
|
||||
maxRemoteAltTextLength?: number;
|
||||
maxBioLength?: number;
|
||||
maxRemoteBioLength?: number;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
|
|
@ -261,6 +263,8 @@ export type Config = {
|
|||
maxRemoteCwLength: number;
|
||||
maxAltTextLength: number;
|
||||
maxRemoteAltTextLength: number;
|
||||
maxBioLength: number;
|
||||
maxRemoteBioLength: number;
|
||||
clusterLimit: number | undefined;
|
||||
id: string;
|
||||
outgoingAddress: string | undefined;
|
||||
|
|
@ -461,6 +465,8 @@ export function loadConfig(): Config {
|
|||
maxRemoteCwLength: config.maxRemoteCwLength ?? 5000,
|
||||
maxAltTextLength: config.maxAltTextLength ?? 20000,
|
||||
maxRemoteAltTextLength: config.maxRemoteAltTextLength ?? 100000,
|
||||
maxBioLength: config.maxBioLength ?? 1500,
|
||||
maxRemoteBioLength: config.maxRemoteBioLength ?? 15000,
|
||||
clusterLimit: config.clusterLimit,
|
||||
outgoingAddress: config.outgoingAddress,
|
||||
outgoingAddressFamily: config.outgoingAddressFamily,
|
||||
|
|
@ -658,7 +664,7 @@ function applyEnvOverrides(config: Source) {
|
|||
_apply_top(['sentryForFrontend', 'browserTracingIntegration', 'routeLabel']);
|
||||
_apply_top([['clusterLimit', 'deliverJobConcurrency', 'inboxJobConcurrency', 'relashionshipJobConcurrency', 'deliverJobPerSec', 'inboxJobPerSec', 'relashionshipJobPerSec', 'deliverJobMaxAttempts', 'inboxJobMaxAttempts']]);
|
||||
_apply_top([['outgoingAddress', 'outgoingAddressFamily', 'proxy', 'proxySmtp', 'mediaDirectory', 'mediaProxy', 'proxyRemoteFiles', 'videoThumbnailGenerator']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'pidFile', 'filePermissionBits']]);
|
||||
_apply_top([['maxFileSize', 'maxNoteLength', 'maxRemoteNoteLength', 'maxAltTextLength', 'maxRemoteAltTextLength', 'maxBioLength', 'maxRemoteBioLength', 'pidFile', 'filePermissionBits']]);
|
||||
_apply_top(['import', ['downloadTimeout', 'maxFileSize']]);
|
||||
_apply_top([['signToActivityPubGet', 'checkActivityPubGetSignature', 'setupPassword', 'disallowExternalApRedirect']]);
|
||||
_apply_top(['logging', 'sql', ['disableQueryTruncation', 'enableQueryParamLogging']]);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,28 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects all email addresses that a abuse report should be sent to.
|
||||
*/
|
||||
@bindThis
|
||||
public async getRecipientEMailAddresses(): Promise<string[]> {
|
||||
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
|
||||
.filter(it => it.isActive && it.userProfile?.emailVerified)
|
||||
.map(it => it.userProfile?.email)
|
||||
.filter(x => x != null),
|
||||
);
|
||||
|
||||
if (this.meta.email) {
|
||||
recipientEMailAddresses.push(this.meta.email);
|
||||
}
|
||||
|
||||
if (this.meta.maintainerEmail) {
|
||||
recipientEMailAddresses.push(this.meta.maintainerEmail);
|
||||
}
|
||||
|
||||
return recipientEMailAddresses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mailを用いて{@link abuseReports}の内容を管理者各位に通知する.
|
||||
* メールアドレスの送信先は以下の通り.
|
||||
|
|
@ -96,15 +118,7 @@ export class AbuseReportNotificationService implements OnApplicationShutdown {
|
|||
return;
|
||||
}
|
||||
|
||||
const recipientEMailAddresses = await this.fetchEMailRecipients().then(it => it
|
||||
.filter(it => it.isActive && it.userProfile?.emailVerified)
|
||||
.map(it => it.userProfile?.email)
|
||||
.filter(x => x != null),
|
||||
);
|
||||
|
||||
recipientEMailAddresses.push(
|
||||
...(this.meta.email ? [this.meta.email] : []),
|
||||
);
|
||||
const recipientEMailAddresses = await this.getRecipientEMailAddresses();
|
||||
|
||||
if (recipientEMailAddresses.length <= 0) {
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing, NoteThreadMutingsRepository } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -46,6 +46,8 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public userBlockingCache: QuantumKVCache<Set<string>>;
|
||||
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public threadMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public noteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public hibernatedUserCache: QuantumKVCache<boolean>;
|
||||
|
|
@ -77,6 +79,9 @@ export class CacheService implements OnApplicationShutdown {
|
|||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private readonly noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
) {
|
||||
|
|
@ -145,6 +150,36 @@ export class CacheService implements OnApplicationShutdown {
|
|||
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||
});
|
||||
|
||||
this.threadMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'threadMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: false }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.groupBy('"muting"."userId"')
|
||||
.where({ userId: In(muterIds), isPostMute: false })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.noteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'noteMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: muterId => this.noteThreadMutingsRepository
|
||||
.find({ where: { userId: muterId, isPostMute: true }, select: { threadId: true } })
|
||||
.then(ms => new Set(ms.map(m => m.threadId))),
|
||||
bulkFetcher: muterIds => this.noteThreadMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."userId"', 'userId')
|
||||
.addSelect('array_agg("muting"."threadId")', 'threadIds')
|
||||
.groupBy('"muting"."userId"')
|
||||
.where({ userId: In(muterIds), isPostMute: true })
|
||||
.getRawMany<{ userId: string, threadIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.userId, new Set(m.threadIds)])),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),
|
||||
|
|
@ -272,6 +307,8 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userFollowingsCache.delete(body.id),
|
||||
this.userFollowersCache.delete(body.id),
|
||||
this.hibernatedUserCache.delete(body.id),
|
||||
this.threadMutingsCache.delete(body.id),
|
||||
this.noteMutingsCache.delete(body.id),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -542,7 +579,11 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userBlockingCache.dispose();
|
||||
this.userBlockedCache.dispose();
|
||||
this.renoteMutingsCache.dispose();
|
||||
this.threadMutingsCache.dispose();
|
||||
this.noteMutingsCache.dispose();
|
||||
this.userFollowingsCache.dispose();
|
||||
this.userFollowersCache.dispose();
|
||||
this.hibernatedUserCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -164,7 +164,7 @@ export class DriveService {
|
|||
try {
|
||||
await this.videoProcessingService.webOptimizeVideo(path, type);
|
||||
} catch (err) {
|
||||
this.registerLogger.warn(`Video optimization failed: ${err instanceof Error ? err.message : String(err)}`, { error: err });
|
||||
this.registerLogger.warn(`Video optimization failed: ${renderInlineError(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -367,7 +367,7 @@ export class DriveService {
|
|||
this.registerLogger.debug('web image not created (not an required image)');
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.warn('web image not created (an error occurred)', err as Error);
|
||||
this.registerLogger.warn(`web image not created: ${renderInlineError(err)}`);
|
||||
}
|
||||
} else {
|
||||
if (satisfyWebpublic) this.registerLogger.debug('web image not created (original satisfies webpublic)');
|
||||
|
|
@ -386,7 +386,7 @@ export class DriveService {
|
|||
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 422);
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.warn('thumbnail not created (an error occurred)', err as Error);
|
||||
this.registerLogger.warn(`Error creating thumbnail: ${renderInlineError(err)}`);
|
||||
}
|
||||
// #endregion thumbnail
|
||||
|
||||
|
|
@ -420,27 +420,21 @@ export class DriveService {
|
|||
);
|
||||
if (this.meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
|
||||
if (this.bunnyService.usingBunnyCDN(this.meta)) {
|
||||
await this.bunnyService.upload(this.meta, key, stream).catch(
|
||||
err => {
|
||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await this.s3Service.upload(this.meta, params)
|
||||
.then(
|
||||
result => {
|
||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
} else { // AbortMultipartUploadCommandOutput
|
||||
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
||||
}
|
||||
})
|
||||
.catch(
|
||||
err => {
|
||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}`, err);
|
||||
},
|
||||
);
|
||||
try {
|
||||
if (this.bunnyService.usingBunnyCDN(this.meta)) {
|
||||
await this.bunnyService.upload(this.meta, key, stream);
|
||||
} else {
|
||||
const result = await this.s3Service.upload(this.meta, params);
|
||||
if ('Bucket' in result) { // CompleteMultipartUploadCommandOutput
|
||||
this.registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
} else { // AbortMultipartUploadCommandOutput
|
||||
this.registerLogger.error(`Upload Result Aborted: key = ${key}, filename = ${filename}`);
|
||||
throw new Error('S3 upload aborted');
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.registerLogger.error(`Upload Failed: key = ${key}, filename = ${filename}: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -857,7 +851,7 @@ export class DriveService {
|
|||
}
|
||||
} catch (err: any) {
|
||||
if (err.name === 'NoSuchKey') {
|
||||
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`, err as Error);
|
||||
this.deleteLogger.warn(`The object storage had no such key to delete: ${key}. Skipping this.`);
|
||||
return;
|
||||
} else {
|
||||
throw new Error(`Failed to delete the file from the object storage with the given key: ${key}`, {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ type TimelineOptions = {
|
|||
excludeReplies?: boolean;
|
||||
excludeBots?: boolean;
|
||||
excludePureRenotes: boolean;
|
||||
ignoreAuthorFromUserSuspension?: boolean;
|
||||
dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise<MiNote[]>,
|
||||
};
|
||||
|
||||
|
|
@ -145,6 +146,23 @@ export class FanoutTimelineEndpointService {
|
|||
};
|
||||
}
|
||||
|
||||
{
|
||||
const parentFilter = filter;
|
||||
filter = (note) => {
|
||||
const noteJoined = note as MiNote & {
|
||||
renoteUser: MiUser | null;
|
||||
replyUser: MiUser | null;
|
||||
};
|
||||
if (!ps.ignoreAuthorFromUserSuspension) {
|
||||
if (note.user!.isSuspended) return false;
|
||||
}
|
||||
if (note.userId !== note.renoteUserId && noteJoined.renoteUser?.isSuspended) return false;
|
||||
if (note.userId !== note.replyUserId && noteJoined.replyUser?.isSuspended) return false;
|
||||
|
||||
return parentFilter(note);
|
||||
};
|
||||
}
|
||||
|
||||
const redisTimeline: MiNote[] = [];
|
||||
let readFromRedis = 0;
|
||||
let lastSuccessfulRate = 1; // rateをキャッシュする?
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { URL } from 'node:url';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import * as Redis from 'ioredis';
|
||||
import { load as cheerio } from 'cheerio';
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -16,7 +16,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import type { CheerioAPI } from 'cheerio';
|
||||
import type { CheerioAPI } from 'cheerio/slim';
|
||||
|
||||
type NodeInfo = {
|
||||
openRegistrations?: unknown;
|
||||
|
|
|
|||
|
|
@ -5,25 +5,22 @@
|
|||
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as parse5 from 'parse5';
|
||||
import { type Document, type HTMLParagraphElement, Window } from 'happy-dom';
|
||||
import { isText, isTag, Text } from 'domhandler';
|
||||
import * as htmlparser2 from 'htmlparser2';
|
||||
import { Node, Document, ChildNode, Element, ParentNode } from 'domhandler';
|
||||
import * as domserializer from 'dom-serializer';
|
||||
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 type * as mfm from '@transfem-org/sfm-js';
|
||||
|
||||
const treeAdapter = parse5.defaultTreeAdapter;
|
||||
type Node = DefaultTreeAdapterMap['node'];
|
||||
type ChildNode = DefaultTreeAdapterMap['childNode'];
|
||||
import type * as mfm from 'mfm-js';
|
||||
|
||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||
|
||||
export type Appender = (document: Document, body: HTMLParagraphElement) => void;
|
||||
export type Appender = (document: Document, body: Element) => void;
|
||||
|
||||
@Injectable()
|
||||
export class MfmService {
|
||||
|
|
@ -40,7 +37,7 @@ export class MfmService {
|
|||
|
||||
const normalizedHashtagNames = hashtagNames == null ? undefined : new Set<string>(hashtagNames.map(x => normalizeForSearch(x)));
|
||||
|
||||
const dom = parse5.parseFragment(html);
|
||||
const dom = htmlparser2.parseDocument(html);
|
||||
|
||||
let text = '';
|
||||
|
||||
|
|
@ -51,57 +48,50 @@ export class MfmService {
|
|||
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';
|
||||
if (isText(node)) return node.data;
|
||||
if (!isTag(node)) return '';
|
||||
if (node.tagName === 'br') return '\n';
|
||||
|
||||
if (node.childNodes) {
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
function appendChildren(childNodes: ChildNode[]): void {
|
||||
if (childNodes) {
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
}
|
||||
|
||||
function analyze(node: Node) {
|
||||
if (treeAdapter.isTextNode(node)) {
|
||||
text += node.value;
|
||||
if (isText(node)) {
|
||||
text += node.data;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comment or document type node
|
||||
if (!treeAdapter.isElementNode(node)) {
|
||||
if (!isTag(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (node.nodeName) {
|
||||
switch (node.tagName) {
|
||||
case 'br': {
|
||||
text += '\n';
|
||||
break;
|
||||
return;
|
||||
}
|
||||
|
||||
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.attribs.rel;
|
||||
const href = node.attribs.href;
|
||||
|
||||
// ハッシュタグ
|
||||
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
|
||||
text += txt;
|
||||
// メンション
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
|
||||
} else if (txt.startsWith('@') && !(rel && 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,25 +106,32 @@ 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})`;
|
||||
}
|
||||
};
|
||||
|
||||
text += generateLink();
|
||||
}
|
||||
break;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't produce invalid empty MFM
|
||||
if (node.childNodes.length < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (node.tagName) {
|
||||
case 'h1': {
|
||||
text += '**【';
|
||||
appendChildren(node.childNodes);
|
||||
|
|
@ -185,14 +182,17 @@ export class MfmService {
|
|||
case 'ruby--': {
|
||||
let ruby: [string, string][] = [];
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeName === 'rp') {
|
||||
if (isText(child) && !/\s|\[|\]/.test(child.data)) {
|
||||
ruby.push([child.data, '']);
|
||||
continue;
|
||||
}
|
||||
if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
|
||||
ruby.push([child.value, '']);
|
||||
if (!isTag(child)) {
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === 'rt' && ruby.length > 0) {
|
||||
if (child.tagName === 'rp') {
|
||||
continue;
|
||||
}
|
||||
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
|
||||
|
|
@ -217,7 +217,7 @@ export class MfmService {
|
|||
|
||||
// block code (<pre><code>)
|
||||
case 'pre': {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
||||
if (node.childNodes.length === 1 && isTag(node.childNodes[0]) && node.childNodes[0].tagName === 'code') {
|
||||
text += '\n```\n';
|
||||
text += getText(node.childNodes[0]);
|
||||
text += '\n```\n';
|
||||
|
|
@ -302,17 +302,17 @@ export class MfmService {
|
|||
let nonRtNodes = [];
|
||||
// scan children, ignore `rp`, split on `rt`
|
||||
for (const child of node.childNodes) {
|
||||
if (treeAdapter.isTextNode(child)) {
|
||||
if (isText(child)) {
|
||||
nonRtNodes.push(child);
|
||||
continue;
|
||||
}
|
||||
if (!treeAdapter.isElementNode(child)) {
|
||||
if (!isTag(child)) {
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === 'rp') {
|
||||
if (child.tagName === 'rp') {
|
||||
continue;
|
||||
}
|
||||
if (child.nodeName === 'rt') {
|
||||
if (child.tagName === 'rt') {
|
||||
// the only case in which we don't need a `$[group ]`
|
||||
// is when both sides of the ruby are simple words
|
||||
const needsGroup = nonRtNodes.length > 1 ||
|
||||
|
|
@ -335,6 +335,38 @@ export class MfmService {
|
|||
break;
|
||||
}
|
||||
|
||||
// Replace iframe with link so we can generate previews.
|
||||
// We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it.
|
||||
case 'iframe': {
|
||||
const txt: string | undefined = node.attribs.title || node.attribs.alt;
|
||||
const href: string | undefined = node.attribs.src;
|
||||
if (href) {
|
||||
if (href.match(/[\s>]/)) {
|
||||
if (txt) {
|
||||
// href is invalid + has a label => render a pseudo-link
|
||||
text += `${text} (${href})`;
|
||||
} else {
|
||||
// href is invalid + no label => render plain text
|
||||
text += href;
|
||||
}
|
||||
} else {
|
||||
if (txt) {
|
||||
// href is valid + has a label => render a link
|
||||
const label = txt
|
||||
.replaceAll('[', '(')
|
||||
.replaceAll(']', ')')
|
||||
.replaceAll(/\r?\n/, ' ')
|
||||
.replaceAll('`', '\'');
|
||||
text += `[${label}](<${href}>)`;
|
||||
} else {
|
||||
// href is valid + no label => render a plain URL
|
||||
text += `<${href}>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default: // includes inline elements
|
||||
{
|
||||
appendChildren(node.childNodes);
|
||||
|
|
@ -350,45 +382,44 @@ export class MfmService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { happyDOM, window } = new Window();
|
||||
const doc = new Document([]);
|
||||
|
||||
const doc = window.document;
|
||||
const body = new Element('p', {});
|
||||
doc.childNodes.push(body);
|
||||
|
||||
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 appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
|
||||
for (const child of children.map(x => handle(x))) {
|
||||
targetElement.childNodes.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
function fnDefault(node: mfm.MfmFn) {
|
||||
const el = doc.createElement('i');
|
||||
const el = new Element('i', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode } = {
|
||||
bold: (node) => {
|
||||
const el = doc.createElement('b');
|
||||
const el = new Element('b', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
small: (node) => {
|
||||
const el = doc.createElement('small');
|
||||
const el = new Element('small', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
strike: (node) => {
|
||||
const el = doc.createElement('del');
|
||||
const el = new Element('del', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
italic: (node) => {
|
||||
const el = doc.createElement('i');
|
||||
const el = new Element('i', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
|
@ -399,11 +430,12 @@ 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();
|
||||
const el = new Element('time', {
|
||||
datetime: date.toISOString(),
|
||||
});
|
||||
el.childNodes.push(new Text(date.toISOString()));
|
||||
return el;
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return fnDefault(node);
|
||||
}
|
||||
}
|
||||
|
|
@ -412,20 +444,20 @@ 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');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
|
||||
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
|
||||
rubyEl.appendChild(rpStartEl);
|
||||
rubyEl.appendChild(rtEl);
|
||||
rubyEl.appendChild(rpEndEl);
|
||||
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
|
||||
rtEl.childNodes.push(new Text(text.split(' ')[1]));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
} else {
|
||||
const rt = node.children.at(-1);
|
||||
|
|
@ -435,20 +467,20 @@ export class MfmService {
|
|||
}
|
||||
|
||||
const text = rt.type === 'text' ? rt.props.text : '';
|
||||
const rubyEl = doc.createElement('ruby');
|
||||
const rtEl = doc.createElement('rt');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
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);
|
||||
rtEl.childNodes.push(new Text(text.trim()));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
}
|
||||
}
|
||||
|
|
@ -456,7 +488,7 @@ export class MfmService {
|
|||
// hack for ruby, should never be needed because we should
|
||||
// never send this out to other instances
|
||||
case 'group': {
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
|
@ -468,125 +500,135 @@ export class MfmService {
|
|||
},
|
||||
|
||||
blockCode: (node) => {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
inner.textContent = node.props.code;
|
||||
pre.appendChild(inner);
|
||||
const pre = new Element('pre', {});
|
||||
const inner = new Element('code', {});
|
||||
inner.childNodes.push(new Text(node.props.code));
|
||||
pre.childNodes.push(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
center: (node) => {
|
||||
const el = doc.createElement('div');
|
||||
const el = new Element('div', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode: (node) => {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
return new Text(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji: (node) => {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
return new Text(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');
|
||||
const a = new Element('a', {
|
||||
href: `${this.config.url}/tags/${node.props.hashtag}`,
|
||||
rel: 'tag',
|
||||
});
|
||||
a.childNodes.push(new Text(`#${node.props.hashtag}`));
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.code));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
link: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', node.props.url);
|
||||
const a = new Element('a', {
|
||||
href: node.props.url,
|
||||
});
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
||||
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
|
||||
? (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;
|
||||
|
||||
const a = new Element('a', {
|
||||
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}`,
|
||||
class: 'u-url mention',
|
||||
});
|
||||
a.childNodes.push(new Text(acct));
|
||||
return a;
|
||||
},
|
||||
|
||||
quote: (node) => {
|
||||
const el = doc.createElement('blockquote');
|
||||
const el = new Element('blockquote', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text: (node) => {
|
||||
if (!node.props.text.match(/[\r\n]/)) {
|
||||
return doc.createTextNode(node.props.text);
|
||||
return new Text(node.props.text);
|
||||
}
|
||||
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
||||
const el = new Element('span', {});
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => new Text(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url;
|
||||
const a = new Element('a', {
|
||||
href: node.props.url,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.url));
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
const a = new Element('a', {
|
||||
href: `https://www.google.com/search?q=${node.props.query}`,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.content));
|
||||
return a;
|
||||
},
|
||||
|
||||
plain: (node) => {
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
// Utility function to make TypeScript behave
|
||||
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
|
||||
const handler = handlers[node.type] as (node: T) => ChildNode;
|
||||
return handler(node);
|
||||
}
|
||||
|
||||
appendChildren(nodes, body);
|
||||
|
||||
for (const additionalAppender of additionalAppenders) {
|
||||
additionalAppender(doc, body);
|
||||
}
|
||||
|
||||
const serialized = body.outerHTML;
|
||||
|
||||
happyDOM.close().catch(err => {});
|
||||
|
||||
return serialized;
|
||||
return domserializer.render(body, {
|
||||
encodeEntities: 'utf8'
|
||||
});
|
||||
}
|
||||
|
||||
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
|
||||
|
|
@ -598,55 +640,55 @@ export class MfmService {
|
|||
return null;
|
||||
}
|
||||
|
||||
const { happyDOM, window } = new Window();
|
||||
const doc = new Document([]);
|
||||
|
||||
const doc = window.document;
|
||||
const body = new Element('p', {});
|
||||
doc.childNodes.push(body);
|
||||
|
||||
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 appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
|
||||
for (const child of children) {
|
||||
const result = handle(child);
|
||||
targetElement.childNodes.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: {
|
||||
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any;
|
||||
[K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => ChildNode;
|
||||
} = {
|
||||
bold(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '**';
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('**'));
|
||||
appendChildren(node.children, el);
|
||||
el.textContent += '**';
|
||||
el.childNodes.push(new Text('**'));
|
||||
return el;
|
||||
},
|
||||
|
||||
small(node) {
|
||||
const el = doc.createElement('small');
|
||||
const el = new Element('small', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
strike(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '~~';
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('~~'));
|
||||
appendChildren(node.children, el);
|
||||
el.textContent += '~~';
|
||||
el.childNodes.push(new Text('~~'));
|
||||
return el;
|
||||
},
|
||||
|
||||
italic(node) {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('*'));
|
||||
appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
el.childNodes.push(new Text('*'));
|
||||
return el;
|
||||
},
|
||||
|
||||
fn(node) {
|
||||
switch (node.props.name) {
|
||||
case 'group': { // hack for ruby
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
|
@ -654,119 +696,121 @@ 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');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
|
||||
rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
|
||||
rubyEl.appendChild(rpStartEl);
|
||||
rubyEl.appendChild(rtEl);
|
||||
rubyEl.appendChild(rpEndEl);
|
||||
rubyEl.childNodes.push(new Text(text.split(' ')[0]));
|
||||
rtEl.childNodes.push(new Text(text.split(' ')[1]));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
} else {
|
||||
const rt = node.children.at(-1);
|
||||
|
||||
if (!rt) {
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
}
|
||||
|
||||
const text = rt.type === 'text' ? rt.props.text : '';
|
||||
const rubyEl = doc.createElement('ruby');
|
||||
const rtEl = doc.createElement('rt');
|
||||
const rubyEl = new Element('ruby', {});
|
||||
const rtEl = new Element('rt', {});
|
||||
|
||||
const rpStartEl = doc.createElement('rp');
|
||||
rpStartEl.appendChild(doc.createTextNode('('));
|
||||
const rpEndEl = doc.createElement('rp');
|
||||
rpEndEl.appendChild(doc.createTextNode(')'));
|
||||
const rpStartEl = new Element('rp', {});
|
||||
rpStartEl.childNodes.push(new Text('('));
|
||||
const rpEndEl = new Element('rp', {});
|
||||
rpEndEl.childNodes.push(new Text(')'));
|
||||
|
||||
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);
|
||||
rtEl.childNodes.push(new Text(text.trim()));
|
||||
rubyEl.childNodes.push(rpStartEl);
|
||||
rubyEl.childNodes.push(rtEl);
|
||||
rubyEl.childNodes.push(rpEndEl);
|
||||
return rubyEl;
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
const el = doc.createElement('span');
|
||||
el.textContent = '*';
|
||||
const el = new Element('span', {});
|
||||
el.childNodes.push(new Text('*'));
|
||||
appendChildren(node.children, el);
|
||||
el.textContent += '*';
|
||||
el.childNodes.push(new Text('*'));
|
||||
return el;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
blockCode(node) {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
const pre = new Element('pre', {});
|
||||
const inner = new Element('code', {});
|
||||
|
||||
const nodes = node.props.code
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
.map((x) => new Text(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
inner.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
inner.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||
}
|
||||
|
||||
pre.appendChild(inner);
|
||||
pre.childNodes.push(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
center(node) {
|
||||
const el = doc.createElement('div');
|
||||
const el = new Element('div', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode(node) {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
return new Text(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji(node) {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
return new Text(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');
|
||||
a.setAttribute('class', 'hashtag');
|
||||
const a = new Element('a', {
|
||||
href: `${this.config.url}/tags/${node.props.hashtag}`,
|
||||
rel: 'tag',
|
||||
class: 'hashtag',
|
||||
});
|
||||
a.childNodes.push(new Text(`#${node.props.hashtag}`));
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.code));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock(node) {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
const el = new Element('code', {});
|
||||
el.childNodes.push(new Text(node.props.formula));
|
||||
return el;
|
||||
},
|
||||
|
||||
link(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
const a = new Element('a', {
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
target: '_blank',
|
||||
href: node.props.url,
|
||||
});
|
||||
appendChildren(node.children, a);
|
||||
return a;
|
||||
},
|
||||
|
|
@ -775,92 +819,107 @@ export class MfmService {
|
|||
const { username, host, acct } = node.props;
|
||||
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
|
||||
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
if (!resolved) {
|
||||
el.textContent = acct;
|
||||
el.childNodes.push(new Text(acct));
|
||||
} else {
|
||||
el.setAttribute('class', 'h-card');
|
||||
el.setAttribute('translate', 'no');
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
|
||||
a.className = 'u-url mention';
|
||||
const span = doc.createElement('span');
|
||||
span.textContent = resolved.username || username;
|
||||
a.textContent = '@';
|
||||
a.appendChild(span);
|
||||
el.appendChild(a);
|
||||
el.attribs.class = 'h-card';
|
||||
el.attribs.translate = 'no';
|
||||
const a = new Element('a', {
|
||||
href: resolved.url ? resolved.url : resolved.uri,
|
||||
class: 'u-url mention',
|
||||
});
|
||||
const span = new Element('span', {});
|
||||
span.childNodes.push(new Text(resolved.username || username));
|
||||
a.childNodes.push(new Text('@'));
|
||||
a.childNodes.push(span);
|
||||
el.childNodes.push(a);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
quote(node) {
|
||||
const el = doc.createElement('blockquote');
|
||||
const el = new Element('blockquote', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text(node) {
|
||||
const el = doc.createElement('span');
|
||||
if (!node.props.text.match(/[\r\n]/)) {
|
||||
return new Text(node.props.text);
|
||||
}
|
||||
|
||||
const el = new Element('span', {});
|
||||
const nodes = node.props.text
|
||||
.split(/\r\n|\r|\n/)
|
||||
.map((x) => doc.createTextNode(x));
|
||||
.map((x) => new Text(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url(node) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('rel', 'nofollow noopener noreferrer');
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('href', node.props.url);
|
||||
a.textContent = node.props.url.replace(/^https?:\/\//, '');
|
||||
const a = new Element('a', {
|
||||
rel: 'nofollow noopener noreferrer',
|
||||
target: '_blank',
|
||||
href: node.props.url,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.url.replace(/^https?:\/\//, '')));
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
|
||||
a.textContent = node.props.content;
|
||||
const a = new Element('a', {
|
||||
href: `https://www.google.com/search?q=${node.props.query}`,
|
||||
});
|
||||
a.childNodes.push(new Text(node.props.content));
|
||||
return a;
|
||||
},
|
||||
|
||||
plain(node) {
|
||||
const el = doc.createElement('span');
|
||||
const el = new Element('span', {});
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
// Utility function to make TypeScript behave
|
||||
function handle<T extends mfm.MfmNode>(node: T): ChildNode {
|
||||
const handler = handlers[node.type] as (node: T) => ChildNode;
|
||||
return handler(node);
|
||||
}
|
||||
|
||||
appendChildren(nodes, body);
|
||||
|
||||
if (quoteUri !== null) {
|
||||
const a = doc.createElement('a');
|
||||
a.setAttribute('href', quoteUri);
|
||||
a.textContent = quoteUri.replace(/^https?:\/\//, '');
|
||||
const a = new Element('a', {
|
||||
href: quoteUri,
|
||||
});
|
||||
a.childNodes.push(new Text(quoteUri.replace(/^https?:\/\//, '')));
|
||||
|
||||
const quote = doc.createElement('span');
|
||||
quote.setAttribute('class', 'quote-inline');
|
||||
quote.appendChild(doc.createElement('br'));
|
||||
quote.appendChild(doc.createElement('br'));
|
||||
quote.innerHTML += 'RE: ';
|
||||
quote.appendChild(a);
|
||||
const quote = new Element('span', {
|
||||
class: 'quote-inline',
|
||||
});
|
||||
quote.childNodes.push(new Element('br', {}));
|
||||
quote.childNodes.push(new Element('br', {}));
|
||||
quote.childNodes.push(new Text('RE: '));
|
||||
quote.childNodes.push(a);
|
||||
|
||||
body.appendChild(quote);
|
||||
body.childNodes.push(quote);
|
||||
}
|
||||
|
||||
let result = body.outerHTML;
|
||||
let result = domserializer.render(body, {
|
||||
encodeEntities: 'utf8'
|
||||
});
|
||||
|
||||
if (inline) {
|
||||
result = result.replace(/^<p>/, '').replace(/<\/p>$/, '');
|
||||
}
|
||||
|
||||
happyDOM.close().catch(() => {});
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { In, DataSource, IsNull, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
|
@ -676,18 +676,15 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.reply.threadId ?? data.reply.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.reply.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.reply.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
|
|
@ -705,14 +702,17 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// Notify
|
||||
if (data.renote.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.renote.userId,
|
||||
threadId: data.renote.threadId ?? data.renote.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.renote.threadId ?? data.renote.id;
|
||||
|
||||
const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId));
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.renote.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.renote.userId),
|
||||
]);
|
||||
|
||||
const muted = data.renote.userId && isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
if (!isThreadMuted && !muted) {
|
||||
nm.push(data.renote.userId, type);
|
||||
|
|
@ -731,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
//#region AP deliver
|
||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||
trackTask(async () => {
|
||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
|
||||
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
|
||||
// メンションされたリモートユーザーに配送
|
||||
|
|
@ -842,18 +842,23 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
|
||||
const [
|
||||
threadMutings,
|
||||
userMutings,
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
|
||||
this.cacheService.userMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
|
||||
]);
|
||||
|
||||
// Only create mention events for local users, and users for whom the note is visible
|
||||
for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: u.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const isThreadMuted = threadMutings.get(u.id)?.has(threadId);
|
||||
|
||||
const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id));
|
||||
const mutings = userMutings.get(u.id);
|
||||
const isUserMuted = mutings != null && isUserRelated(note, mutings);
|
||||
|
||||
if (isThreadMuted || muted) {
|
||||
if (isThreadMuted || isUserMuted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -874,17 +879,6 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
|
||||
if (data.localOnly) return null;
|
||||
|
||||
const content = this.isRenote(data) && !this.isQuote(data)
|
||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
||||
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
|
||||
|
||||
return this.apRendererService.addContext(content);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private index(note: MiNote) {
|
||||
if (note.text == null && note.cw == null) return;
|
||||
|
|
@ -964,6 +958,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
if (following.followerHost !== null) continue;
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { setImmediate } from 'node:timers/promises';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { DataSource, In, IsNull, LessThan } from 'typeorm';
|
||||
import * as Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
|
@ -647,18 +647,15 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
if (data.reply) {
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
},
|
||||
});
|
||||
const threadId = data.reply.threadId ?? data.reply.id;
|
||||
|
||||
const [
|
||||
isThreadMuted,
|
||||
userIdsWhoMeMuting,
|
||||
] = data.reply.userId ? await Promise.all([
|
||||
] = await Promise.all([
|
||||
this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
|
||||
this.cacheService.userMutingsCache.fetch(data.reply.userId),
|
||||
]) : [new Set<string>()];
|
||||
]);
|
||||
|
||||
const muted = isUserRelated(note, userIdsWhoMeMuting);
|
||||
|
||||
|
|
@ -675,7 +672,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
//#region AP deliver
|
||||
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
|
||||
trackTask(async () => {
|
||||
const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
|
||||
const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
|
||||
// メンションされたリモートユーザーに配送
|
||||
|
|
@ -770,17 +767,6 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
(note.files != null && note.files.length > 0);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
|
||||
if (data.localOnly) return null;
|
||||
|
||||
const content = this.isRenote(data) && !this.isQuote(data)
|
||||
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
|
||||
: this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
|
||||
|
||||
return this.apRendererService.addContext(content);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private index(note: MiNote) {
|
||||
if (note.text == null && note.cw == null) return;
|
||||
|
|
@ -849,6 +835,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
|
||||
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
|
||||
for (const following of followings) {
|
||||
if (following.followerHost !== null) continue;
|
||||
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
|
||||
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -47,29 +47,36 @@ export class QueryService {
|
|||
) {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -557,4 +564,26 @@ export class QueryService {
|
|||
|
||||
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
|
||||
}
|
||||
|
||||
// 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(`note.${user}Id IS NULL`)
|
||||
.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(`note.${user}Id IS NULL`)
|
||||
.orWhere(`${user}.isSuspended = FALSE`));
|
||||
q
|
||||
.andWhere('user.isSuspended = FALSE')
|
||||
.andWhere(brakets('replyUser'))
|
||||
.andWhere(brakets('renoteUser'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
|
||||
import { allSettled } from '@/misc/promise-tracker.js';
|
||||
import Logger from '@/logger.js';
|
||||
import {
|
||||
DeliverJobData,
|
||||
EndedPollNotificationJobData,
|
||||
|
|
@ -120,6 +121,8 @@ const $scheduleNotePost: Provider = {
|
|||
],
|
||||
})
|
||||
export class QueueModule implements OnApplicationShutdown {
|
||||
private readonly logger = new Logger('queue');
|
||||
|
||||
constructor(
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
|
|
@ -135,8 +138,10 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
|
||||
public async dispose(): Promise<void> {
|
||||
// Wait for all potential queue jobs
|
||||
this.logger.info('Finalizing active promises...');
|
||||
await allSettled();
|
||||
// And then close all queues
|
||||
this.logger.info('Closing BullMQ queues...');
|
||||
await Promise.all([
|
||||
this.systemQueue.close(),
|
||||
this.endedPollNotificationQueue.close(),
|
||||
|
|
@ -149,6 +154,7 @@ export class QueueModule implements OnApplicationShutdown {
|
|||
this.systemWebhookDeliverQueue.close(),
|
||||
this.scheduleNotePostQueue.close(),
|
||||
]);
|
||||
this.logger.info('Queue module disposed.');
|
||||
}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -684,8 +684,11 @@ export class QueueService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public createCleanRemoteFilesJob() {
|
||||
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
|
||||
public createCleanRemoteFilesJob(olderThanSeconds: number = 0, keepFilesInUse: boolean = false) {
|
||||
return this.objectStorageQueue.add('cleanRemoteFiles', {
|
||||
keepFilesInUse,
|
||||
olderThanSeconds,
|
||||
}, {
|
||||
removeOnComplete: {
|
||||
age: 3600 * 24 * 7, // keep up to 7 days
|
||||
count: 30,
|
||||
|
|
|
|||
|
|
@ -275,12 +275,8 @@ export class ReactionService {
|
|||
|
||||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
||||
if (note.userHost === null) {
|
||||
const isThreadMuted = await this.noteThreadMutingsRepository.exists({
|
||||
where: {
|
||||
userId: note.userId,
|
||||
threadId: note.threadId ?? note.id,
|
||||
},
|
||||
});
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const isThreadMuted = await this.cacheService.threadMutingsCache.fetch(note.userId).then(ms => ms.has(threadId));
|
||||
|
||||
if (!isThreadMuted) {
|
||||
this.notificationService.createNotification(note.userId, 'reaction', {
|
||||
|
|
|
|||
|
|
@ -27,25 +27,9 @@ export class RegistryApiService {
|
|||
public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) {
|
||||
// TODO: 作成できるキーの数を制限する
|
||||
|
||||
const query = this.registryItemsRepository.createQueryBuilder('item');
|
||||
if (domain) {
|
||||
query.where('item.domain = :domain', { domain: domain });
|
||||
} else {
|
||||
query.where('item.domain IS NULL');
|
||||
}
|
||||
query.andWhere('item.userId = :userId', { userId: userId });
|
||||
query.andWhere('item.key = :key', { key: key });
|
||||
query.andWhere('item.scope = :scope', { scope: scope });
|
||||
|
||||
const existingItem = await query.getOne();
|
||||
|
||||
if (existingItem) {
|
||||
await this.registryItemsRepository.update(existingItem.id, {
|
||||
updatedAt: new Date(),
|
||||
value: value,
|
||||
});
|
||||
} else {
|
||||
await this.registryItemsRepository.insert({
|
||||
await this.registryItemsRepository.createQueryBuilder('item')
|
||||
.insert()
|
||||
.values({
|
||||
id: this.idService.gen(),
|
||||
updatedAt: new Date(),
|
||||
userId: userId,
|
||||
|
|
@ -53,8 +37,13 @@ export class RegistryApiService {
|
|||
scope: scope,
|
||||
key: key,
|
||||
value: value,
|
||||
});
|
||||
}
|
||||
})
|
||||
.orUpdate(
|
||||
['updatedAt', 'value'],
|
||||
['userId', 'key', 'scope', 'domain'],
|
||||
{ upsertType: 'on-conflict-do-update' }
|
||||
)
|
||||
.execute();
|
||||
|
||||
if (domain == null) {
|
||||
// TODO: サードパーティアプリが傍受出来てしまうのでどうにかする
|
||||
|
|
|
|||
|
|
@ -737,6 +737,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async clone(role: MiRole, moderator?: MiUser): Promise<MiRole> {
|
||||
const suffix = ' (cloned)';
|
||||
const newName = role.name.slice(0, 256 - suffix.length) + suffix;
|
||||
|
||||
return this.create({
|
||||
...role,
|
||||
name: newName,
|
||||
}, moderator);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async delete(role: MiRole, moderator?: MiUser): Promise<void> {
|
||||
await this.rolesRepository.delete({ id: role.id });
|
||||
|
|
|
|||
|
|
@ -301,6 +301,7 @@ export class SearchService {
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
|
|
@ -368,11 +369,17 @@ export class SearchService {
|
|||
])
|
||||
: [new Set<string>(), new Set<string>()];
|
||||
|
||||
const query = this.notesRepository.createQueryBuilder('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;
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { MiUser } from '@/models/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
|
@ -17,9 +17,15 @@ import { RelationshipJobData } from '@/queue/types.js';
|
|||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { trackPromise } from '@/misc/promise-tracker.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
|
@ -36,7 +42,10 @@ export class UserSuspendService {
|
|||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private readonly cacheService: CacheService,
|
||||
|
||||
loggerService: LoggerService,
|
||||
) {
|
||||
this.logger = loggerService.getLogger('user-suspend');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -47,16 +56,16 @@ export class UserSuspendService {
|
|||
isSuspended: true,
|
||||
});
|
||||
|
||||
this.moderationLogService.log(moderator, 'suspend', {
|
||||
await this.moderationLogService.log(moderator, 'suspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await this.postSuspend(user).catch(e => {});
|
||||
await this.unFollowAll(user).catch(e => {});
|
||||
})();
|
||||
trackPromise((async () => {
|
||||
await this.postSuspend(user);
|
||||
await this.freezeAll(user);
|
||||
})().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -65,33 +74,36 @@ export class UserSuspendService {
|
|||
isSuspended: false,
|
||||
});
|
||||
|
||||
this.moderationLogService.log(moderator, 'unsuspend', {
|
||||
await this.moderationLogService.log(moderator, 'unsuspend', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await this.postUnsuspend(user).catch(e => {});
|
||||
})();
|
||||
trackPromise((async () => {
|
||||
await this.postUnsuspend(user);
|
||||
await this.unFreezeAll(user);
|
||||
})().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||
|
||||
/*
|
||||
this.followRequestsRepository.delete({
|
||||
followeeId: user.id,
|
||||
});
|
||||
this.followRequestsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
*/
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
const queue = new Map<string, boolean>();
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
|
|
@ -104,12 +116,12 @@ export class UserSuspendService {
|
|||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
if (inbox != null) {
|
||||
queue.set(inbox, true);
|
||||
}
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox, true);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +133,7 @@ export class UserSuspendService {
|
|||
// 知り得る全SharedInboxにUndo Delete配信
|
||||
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
const queue = new Map<string, boolean>();
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
|
|
@ -134,12 +146,12 @@ export class UserSuspendService {
|
|||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
if (inbox != null) {
|
||||
queue.set(inbox, true);
|
||||
}
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user as any, content, inbox, true);
|
||||
}
|
||||
await this.queueService.deliverMany(user, content, queue);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,4 +172,36 @@ export class UserSuspendService {
|
|||
}
|
||||
this.queueService.createUnfollowJob(jobs);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async freezeAll(user: MiUser): Promise<void> {
|
||||
// Freeze follow relations with all remote users
|
||||
await this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.orWhere({
|
||||
followeeId: user.id,
|
||||
followerHost: Not(IsNull()),
|
||||
})
|
||||
.update({
|
||||
isFollowerHibernated: true,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async unFreezeAll(user: MiUser): Promise<void> {
|
||||
// Restore follow relations with all remote users
|
||||
await this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.innerJoin(MiUser, 'follower', 'user.id = following.followerId')
|
||||
.andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
|
||||
.andWhere({
|
||||
followeeId: user.id,
|
||||
followerHost: Not(IsNull()),
|
||||
})
|
||||
.update({
|
||||
isFollowerHibernated: false,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,12 @@ import { URL, domainToASCII } from 'node:url';
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import RE2 from 're2';
|
||||
import psl from 'psl';
|
||||
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 {
|
||||
|
|
@ -213,4 +215,20 @@ export class UtilityService {
|
|||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@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 }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
import { URL } from 'node:url';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import type Logger from '@/logger.js';
|
||||
|
|
@ -101,14 +101,12 @@ export class WebfingerService {
|
|||
private async fetchWebFingerTemplateFromHostMeta(url: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml');
|
||||
const options = {
|
||||
ignoreAttributes: false,
|
||||
isArray: (_name: string, jpath: string) => jpath === 'XRD.Link',
|
||||
};
|
||||
const parser = new XMLParser(options);
|
||||
const hostMeta = parser.parse(res);
|
||||
const template = (hostMeta['XRD']['Link'] as Array<any>).filter(p => p['@_rel'] === 'lrdd')[0]['@_template'];
|
||||
return template.indexOf('{uri}') < 0 ? null : template;
|
||||
const hostMeta = cheerio(res, {
|
||||
xml: true,
|
||||
});
|
||||
|
||||
const template = hostMeta('XRD > Link[rel="lrdd"][template*="{uri}"]').attr('template');
|
||||
return template ?? null;
|
||||
} catch (err) {
|
||||
this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js';
|
|||
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
|
||||
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
|
||||
|
||||
const oneDayMillis = 24 * 60 * 60 * 1000;
|
||||
|
|
@ -166,6 +167,7 @@ export class WebhookTestService {
|
|||
private userWebhookService: UserWebhookService,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private queueService: QueueService,
|
||||
private readonly idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -392,6 +394,7 @@ export class WebhookTestService {
|
|||
private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise<Packed<'Note'>> {
|
||||
return {
|
||||
id: note.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
deletedAt: null,
|
||||
text: note.text,
|
||||
|
|
@ -401,6 +404,10 @@ export class WebhookTestService {
|
|||
replyId: note.replyId,
|
||||
renoteId: note.renoteId,
|
||||
isHidden: false,
|
||||
isMutingThread: false,
|
||||
isMutingNote: false,
|
||||
isFavorited: false,
|
||||
isRenoted: false,
|
||||
visibility: note.visibility,
|
||||
mentions: note.mentions,
|
||||
visibleUserIds: note.visibleUserIds,
|
||||
|
|
@ -435,10 +442,12 @@ export class WebhookTestService {
|
|||
private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise<Packed<'UserLite'>> {
|
||||
return {
|
||||
...user,
|
||||
createdAt: this.idService.parse(user.id).date.toISOString(),
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
host: user.host,
|
||||
description: 'dummy user',
|
||||
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
|
||||
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
|
||||
avatarDecorations: user.avatarDecorations.map(it => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { MfmService, Appender } from '@/core/MfmService.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
|
|||
|
|
@ -6,8 +6,9 @@
|
|||
import { createPublicKey, randomUUID } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { Element, Text } from 'domhandler';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -31,6 +32,8 @@ import { IdService } from '@/core/IdService.js';
|
|||
import { appendContentWarning } from '@/misc/append-content-warning.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { JsonLdService } from './JsonLdService.js';
|
||||
import { ApMfmService } from './ApMfmService.js';
|
||||
import { CONTEXT } from './misc/contexts.js';
|
||||
|
|
@ -74,6 +77,7 @@ export class ApRendererService {
|
|||
private idService: IdService,
|
||||
private readonly queryService: QueryService,
|
||||
private utilityService: UtilityService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +235,7 @@ export class ApRendererService {
|
|||
*/
|
||||
@bindThis
|
||||
public async renderFollowUser(id: MiUser['id']): Promise<string> {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser;
|
||||
const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser;
|
||||
return this.userEntityService.getUserUri(user);
|
||||
}
|
||||
|
||||
|
|
@ -401,7 +405,7 @@ export class ApRendererService {
|
|||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
||||
|
||||
if (inReplyToNote != null) {
|
||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
||||
const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
|
||||
|
||||
if (inReplyToUser) {
|
||||
if (inReplyToNote.uri) {
|
||||
|
|
@ -421,7 +425,7 @@ export class ApRendererService {
|
|||
|
||||
let quote: string | undefined = undefined;
|
||||
|
||||
if (note.renoteId) {
|
||||
if (isRenote(note) && isQuote(note)) {
|
||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
||||
|
||||
if (renote) {
|
||||
|
|
@ -475,16 +479,18 @@ export class ApRendererService {
|
|||
// the claas 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);
|
||||
body.childNodes.push(new Element('br', {}));
|
||||
body.childNodes.push(new Element('br', {}));
|
||||
const span = new Element('span', {
|
||||
class: 'quote-inline',
|
||||
});
|
||||
span.childNodes.push(new Text('RE: '));
|
||||
const link = new Element('a', {
|
||||
href: quote,
|
||||
});
|
||||
link.childNodes.push(new Text(quote));
|
||||
span.childNodes.push(link);
|
||||
body.childNodes.push(span);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -539,6 +545,7 @@ export class ApRendererService {
|
|||
attributedTo,
|
||||
summary: summary ?? undefined,
|
||||
content: content ?? undefined,
|
||||
updated: note.updatedAt?.toISOString() ?? undefined,
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
|
|
@ -548,7 +555,8 @@ export class ApRendererService {
|
|||
quoteUrl: quote,
|
||||
quoteUri: quote,
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||
quote: quote,
|
||||
// Disabled since Mastodon hides the fallback link when this is set
|
||||
// quote: quote,
|
||||
published: this.idService.parse(note.id).date.toISOString(),
|
||||
to,
|
||||
cc,
|
||||
|
|
@ -753,174 +761,6 @@ export class ApRendererService {
|
|||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise<IPost> {
|
||||
const getPromisedFiles = async (ids: string[]): Promise<MiDriveFile[]> => {
|
||||
if (ids.length === 0) return [];
|
||||
const items = await this.driveFilesRepository.findBy({ id: In(ids) });
|
||||
return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
|
||||
};
|
||||
|
||||
let inReplyTo;
|
||||
let inReplyToNote: MiNote | null;
|
||||
|
||||
if (note.replyId) {
|
||||
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
|
||||
|
||||
if (inReplyToNote != null) {
|
||||
const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
|
||||
|
||||
if (inReplyToUser) {
|
||||
if (inReplyToNote.uri) {
|
||||
inReplyTo = inReplyToNote.uri;
|
||||
} else {
|
||||
if (dive) {
|
||||
inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
|
||||
} else {
|
||||
inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
inReplyTo = null;
|
||||
}
|
||||
|
||||
let quote: string | undefined = undefined;
|
||||
|
||||
if (note.renoteId) {
|
||||
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
|
||||
|
||||
if (renote) {
|
||||
quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
|
||||
|
||||
const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
|
||||
|
||||
let to: string[] = [];
|
||||
let cc: string[] = [];
|
||||
|
||||
if (note.visibility === 'public') {
|
||||
to = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||
cc = [`${attributedTo}/followers`].concat(mentions);
|
||||
} else if (note.visibility === 'home') {
|
||||
to = [`${attributedTo}/followers`];
|
||||
cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
|
||||
} else if (note.visibility === 'followers') {
|
||||
to = [`${attributedTo}/followers`];
|
||||
cc = mentions;
|
||||
} else {
|
||||
to = mentions;
|
||||
}
|
||||
|
||||
const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({
|
||||
id: In(note.mentions),
|
||||
}) : [];
|
||||
|
||||
const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
|
||||
const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser));
|
||||
|
||||
const files = await getPromisedFiles(note.fileIds);
|
||||
|
||||
const text = note.text ?? '';
|
||||
let poll: MiPoll | null = null;
|
||||
|
||||
if (note.hasPoll) {
|
||||
poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
}
|
||||
|
||||
const apAppend: Appender[] = [];
|
||||
|
||||
if (quote) {
|
||||
// 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.
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
|
||||
|
||||
// Apply mandatory CW, if applicable
|
||||
if (author.mandatoryCW) {
|
||||
summary = appendContentWarning(summary, author.mandatoryCW);
|
||||
}
|
||||
|
||||
const { content } = this.apMfmService.getNoteHtml(note, apAppend);
|
||||
|
||||
const emojis = await this.getEmojis(note.emojis);
|
||||
const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
|
||||
|
||||
const tag: IObject[] = [
|
||||
...hashtagTags,
|
||||
...mentionTags,
|
||||
...apemojis,
|
||||
];
|
||||
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
|
||||
if (quote) {
|
||||
tag.push({
|
||||
type: 'Link',
|
||||
mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
|
||||
rel: 'https://misskey-hub.net/ns#_misskey_quote',
|
||||
href: quote,
|
||||
} satisfies ILink);
|
||||
}
|
||||
|
||||
const asPoll = poll ? {
|
||||
type: 'Question',
|
||||
[poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
|
||||
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
|
||||
type: 'Note',
|
||||
name: text,
|
||||
replies: {
|
||||
type: 'Collection',
|
||||
totalItems: poll!.votes[i],
|
||||
},
|
||||
})),
|
||||
} as const : {};
|
||||
|
||||
return {
|
||||
id: `${this.config.url}/notes/${note.id}`,
|
||||
type: 'Note',
|
||||
attributedTo,
|
||||
summary: summary ?? undefined,
|
||||
content: content ?? undefined,
|
||||
updated: note.updatedAt?.toISOString(),
|
||||
_misskey_content: text,
|
||||
source: {
|
||||
content: text,
|
||||
mediaType: 'text/x.misskeymarkdown',
|
||||
},
|
||||
_misskey_quote: quote,
|
||||
quoteUrl: quote,
|
||||
quoteUri: quote,
|
||||
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
|
||||
quote: quote,
|
||||
published: this.idService.parse(note.id).date.toISOString(),
|
||||
to,
|
||||
cc,
|
||||
inReplyTo,
|
||||
attachment: files.map(x => this.renderDocument(x)),
|
||||
sensitive: note.cw != null || files.some(file => file.isSensitive),
|
||||
tag,
|
||||
...asPoll,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
|
||||
return {
|
||||
|
|
@ -1074,6 +914,27 @@ export class ApRendererService {
|
|||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) {
|
||||
if (note.localOnly) return null;
|
||||
|
||||
if (isPureRenote(note)) {
|
||||
const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||
const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note);
|
||||
return this.addContext(apAnnounce);
|
||||
}
|
||||
|
||||
const apNote = await this.renderNote(note, user, false);
|
||||
|
||||
if (note.updatedAt != null) {
|
||||
const apUpdate = this.renderUpdate(apNote, user);
|
||||
return this.addContext(apUpdate);
|
||||
} else {
|
||||
const apCreate = this.renderCreate(apNote, note);
|
||||
return this.addContext(apCreate);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async getEmojis(names: string[]): Promise<MiEmoji[]> {
|
||||
if (names.length === 0) return [];
|
||||
|
|
|
|||
|
|
@ -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 { load as cheerio } from 'cheerio/slim';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
|
|
@ -18,6 +18,8 @@ import { bindThis } from '@/decorators.js';
|
|||
import type Logger from '@/logger.js';
|
||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||
import type { IObject, IObjectWithId } from './type.js';
|
||||
import type { Cheerio, CheerioAPI } from 'cheerio/slim';
|
||||
import type { AnyNode } from 'domhandler';
|
||||
|
||||
type Request = {
|
||||
url: string;
|
||||
|
|
@ -219,53 +221,33 @@ export class ApRequestService {
|
|||
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
|
||||
_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;
|
||||
let alternate: Cheerio<AnyNode> | null;
|
||||
try {
|
||||
document.documentElement.innerHTML = html;
|
||||
const html = await res.text();
|
||||
const document = cheerio(html);
|
||||
|
||||
// Search for any matching value in priority order:
|
||||
// 1. Type=AP > Type=none > Type=anything
|
||||
// 2. Alternate > Canonical
|
||||
// 3. Page order (fallback)
|
||||
const alternate =
|
||||
document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ??
|
||||
document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ??
|
||||
document.querySelector('head > link[href][rel="alternate"]:not([type])') ??
|
||||
document.querySelector('head > link[href][rel="canonical"]:not([type])') ??
|
||||
document.querySelector('head > link[href][rel="alternate"]') ??
|
||||
document.querySelector('head > link[href][rel="canonical"]');
|
||||
|
||||
if (alternate) {
|
||||
const href = alternate.getAttribute('href');
|
||||
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
||||
return await this.signedGet(href, user, allowAnonymous, false);
|
||||
}
|
||||
}
|
||||
alternate = selectFirst(document, [
|
||||
'head > link[href][rel="alternate"][type="application/activity+json"]',
|
||||
'head > link[href][rel="canonical"][type="application/activity+json"]',
|
||||
'head > link[href][rel="alternate"]:not([type])',
|
||||
'head > link[href][rel="canonical"]:not([type])',
|
||||
'head > link[href][rel="alternate"]',
|
||||
'head > link[href][rel="canonical"]',
|
||||
]);
|
||||
} catch {
|
||||
// something went wrong parsing the HTML, ignore the whole thing
|
||||
} finally {
|
||||
happyDOM.close().catch(err => {});
|
||||
alternate = null;
|
||||
}
|
||||
|
||||
if (alternate) {
|
||||
const href = alternate.attr('href');
|
||||
if (href && this.apUtilityService.haveSameAuthority(url, href)) {
|
||||
return await this.signedGet(href, user, allowAnonymous, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
|
@ -285,3 +267,14 @@ export class ApRequestService {
|
|||
return activity as IObjectWithId;
|
||||
}
|
||||
}
|
||||
|
||||
function selectFirst($: CheerioAPI, selectors: string[]): Cheerio<AnyNode> | null {
|
||||
for (const selector of selectors) {
|
||||
const selection = $(selector);
|
||||
if (selection.length > 0) {
|
||||
return selection;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
|||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { isPureRenote } from '@/misc/is-renote.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
|
||||
import { ApDbResolverService } from './ApDbResolverService.js';
|
||||
import { ApRendererService } from './ApRendererService.js';
|
||||
|
|
@ -49,6 +51,7 @@ export class Resolver {
|
|||
private loggerService: LoggerService,
|
||||
private readonly apLogService: ApLogService,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly cacheService: CacheService,
|
||||
private recursionLimit = 256,
|
||||
) {
|
||||
this.history = new Set();
|
||||
|
|
@ -355,18 +358,20 @@ export class Resolver {
|
|||
|
||||
switch (parsed.type) {
|
||||
case 'notes':
|
||||
return this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() })
|
||||
return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } })
|
||||
.then(async note => {
|
||||
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
||||
const author = note.user ?? await this.cacheService.findUserById(note.userId);
|
||||
if (parsed.rest === 'activity') {
|
||||
// this refers to the create activity and not the note itself
|
||||
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
|
||||
return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
|
||||
} else if (!isPureRenote(note)) {
|
||||
const apNote = await this.apRendererService.renderNote(note, author);
|
||||
return this.apRendererService.addContext(apNote);
|
||||
} else {
|
||||
return this.apRendererService.renderNote(note, author);
|
||||
throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`);
|
||||
}
|
||||
}) as Promise<IObjectWithId>;
|
||||
case 'users':
|
||||
return this.usersRepository.findOneByOrFail({ id: parsed.id, host: IsNull() })
|
||||
return this.cacheService.findLocalUserById(parsed.id)
|
||||
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
|
||||
case 'questions':
|
||||
// Polls are indexed by the note they are attached to.
|
||||
|
|
@ -387,14 +392,8 @@ export class Resolver {
|
|||
.then(async followRequest => {
|
||||
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`);
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followerId,
|
||||
host: IsNull(),
|
||||
}),
|
||||
this.usersRepository.findOneBy({
|
||||
id: followRequest.followeeId,
|
||||
host: Not(IsNull()),
|
||||
}),
|
||||
this.cacheService.findLocalUserById(followRequest.followerId),
|
||||
this.cacheService.findLocalUserById(followRequest.followeeId),
|
||||
]);
|
||||
if (follower == null || followee == null) {
|
||||
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`);
|
||||
|
|
@ -440,6 +439,7 @@ export class ApResolverService {
|
|||
private loggerService: LoggerService,
|
||||
private readonly apLogService: ApLogService,
|
||||
private readonly apUtilityService: ApUtilityService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
|
@ -465,6 +465,7 @@ export class ApResolverService {
|
|||
this.loggerService,
|
||||
this.apLogService,
|
||||
this.apUtilityService,
|
||||
this.cacheService,
|
||||
opts?.recursionLimit,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,83 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import type { IApDocument } from '@/core/activitypub/type.js';
|
||||
import type { CheerioAPI } from 'cheerio/slim';
|
||||
|
||||
/**
|
||||
* Finds HTML elements representing inline media and returns them as simulated AP documents.
|
||||
* Returns an empty array if the input cannot be parsed, or no media was found.
|
||||
* @param html Input HTML to analyze.
|
||||
*/
|
||||
export function extractMediaFromHtml(html: string): IApDocument[] {
|
||||
const $ = parseHtml(html);
|
||||
if (!$) return [];
|
||||
|
||||
const attachments = new Map<string, IApDocument>();
|
||||
|
||||
// <img> tags, including <picture> and <object> fallback elements
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img
|
||||
$('img[src]')
|
||||
.toArray()
|
||||
.forEach(img => attachments.set(img.attribs.src, {
|
||||
type: 'Image',
|
||||
url: img.attribs.src,
|
||||
name: img.attribs.alt || img.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <object> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/object
|
||||
$('object[data]')
|
||||
.toArray()
|
||||
.forEach(object => attachments.set(object.attribs.data, {
|
||||
type: 'Document',
|
||||
url: object.attribs.data,
|
||||
name: object.attribs.alt || object.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <embed> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/embed
|
||||
$('embed[src]')
|
||||
.toArray()
|
||||
.forEach(embed => attachments.set(embed.attribs.src, {
|
||||
type: 'Document',
|
||||
url: embed.attribs.src,
|
||||
name: embed.attribs.alt || embed.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <audio> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/audio
|
||||
$('audio[src]')
|
||||
.toArray()
|
||||
.forEach(audio => attachments.set(audio.attribs.src, {
|
||||
type: 'Audio',
|
||||
url: audio.attribs.src,
|
||||
name: audio.attribs.alt || audio.attribs.title || null,
|
||||
}));
|
||||
|
||||
// <video> tags
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/video
|
||||
$('video[src]')
|
||||
.toArray()
|
||||
.forEach(audio => attachments.set(audio.attribs.src, {
|
||||
type: 'Video',
|
||||
url: audio.attribs.src,
|
||||
name: audio.attribs.alt || audio.attribs.title || null,
|
||||
}));
|
||||
|
||||
// TODO support <svg>? We would need to extract it directly from the HTML and save to a temp file.
|
||||
|
||||
return Array.from(attachments.values());
|
||||
}
|
||||
|
||||
function parseHtml(html: string): CheerioAPI | null {
|
||||
try {
|
||||
return cheerio(html);
|
||||
} catch {
|
||||
// Don't worry about invalid HTML
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { parse, inspect, extract } from 'mfm-js';
|
||||
import type { IApDocument } from '@/core/activitypub/type.js';
|
||||
import type { MfmNode, MfmText } from 'mfm-js';
|
||||
|
||||
/**
|
||||
* Finds MFM notes representing inline media and returns them as simulated AP documents.
|
||||
* Returns an empty array if the input cannot be parsed, or no media was found.
|
||||
* @param mfm Input MFM to analyze.
|
||||
*/
|
||||
export function extractMediaFromMfm(mfm: string): IApDocument[] {
|
||||
const nodes = parseMfm(mfm);
|
||||
if (nodes == null) return [];
|
||||
|
||||
const attachments = new Map<string, IApDocument>();
|
||||
|
||||
inspect(nodes, node => {
|
||||
if (node.type === 'link' && node.props.image) {
|
||||
const alt: string[] = [];
|
||||
|
||||
inspect(node.children, node => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
alt.push(node.props.text);
|
||||
break;
|
||||
case 'unicodeEmoji':
|
||||
alt.push(node.props.emoji);
|
||||
break;
|
||||
case 'emojiCode':
|
||||
alt.push(':');
|
||||
alt.push(node.props.name);
|
||||
alt.push(':');
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
attachments.set(node.props.url, {
|
||||
type: 'Image',
|
||||
url: node.props.url,
|
||||
name: alt.length > 0
|
||||
? alt.join('')
|
||||
: null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(attachments.values());
|
||||
}
|
||||
|
||||
function parseMfm(mfm: string): MfmNode[] | null {
|
||||
try {
|
||||
return parse(mfm);
|
||||
} catch {
|
||||
// Don't worry about invalid MFM
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import type { IPost } from '@/core/activitypub/type.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
|
||||
/**
|
||||
* Gets content of a specified media type from a provided object.
|
||||
*
|
||||
* Optionally supports a "permissive" mode which enables the following changes:
|
||||
* 1. MIME types are checked in a case-insensitive manner.
|
||||
* 2. MIME types are matched based on inclusion, not strict equality.
|
||||
* 3. A candidate content is considered to match if it has no specified MIME type.
|
||||
*
|
||||
* Note: this method is written defensively to protect against malform remote objects.
|
||||
* When extending or modifying it, please be sure to work with "unknown" type and validate everything.
|
||||
*
|
||||
* Note: the logic in this method is carefully ordered to match the selection priority of existing code in ApNoteService.
|
||||
* Please do not re-arrange it without testing!
|
||||
* New checks can be added to the end of the method to safely extend the existing logic.
|
||||
*
|
||||
* @param object AP object to extract content from.
|
||||
* @param mimeType MIME type to look for.
|
||||
* @param permissive Enables permissive mode, as described above. Defaults to false (disabled).
|
||||
*/
|
||||
export function getContentByType(object: IPost | Record<string, unknown>, mimeType: string, permissive = false): string | null {
|
||||
// Case 1: Extended "source" property
|
||||
if (object.source && typeof(object.source) === 'object') {
|
||||
// "source" is permitted to be an array, though no implementations are known to do this yet.
|
||||
const sources = toArray(object.source) as Record<string, unknown>[];
|
||||
for (const source of sources) {
|
||||
if (typeof (source.content) === 'string' && checkMediaType(source.mediaType)) {
|
||||
return source.content;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Special case for MFM
|
||||
if (typeof(object._misskey_content) === 'string' && mimeType === 'text/x.misskeymarkdown') {
|
||||
return object._misskey_content;
|
||||
}
|
||||
|
||||
// Case 3: AP native "content" property
|
||||
if (typeof(object.content) === 'string' && checkMediaType(object.mediaType)) {
|
||||
return object.content;
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
// Checks if the provided media type matches the input parameters.
|
||||
function checkMediaType(mediaType: unknown): boolean {
|
||||
if (typeof(mediaType) === 'string') {
|
||||
// Strict match
|
||||
if (mediaType === mimeType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Permissive match
|
||||
if (permissive && mediaType.toLowerCase().includes(mimeType.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Permissive fallback match
|
||||
if (permissive && mediaType == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No match
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -86,7 +86,7 @@ export class ApImageService {
|
|||
uri: image.url,
|
||||
sensitive: !!(image.sensitive),
|
||||
isLink: !shouldBeCached,
|
||||
comment: truncate(image.name ?? undefined, this.config.maxRemoteAltTextLength),
|
||||
comment: truncate(image.summary || image.name || undefined, this.config.maxRemoteAltTextLength),
|
||||
});
|
||||
if (!file.isLink || file.url === image.url) return file;
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import promiseLimit from 'promise-limit';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
|
|
@ -27,6 +28,9 @@ import { checkHttps } from '@/misc/check-https.js';
|
|||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { extractMediaFromHtml } from '@/core/activitypub/misc/extract-media-from-html.js';
|
||||
import { extractMediaFromMfm } from '@/core/activitypub/misc/extract-media-from-mfm.js';
|
||||
import { getContentByType } from '@/core/activitypub/misc/get-content-by-type.js';
|
||||
import { getOneApId, getApId, validPost, isEmoji, getApType, isApObject, isDocument, IApDocument, isLink } from '../type.js';
|
||||
import { ApLoggerService } from '../ApLoggerService.js';
|
||||
import { ApMfmService } from '../ApMfmService.js';
|
||||
|
|
@ -206,12 +210,10 @@ export class ApNoteService {
|
|||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
let text =
|
||||
getContentByType(note, 'text/x.misskeymarkdown') ??
|
||||
getContentByType(note, 'text/markdown');
|
||||
if (text == null && typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
|
|
@ -248,21 +250,14 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const processErrors: string[] = [];
|
||||
|
||||
// 添付ファイル
|
||||
const files: MiDriveFile[] = [];
|
||||
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, attach);
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
|
||||
const icon = getBestIcon(note);
|
||||
if (icon) {
|
||||
icon.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, icon);
|
||||
if (file) files.push(file);
|
||||
// Note: implementation moved to getAttachment function to avoid duplication.
|
||||
// Please copy any upstream changes to that method! (It's in the bottom of this class)
|
||||
const { files, hasFileError } = await this.getAttachments(note, actor);
|
||||
if (hasFileError) {
|
||||
processErrors.push('attachmentFailed');
|
||||
}
|
||||
|
||||
// リプライ
|
||||
|
|
@ -284,7 +279,9 @@ export class ApNoteService {
|
|||
|
||||
// 引用
|
||||
const quote = await this.getQuote(note, entryUri, resolver);
|
||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||
if (quote === null) {
|
||||
processErrors.push('quoteUnavailable');
|
||||
}
|
||||
|
||||
if (reply && reply.userHost == null && reply.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot reply to local-only note');
|
||||
|
|
@ -328,7 +325,7 @@ export class ApNoteService {
|
|||
files,
|
||||
reply,
|
||||
renote: quote ?? null,
|
||||
processErrors,
|
||||
processErrors: processErrors.length > 0 ? processErrors : null,
|
||||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
|
|
@ -412,12 +409,10 @@ export class ApNoteService {
|
|||
const cw = note.summary === '' ? null : note.summary;
|
||||
|
||||
// テキストのパース
|
||||
let text: string | null = null;
|
||||
if (note.source?.mediaType === 'text/x.misskeymarkdown' && typeof note.source.content === 'string') {
|
||||
text = note.source.content;
|
||||
} else if (typeof note._misskey_content !== 'undefined') {
|
||||
text = note._misskey_content;
|
||||
} else if (typeof note.content === 'string') {
|
||||
let text =
|
||||
getContentByType(note, 'text/x.misskeymarkdown') ??
|
||||
getContentByType(note, 'text/markdown');
|
||||
if (text == null && typeof note.content === 'string') {
|
||||
text = this.apMfmService.htmlToMfm(note.content, note.tag);
|
||||
}
|
||||
|
||||
|
|
@ -446,21 +441,12 @@ export class ApNoteService {
|
|||
}
|
||||
}
|
||||
|
||||
const processErrors: string[] = [];
|
||||
|
||||
// 添付ファイル
|
||||
const files: MiDriveFile[] = [];
|
||||
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, attach);
|
||||
if (file) files.push(file);
|
||||
}
|
||||
|
||||
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
|
||||
const icon = getBestIcon(note);
|
||||
if (icon) {
|
||||
icon.sensitive ??= note.sensitive;
|
||||
const file = await this.apImageService.resolveImage(actor, icon);
|
||||
if (file) files.push(file);
|
||||
const { files, hasFileError } = await this.getAttachments(note, actor);
|
||||
if (hasFileError) {
|
||||
processErrors.push('attachmentFailed');
|
||||
}
|
||||
|
||||
// リプライ
|
||||
|
|
@ -482,7 +468,9 @@ export class ApNoteService {
|
|||
|
||||
// 引用
|
||||
const quote = await this.getQuote(note, entryUri, resolver);
|
||||
const processErrors = quote === null ? ['quoteUnavailable'] : null;
|
||||
if (quote === null) {
|
||||
processErrors.push('quoteUnavailable');
|
||||
}
|
||||
|
||||
if (quote && quote.userHost == null && quote.localOnly) {
|
||||
throw new IdentifiableError('12e23cec-edd9-442b-aa48-9c21f0c3b215', 'Cannot quote a local-only note');
|
||||
|
|
@ -523,7 +511,7 @@ export class ApNoteService {
|
|||
files,
|
||||
reply,
|
||||
renote: quote ?? null,
|
||||
processErrors,
|
||||
processErrors: processErrors.length > 0 ? processErrors : null,
|
||||
name: note.name,
|
||||
cw,
|
||||
text,
|
||||
|
|
@ -722,10 +710,95 @@ export class ApNoteService {
|
|||
// Permanent error - return null
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts and saves all media attachments from the provided note.
|
||||
* Returns an array of all the created files.
|
||||
*/
|
||||
private async getAttachments(note: IPost, actor: MiRemoteUser): Promise<{ files: MiDriveFile[], hasFileError: boolean }> {
|
||||
const attachments = new Map<string, IApDocument & { url: string }>();
|
||||
|
||||
// Extract inline media from HTML content.
|
||||
// Don't use source.content, _misskey_content, or anything else because those aren't HTML.
|
||||
const htmlContent = getContentByType(note, 'text/html', true);
|
||||
if (htmlContent) {
|
||||
for (const attach of extractMediaFromHtml(htmlContent)) {
|
||||
if (hasUrl(attach)) {
|
||||
attachments.set(attach.url, attach);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract inline media from MFM / markdown content.
|
||||
const mfmContent =
|
||||
getContentByType(note, 'text/x.misskeymarkdown') ??
|
||||
getContentByType(note, 'text/markdown');
|
||||
if (mfmContent) {
|
||||
for (const attach of extractMediaFromMfm(mfmContent)) {
|
||||
if (hasUrl(attach)) {
|
||||
attachments.set(attach.url, attach);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Some software (Peertube) attaches a thumbnail under "icon" instead of "attachment"
|
||||
const icon = getBestIcon(note);
|
||||
if (icon) {
|
||||
if (hasUrl(icon)) {
|
||||
attachments.set(icon.url, icon);
|
||||
}
|
||||
}
|
||||
|
||||
// Populate AP attachments last, to overwrite any "fallback" elements that may have been inlined in HTML.
|
||||
// AP attachments should be considered canonical.
|
||||
for (const attach of toArray(note.attachment)) {
|
||||
if (hasUrl(attach)) {
|
||||
attachments.set(attach.url, attach);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve all files w/ concurrency 2.
|
||||
// This prevents one big file from blocking the others.
|
||||
const limiter = promiseLimit<MiDriveFile | null>(2);
|
||||
const results = await Promise
|
||||
.all(Array
|
||||
.from(attachments.values())
|
||||
.map(attach => limiter(async () => {
|
||||
attach.sensitive ??= note.sensitive;
|
||||
return await this.resolveImage(actor, attach);
|
||||
})));
|
||||
|
||||
// Process results
|
||||
let hasFileError = false;
|
||||
const files: MiDriveFile[] = [];
|
||||
for (const result of results) {
|
||||
if (result != null) {
|
||||
files.push(result);
|
||||
} else {
|
||||
hasFileError = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { files, hasFileError };
|
||||
}
|
||||
|
||||
private async resolveImage(actor: MiRemoteUser, attachment: IApDocument & { url: string }): Promise<MiDriveFile | null> {
|
||||
try {
|
||||
return await this.apImageService.resolveImage(actor, attachment);
|
||||
} catch (err) {
|
||||
if (isRetryableError(err)) {
|
||||
this.logger.warn(`Temporary failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
|
||||
throw err;
|
||||
} else {
|
||||
this.logger.warn(`Permanent failure to resolve attachment at ${attachment.url}: ${renderInlineError(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getBestIcon(note: IObject): IObject | null {
|
||||
const icons: IObject[] = toArray(note.icon);
|
||||
function getBestIcon(note: IObject): IApDocument | null {
|
||||
const icons: IApDocument[] = toArray(note.icon);
|
||||
if (icons.length < 2) {
|
||||
return icons[0] ?? null;
|
||||
}
|
||||
|
|
@ -741,3 +814,8 @@ function getBestIcon(note: IObject): IObject | null {
|
|||
return best;
|
||||
}, null as IApDocument | null) ?? null;
|
||||
}
|
||||
|
||||
// Need this to make TypeScript happy...
|
||||
function hasUrl<T extends IObject>(object: T): object is T & { url: string } {
|
||||
return typeof(object.url) === 'string';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ import type UsersChart from '@/core/chart/charts/users.js';
|
|||
import type InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import type { HashtagService } from '@/core/HashtagService.js';
|
||||
import { MiUserNotePining } from '@/models/UserNotePining.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type { UtilityService } from '@/core/UtilityService.js';
|
||||
import type { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
|
@ -45,6 +44,7 @@ import { HttpRequestService } from '@/core/HttpRequestService.js';
|
|||
import { verifyFieldLinks } from '@/misc/verify-field-link.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { getApId, getApType, isActor, isCollection, isCollectionOrOrderedCollection, isPropertyValue } from '../type.js';
|
||||
import { extractApHashtags } from './tag.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
|
@ -55,10 +55,8 @@ import type { ApLoggerService } from '../ApLoggerService.js';
|
|||
|
||||
import type { ApImageService } from './ApImageService.js';
|
||||
import type { IActor, ICollection, IObject, IOrderedCollection } from '../type.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
|
||||
const nameLength = 128;
|
||||
const summaryLength = 2048;
|
||||
|
||||
type Field = Record<'name' | 'value', string>;
|
||||
|
||||
|
|
@ -220,7 +218,7 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
if (!(typeof x.summary === 'string' && x.summary.length > 0)) {
|
||||
throw new UnrecoverableError(`invalid Actor ${uri}: wrong summary`);
|
||||
}
|
||||
x.summary = truncate(x.summary, summaryLength);
|
||||
x.summary = truncate(x.summary, this.config.maxRemoteBioLength);
|
||||
}
|
||||
|
||||
const idHost = this.utilityService.punyHostPSLDomain(x.id);
|
||||
|
|
@ -458,9 +456,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
let _description: string | null = null;
|
||||
|
||||
if (person._misskey_summary) {
|
||||
_description = truncate(person._misskey_summary, summaryLength);
|
||||
_description = truncate(person._misskey_summary, this.config.maxRemoteBioLength);
|
||||
} else if (person.summary) {
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
|
||||
}
|
||||
|
||||
await transactionalEntityManager.save(new MiUserProfile({
|
||||
|
|
@ -575,7 +573,6 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
if (exist === null) return;
|
||||
//#endregion
|
||||
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||
|
||||
const object = hint ?? await resolver.resolve(uri);
|
||||
|
|
@ -717,9 +714,9 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
let _description: string | null = null;
|
||||
|
||||
if (person._misskey_summary) {
|
||||
_description = truncate(person._misskey_summary, summaryLength);
|
||||
_description = truncate(person._misskey_summary, this.config.maxRemoteBioLength);
|
||||
} else if (person.summary) {
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, summaryLength), person.tag);
|
||||
_description = this.apMfmService.htmlToMfm(truncate(person.summary, this.config.maxRemoteBioLength), person.tag);
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update({ userId: exist.id }, {
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export interface IObject {
|
|||
cc?: ApObject;
|
||||
to?: ApObject;
|
||||
attributedTo?: ApObject;
|
||||
attachment?: any[];
|
||||
attachment?: IApDocument[];
|
||||
inReplyTo?: any;
|
||||
replies?: ICollection | IOrderedCollection | string;
|
||||
content?: string | null;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,7 @@ export class ChartManagementService implements OnApplicationShutdown {
|
|||
public async dispose(): Promise<void> {
|
||||
clearInterval(this.saveIntervalId);
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.logger.info('Saving charts for shutdown...');
|
||||
for (const chart of this.charts) {
|
||||
await chart.save();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export class InstanceEntityService {
|
|||
me?: { id: MiUser['id']; } | null | undefined,
|
||||
): Promise<Packed<'FederationInstance'>> {
|
||||
const iAmModerator = me ? await this.roleService.isModerator(me as MiUser) : false;
|
||||
const softwareSuspended = this.utilityService.isDeliverSuspendedSoftware(instance);
|
||||
|
||||
return {
|
||||
id: instance.id,
|
||||
|
|
@ -45,8 +46,8 @@ export class InstanceEntityService {
|
|||
followingCount: instance.followingCount,
|
||||
followersCount: instance.followersCount,
|
||||
isNotResponding: instance.isNotResponding,
|
||||
isSuspended: instance.suspensionState !== 'none',
|
||||
suspensionState: instance.suspensionState,
|
||||
isSuspended: instance.suspensionState !== 'none' || Boolean(softwareSuspended),
|
||||
suspensionState: instance.suspensionState === 'none' && softwareSuspended ? 'softwareSuspended' : instance.suspensionState,
|
||||
isBlocked: instance.isBlocked,
|
||||
softwareName: instance.softwareName,
|
||||
softwareVersion: instance.softwareVersion,
|
||||
|
|
|
|||
|
|
@ -117,6 +117,8 @@ export class MetaEntityService {
|
|||
maxRemoteCwLength: this.config.maxRemoteCwLength,
|
||||
maxAltTextLength: this.config.maxAltTextLength,
|
||||
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
|
||||
maxBioLength: this.config.maxBioLength,
|
||||
maxRemoteBioLength: this.config.maxRemoteBioLength,
|
||||
defaultLightTheme,
|
||||
defaultDarkTheme,
|
||||
defaultLike: instance.defaultLike,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,12 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing, NoteFavoritesRepository } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { isPackedPureRenote } from '@/misc/is-renote.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
|
@ -55,6 +56,7 @@ function getAppearNoteIds(notes: MiNote[]): Set<string> {
|
|||
return appearNoteIds;
|
||||
}
|
||||
|
||||
// noinspection ES6MissingAwait
|
||||
@Injectable()
|
||||
export class NoteEntityService implements OnModuleInit {
|
||||
private userEntityService: UserEntityService;
|
||||
|
|
@ -96,6 +98,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
@Inject(DI.config)
|
||||
private readonly config: Config,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
//private userEntityService: UserEntityService,
|
||||
//private driveFileEntityService: DriveFileEntityService,
|
||||
//private customEmojiService: CustomEmojiService,
|
||||
|
|
@ -131,9 +137,21 @@ export class NoteEntityService implements OnModuleInit {
|
|||
return packedNote.visibility;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async hideNotes(notes: Packed<'Note'>[], meId: string | null): Promise<void> {
|
||||
const myFollowing = meId ? new Map(await this.cacheService.userFollowingsCache.fetch(meId)) : new Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>();
|
||||
const myBlockers = meId ? new Set(await this.cacheService.userBlockedCache.fetch(meId)) : new Set<string>();
|
||||
|
||||
// This shouldn't actually await, but we have to wrap it anyway because hideNote() is async
|
||||
await Promise.all(notes.map(note => this.hideNote(note, meId, {
|
||||
myFollowing,
|
||||
myBlockers,
|
||||
})));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
|
||||
myFollowing?: ReadonlyMap<string, unknown>,
|
||||
myFollowing?: ReadonlyMap<string, Omit<MiFollowing, 'isFollowerHibernated'>> | ReadonlySet<string>,
|
||||
myBlockers?: ReadonlySet<string>,
|
||||
}): Promise<void> {
|
||||
if (meId === packedNote.userId) return;
|
||||
|
|
@ -275,6 +293,142 @@ export class NoteEntityService implements OnModuleInit {
|
|||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyNoteMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
|
||||
const mutedNotes = await this.cacheService.noteMutingsCache.fetch(meId);
|
||||
|
||||
const mutedIds = notes
|
||||
.filter(note => mutedNotes.has(note.id))
|
||||
.map(note => note.id);
|
||||
return new Set(mutedIds);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyTheadMutings(notes: Packed<'Note'>[], meId: string): Promise<Set<string>> {
|
||||
const mutedThreads = await this.cacheService.threadMutingsCache.fetch(meId);
|
||||
|
||||
const mutedIds = notes
|
||||
.filter(note => mutedThreads.has(note.threadId))
|
||||
.map(note => note.id);
|
||||
return new Set(mutedIds);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyRenotes(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||
myRenotes: Set<string>;
|
||||
}): Promise<Set<string>> {
|
||||
const fetchedRenotes = new Set<string>();
|
||||
const toFetch = new Set<string>();
|
||||
|
||||
if (_hint_) {
|
||||
for (const note of notes) {
|
||||
if (_hint_.myRenotes.has(note.id)) {
|
||||
fetchedRenotes.add(note.id);
|
||||
} else {
|
||||
toFetch.add(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.size > 0) {
|
||||
const fetched = await this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({
|
||||
userId: meId,
|
||||
renoteId: In(Array.from(toFetch)),
|
||||
})
|
||||
.select('note.renoteId', 'renoteId')
|
||||
.getRawMany<{ renoteId: string }>();
|
||||
|
||||
for (const { renoteId } of fetched) {
|
||||
fetchedRenotes.add(renoteId);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedRenotes;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyFavorites(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||
myFavorites: Set<string>;
|
||||
}): Promise<Set<string>> {
|
||||
const fetchedFavorites = new Set<string>();
|
||||
const toFetch = new Set<string>();
|
||||
|
||||
if (_hint_) {
|
||||
for (const note of notes) {
|
||||
if (_hint_.myFavorites.has(note.id)) {
|
||||
fetchedFavorites.add(note.id);
|
||||
} else {
|
||||
toFetch.add(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.size > 0) {
|
||||
const fetched = await this.noteFavoritesRepository.find({
|
||||
where: {
|
||||
userId: meId,
|
||||
noteId: In(Array.from(toFetch)),
|
||||
},
|
||||
select: {
|
||||
noteId: true,
|
||||
},
|
||||
}) as { noteId: string }[];
|
||||
|
||||
for (const { noteId } of fetched) {
|
||||
fetchedFavorites.add(noteId);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedFavorites;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyReactions(notes: Packed<'Note'>[], meId: string, _hint_?: {
|
||||
myReactions: Map<MiNote['id'], string | null>;
|
||||
}): Promise<Map<string, string>> {
|
||||
const fetchedReactions = new Map<string, string>();
|
||||
const toFetch = new Set<string>();
|
||||
|
||||
if (_hint_) {
|
||||
for (const note of notes) {
|
||||
const fromHint = _hint_.myReactions.get(note.id);
|
||||
|
||||
// null means we know there's no reaction, so just skip it.
|
||||
if (fromHint === null) continue;
|
||||
|
||||
if (fromHint) {
|
||||
const converted = this.reactionService.convertLegacyReaction(fromHint);
|
||||
fetchedReactions.set(note.id, converted);
|
||||
} else if (Object.values(note.reactions).some(count => count > 0)) {
|
||||
// Note has at least one reaction, so we need to fetch
|
||||
toFetch.add(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.size > 0) {
|
||||
const fetched = await this.noteReactionsRepository.find({
|
||||
where: {
|
||||
userId: meId,
|
||||
noteId: In(Array.from(toFetch)),
|
||||
},
|
||||
select: {
|
||||
noteId: true,
|
||||
reaction: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const { noteId, reaction } of fetched) {
|
||||
const converted = this.reactionService.convertLegacyReaction(reaction);
|
||||
fetchedReactions.set(noteId, converted);
|
||||
}
|
||||
}
|
||||
|
||||
return fetchedReactions;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async populateMyReaction(note: { id: MiNote['id']; reactions: MiNote['reactions']; reactionAndUserPairCache?: MiNote['reactionAndUserPairCache']; }, meId: MiUser['id'], _hint_?: {
|
||||
myReactions: Map<MiNote['id'], string | null>;
|
||||
|
|
@ -306,9 +460,14 @@ export class NoteEntityService implements OnModuleInit {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
const reaction = await this.noteReactionsRepository.findOneBy({
|
||||
userId: meId,
|
||||
noteId: note.id,
|
||||
const reaction = await this.noteReactionsRepository.findOne({
|
||||
where: {
|
||||
userId: meId,
|
||||
noteId: note.id,
|
||||
},
|
||||
select: {
|
||||
reaction: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (reaction) {
|
||||
|
|
@ -422,6 +581,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
pollVotes: Map<string, Map<string, MiPollVote[]>>;
|
||||
channels: Map<string, MiChannel>;
|
||||
notes: Map<string, MiNote>;
|
||||
mutedThreads: Set<string>;
|
||||
mutedNotes: Set<string>;
|
||||
favoriteNotes: Set<string>;
|
||||
renotedNotes: Set<string>;
|
||||
};
|
||||
},
|
||||
): Promise<Packed<'Note'>> {
|
||||
|
|
@ -460,8 +623,28 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const packedFiles = options?._hint_?.packedFiles;
|
||||
const packedUsers = options?._hint_?.packedUsers;
|
||||
|
||||
const threadId = note.threadId ?? note.id;
|
||||
const [mutedThreads, mutedNotes, isFavorited, isRenoted] = await Promise.all([
|
||||
// mutedThreads
|
||||
opts._hint_?.mutedThreads
|
||||
?? (meId ? this.cacheService.threadMutingsCache.fetch(meId) : new Set<string>()),
|
||||
// mutedNotes
|
||||
opts._hint_?.mutedNotes
|
||||
?? (meId ? this.cacheService.noteMutingsCache.fetch(meId) : new Set<string>),
|
||||
// isFavorited
|
||||
opts._hint_?.favoriteNotes.has(note.id)
|
||||
?? (meId ? this.noteFavoritesRepository.existsBy({ userId: meId, noteId: note.id }) : false),
|
||||
// isRenoted
|
||||
opts._hint_?.renotedNotes.has(note.id)
|
||||
?? (meId ? this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ renoteId: note.id, userId: meId })
|
||||
.getExists() : false),
|
||||
]);
|
||||
|
||||
const packed: Packed<'Note'> = await awaitAll({
|
||||
id: note.id,
|
||||
threadId,
|
||||
createdAt: this.idService.parse(note.id).date.toISOString(),
|
||||
updatedAt: note.updatedAt ? note.updatedAt.toISOString() : undefined,
|
||||
userId: note.userId,
|
||||
|
|
@ -501,6 +684,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
poll: opts._hint_?.polls.get(note.id),
|
||||
myVotes: opts._hint_?.pollVotes.get(note.id)?.get(note.userId),
|
||||
}) : undefined,
|
||||
isMutingThread: mutedThreads.has(threadId),
|
||||
isMutingNote: mutedNotes.has(note.id),
|
||||
isFavorited,
|
||||
isRenoted,
|
||||
|
||||
...(meId && Object.keys(reactions).length > 0 ? {
|
||||
myReaction: this.populateMyReaction({
|
||||
|
|
@ -648,7 +835,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
const fileIds = new Set(targetNotes.flatMap(n => n.fileIds));
|
||||
const mentionedUsers = new Set(targetNotes.flatMap(note => note.mentions));
|
||||
|
||||
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels] = await Promise.all([
|
||||
const [{ bufferedReactions, myReactionsMap }, packedFiles, packedUsers, mentionHandles, userFollowings, userBlockers, polls, pollVotes, channels, mutedThreads, mutedNotes, favoriteNotes, renotedNotes] = await Promise.all([
|
||||
// bufferedReactions & myReactionsMap
|
||||
this.getReactions(targetNotes, me),
|
||||
// packedFiles
|
||||
|
|
@ -659,6 +846,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
// mentionHandles
|
||||
this.getUserHandles(Array.from(mentionedUsers)),
|
||||
// userFollowings
|
||||
// TODO this might be wrong
|
||||
this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)),
|
||||
// userBlockers
|
||||
this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)),
|
||||
|
|
@ -683,6 +871,24 @@ export class NoteEntityService implements OnModuleInit {
|
|||
}, new Map<string, Map<string, MiPollVote[]>>)),
|
||||
// channels
|
||||
this.getChannels(targetNotes),
|
||||
// mutedThreads
|
||||
me ? this.cacheService.threadMutingsCache.fetch(me.id) : new Set<string>(),
|
||||
// mutedNotes
|
||||
me ? this.cacheService.noteMutingsCache.fetch(me.id) : new Set<string>(),
|
||||
// favoriteNotes
|
||||
me ? this.noteFavoritesRepository
|
||||
.createQueryBuilder('favorite')
|
||||
.select('favorite.noteId', 'noteId')
|
||||
.where({ userId: me.id, noteId: In(noteIds) })
|
||||
.getRawMany<{ noteId: string }>()
|
||||
.then(fs => new Set(fs.map(f => f.noteId))) : new Set<string>(),
|
||||
// renotedNotes
|
||||
me ? this.queryService
|
||||
.andIsRenote(this.notesRepository.createQueryBuilder('note'), 'note')
|
||||
.andWhere({ userId: me.id, renoteId: In(noteIds) })
|
||||
.select('note.renoteId', 'renoteId')
|
||||
.getRawMany<{ renoteId: string }>()
|
||||
.then(ns => new Set(ns.map(n => n.renoteId))) : new Set<string>(),
|
||||
// (not returned)
|
||||
this.customEmojiService.prefetchEmojis(this.aggregateNoteEmojis(notes)),
|
||||
]);
|
||||
|
|
@ -701,6 +907,10 @@ export class NoteEntityService implements OnModuleInit {
|
|||
pollVotes,
|
||||
channels,
|
||||
notes: new Map(targetNotes.map(n => [n.id, n])),
|
||||
mutedThreads,
|
||||
mutedNotes,
|
||||
favoriteNotes,
|
||||
renotedNotes,
|
||||
},
|
||||
})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -432,8 +432,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
userIdsByUri?: Map<string, string>,
|
||||
instances?: Map<string, MiInstance | null>,
|
||||
securityKeyCounts?: Map<string, number>,
|
||||
pendingReceivedFollows?: Set<string>,
|
||||
pendingSentFollows?: Set<string>,
|
||||
},
|
||||
): Promise<Packed<S>> {
|
||||
const opts = Object.assign({
|
||||
|
|
@ -679,8 +677,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),
|
||||
hasUnreadChannel: false, // 後方互換性のため
|
||||
hasUnreadNotification: notificationsInfo?.hasUnread, // 後方互換性のため
|
||||
hasPendingReceivedFollowRequest: opts.pendingReceivedFollows?.has(user.id) ?? this.getHasPendingReceivedFollowRequest(user.id),
|
||||
hasPendingSentFollowRequest: opts.pendingSentFollows?.has(user.id) ?? this.getHasPendingSentFollowRequest(user.id),
|
||||
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
|
||||
hasPendingSentFollowRequest: this.getHasPendingSentFollowRequest(user.id),
|
||||
unreadNotificationsCount: notificationsInfo?.unreadCount,
|
||||
mutedWords: profile!.mutedWords,
|
||||
hardMutedWords: profile!.hardMutedWords,
|
||||
|
|
@ -761,11 +759,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
const iAmModerator = await this.roleService.isModerator(me as MiUser);
|
||||
const meId = me ? me.id : null;
|
||||
const isMe = meId && _userIds.includes(meId);
|
||||
const isDetailed = options && options.schema !== 'UserLite';
|
||||
const isDetailedAndMe = isDetailed && isMe;
|
||||
const isDetailedAndMeOrMod = isDetailed && (isMe || iAmModerator);
|
||||
const isDetailedAndNotMe = isDetailed && !isMe;
|
||||
const isDetailedAndMod = isDetailed && iAmModerator;
|
||||
|
||||
const userUris = new Set(_users
|
||||
.flatMap(user => [user.uri, user.movedToUri])
|
||||
|
|
@ -787,14 +782,14 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
// -- 実行者の有無や指定スキーマの種別によって要否が異なる値群を取得
|
||||
|
||||
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, pendingReceivedFollows, pendingSentFollows] = await Promise.all([
|
||||
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts] = await Promise.all([
|
||||
// profilesMap
|
||||
this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))),
|
||||
// userMemos
|
||||
isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId })
|
||||
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(),
|
||||
// userRelations
|
||||
isDetailedAndNotMe && meId ? this.getRelations(meId, _userIds) : new Map(),
|
||||
isDetailed && meId ? this.getRelations(meId, _userIds) : new Map(),
|
||||
// pinNotes
|
||||
isDetailed ? this.userNotePiningsRepository.createQueryBuilder('pin')
|
||||
.where('pin.userId IN (:...userIds)', { userIds: _userIds })
|
||||
|
|
@ -828,7 +823,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
Promise.all(Array.from(userHosts).map(async host => [host, await this.federatedInstanceService.fetch(host)] as const))
|
||||
.then(hosts => new Map(hosts)),
|
||||
// securityKeyCounts
|
||||
isDetailedAndMeOrMod ? this.userSecurityKeysRepository.createQueryBuilder('key')
|
||||
isDetailedAndMod ? this.userSecurityKeysRepository.createQueryBuilder('key')
|
||||
.select('key.userId', 'userId')
|
||||
.addSelect('count(key.id)', 'userCount')
|
||||
.where({
|
||||
|
|
@ -836,26 +831,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
})
|
||||
.groupBy('key.userId')
|
||||
.getRawMany<{ userId: string, userCount: number }>()
|
||||
.then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) : new Map(),
|
||||
// TODO optimization: cache follow requests
|
||||
// pendingReceivedFollows
|
||||
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
|
||||
.select('req.followeeId', 'followeeId')
|
||||
.where({
|
||||
followeeId: In(_userIds),
|
||||
})
|
||||
.groupBy('req.followeeId')
|
||||
.getRawMany<{ followeeId: string }>()
|
||||
.then(reqs => new Set(reqs.map(r => r.followeeId))) : new Set<string>(),
|
||||
// pendingSentFollows
|
||||
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
|
||||
.select('req.followerId', 'followerId')
|
||||
.where({
|
||||
followerId: In(_userIds),
|
||||
})
|
||||
.groupBy('req.followerId')
|
||||
.getRawMany<{ followerId: string }>()
|
||||
.then(reqs => new Set(reqs.map(r => r.followerId))) : new Set<string>(),
|
||||
.then(counts => new Map(counts.map(c => [c.userId, c.userCount])))
|
||||
: undefined, // .pack will fetch the keys for the requesting user if it's in the _userIds
|
||||
]);
|
||||
|
||||
return Promise.all(
|
||||
|
|
@ -872,8 +849,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
userIdsByUri,
|
||||
instances,
|
||||
securityKeyCounts,
|
||||
pendingReceivedFollows,
|
||||
pendingSentFollows,
|
||||
},
|
||||
)),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,13 +13,32 @@ import type { Config } from '@/config.js';
|
|||
import { QUEUE, baseQueueOptions } from '@/queue/const.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
export interface StatsEntry {
|
||||
activeSincePrevTick: number,
|
||||
active: number,
|
||||
waiting: number,
|
||||
delayed: number,
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
deliver: StatsEntry,
|
||||
inbox: StatsEntry,
|
||||
}
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
const interval = 10000;
|
||||
|
||||
@Injectable()
|
||||
export class QueueStatsService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timeout;
|
||||
private intervalId?: NodeJS.Timeout;
|
||||
private activeDeliverJobs = 0;
|
||||
private activeInboxJobs = 0;
|
||||
|
||||
private deliverQueueEvents?: Bull.QueueEvents;
|
||||
private inboxQueueEvents?: Bull.QueueEvents;
|
||||
|
||||
private log?: Stats[];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
|
@ -29,30 +48,39 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private onDeliverActive() {
|
||||
this.activeDeliverJobs++;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private onInboxActive() {
|
||||
this.activeInboxJobs++;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private onRequestQueueStatsLog(x: { id: string, length?: number }) {
|
||||
if (this.log) {
|
||||
ev.emit(`queueStatsLog:${x.id}`, this.log.slice(0, x.length ?? 50));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report queue stats regularly
|
||||
*/
|
||||
@bindThis
|
||||
public start(): void {
|
||||
const log = [] as any[];
|
||||
public async start() {
|
||||
// Just in case start gets called repeatedly
|
||||
await this.stop();
|
||||
|
||||
ev.on('requestQueueStatsLog', x => {
|
||||
ev.emit(`queueStatsLog:${x.id}`, log.slice(0, x.length ?? 50));
|
||||
});
|
||||
this.log = [];
|
||||
ev.on('requestQueueStatsLog', this.onRequestQueueStatsLog);
|
||||
|
||||
let activeDeliverJobs = 0;
|
||||
let activeInboxJobs = 0;
|
||||
this.deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
|
||||
this.inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
|
||||
|
||||
const deliverQueueEvents = new Bull.QueueEvents(QUEUE.DELIVER, baseQueueOptions(this.config, QUEUE.DELIVER));
|
||||
const inboxQueueEvents = new Bull.QueueEvents(QUEUE.INBOX, baseQueueOptions(this.config, QUEUE.INBOX));
|
||||
|
||||
deliverQueueEvents.on('active', () => {
|
||||
activeDeliverJobs++;
|
||||
});
|
||||
|
||||
inboxQueueEvents.on('active', () => {
|
||||
activeInboxJobs++;
|
||||
});
|
||||
this.deliverQueueEvents.on('active', this.onDeliverActive);
|
||||
this.inboxQueueEvents.on('active', this.onInboxActive);
|
||||
|
||||
const tick = async () => {
|
||||
const deliverJobCounts = await this.queueService.deliverQueue.getJobCounts();
|
||||
|
|
@ -60,13 +88,13 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
|
||||
const stats = {
|
||||
deliver: {
|
||||
activeSincePrevTick: activeDeliverJobs,
|
||||
activeSincePrevTick: this.activeDeliverJobs,
|
||||
active: deliverJobCounts.active,
|
||||
waiting: deliverJobCounts.waiting,
|
||||
delayed: deliverJobCounts.delayed,
|
||||
},
|
||||
inbox: {
|
||||
activeSincePrevTick: activeInboxJobs,
|
||||
activeSincePrevTick: this.activeInboxJobs,
|
||||
active: inboxJobCounts.active,
|
||||
waiting: inboxJobCounts.waiting,
|
||||
delayed: inboxJobCounts.delayed,
|
||||
|
|
@ -75,11 +103,13 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
|
||||
ev.emit('queueStats', stats);
|
||||
|
||||
log.unshift(stats);
|
||||
if (log.length > 200) log.pop();
|
||||
if (this.log) {
|
||||
this.log.unshift(stats);
|
||||
if (this.log.length > 200) this.log.pop();
|
||||
}
|
||||
|
||||
activeDeliverJobs = 0;
|
||||
activeInboxJobs = 0;
|
||||
this.activeDeliverJobs = 0;
|
||||
this.activeInboxJobs = 0;
|
||||
};
|
||||
|
||||
tick();
|
||||
|
|
@ -88,12 +118,32 @@ export class QueueStatsService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
clearInterval(this.intervalId);
|
||||
public async stop() {
|
||||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
this.log = undefined;
|
||||
ev.off('requestQueueStatsLog', this.onRequestQueueStatsLog);
|
||||
|
||||
this.deliverQueueEvents?.off('active', this.onDeliverActive);
|
||||
this.inboxQueueEvents?.off('active', this.onInboxActive);
|
||||
|
||||
await this.deliverQueueEvents?.close();
|
||||
await this.inboxQueueEvents?.close();
|
||||
|
||||
this.activeDeliverJobs = 0;
|
||||
this.activeInboxJobs = 0;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public onApplicationShutdown(signal?: string | undefined): void {
|
||||
this.dispose();
|
||||
public async dispose() {
|
||||
await this.stop();
|
||||
ev.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(signal?: string | undefined) {
|
||||
await this.dispose();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,22 @@ import type { OnApplicationShutdown } from '@nestjs/common';
|
|||
import { MiMeta } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
export interface Stats {
|
||||
cpu: number,
|
||||
mem: {
|
||||
used: number,
|
||||
active: number,
|
||||
},
|
||||
net: {
|
||||
rx: number,
|
||||
tx: number,
|
||||
},
|
||||
fs: {
|
||||
r: number,
|
||||
w: number,
|
||||
},
|
||||
}
|
||||
|
||||
const ev = new Xev();
|
||||
|
||||
const interval = 2000;
|
||||
|
|
@ -23,12 +39,19 @@ const round = (num: number) => Math.round(num * 10) / 10;
|
|||
export class ServerStatsService implements OnApplicationShutdown {
|
||||
private intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
private log: Stats[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
) {
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onRequestStatsLog(x: { id: string, length: number }) {
|
||||
ev.emit(`serverStatsLog:${x.id}`, this.log.slice(0, x.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Report server stats regularly
|
||||
*/
|
||||
|
|
@ -36,11 +59,8 @@ export class ServerStatsService implements OnApplicationShutdown {
|
|||
public async start(): Promise<void> {
|
||||
if (!this.meta.enableServerMachineStats) return;
|
||||
|
||||
const log = [] as any[];
|
||||
|
||||
ev.on('requestServerStatsLog', x => {
|
||||
ev.emit(`serverStatsLog:${x.id}`, log.slice(0, x.length));
|
||||
});
|
||||
this.log = [];
|
||||
ev.on('requestServerStatsLog', this.onRequestStatsLog);
|
||||
|
||||
const tick = async () => {
|
||||
const cpu = await cpuUsage();
|
||||
|
|
@ -64,8 +84,8 @@ export class ServerStatsService implements OnApplicationShutdown {
|
|||
},
|
||||
};
|
||||
ev.emit('serverStats', stats);
|
||||
log.unshift(stats);
|
||||
if (log.length > 200) log.pop();
|
||||
this.log.unshift(stats);
|
||||
if (this.log.length > 200) this.log.pop();
|
||||
};
|
||||
|
||||
tick();
|
||||
|
|
@ -78,6 +98,11 @@ export class ServerStatsService implements OnApplicationShutdown {
|
|||
if (this.intervalId) {
|
||||
clearInterval(this.intervalId);
|
||||
}
|
||||
|
||||
this.log = [];
|
||||
ev.off('requestServerStatsLog', this.onRequestStatsLog);
|
||||
|
||||
ev.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -89,9 +114,13 @@ export class ServerStatsService implements OnApplicationShutdown {
|
|||
// CPU STAT
|
||||
function cpuUsage(): Promise<number> {
|
||||
return new Promise((res, rej) => {
|
||||
osUtils.cpuUsage((cpuUsage) => {
|
||||
res(cpuUsage);
|
||||
});
|
||||
try {
|
||||
osUtils.cpuUsage((cpuUsage) => {
|
||||
res(cpuUsage);
|
||||
});
|
||||
} catch (err) {
|
||||
rej(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { unique } from '@/misc/prelude/array.js';
|
||||
|
||||
export function extractCustomEmojisFromMfm(nodes: mfm.MfmNode[]): string[] {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { unique } from '@/misc/prelude/array.js';
|
||||
|
||||
export function extractHashtags(nodes: mfm.MfmNode[]): string[] {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
|
||||
// test is located in test/extract-mentions
|
||||
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
|
||||
export function extractMentions(nodes: mfm.MfmNode[]): mfm.MfmMention['props'][] {
|
||||
// TODO: 重複を削除
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import { packedChatMessageSchema, packedChatMessageLiteSchema, packedChatMessage
|
|||
import { packedChatRoomSchema } from '@/models/json-schema/chat-room.js';
|
||||
import { packedChatRoomInvitationSchema } from '@/models/json-schema/chat-room-invitation.js';
|
||||
import { packedChatRoomMembershipSchema } from '@/models/json-schema/chat-room-membership.js';
|
||||
import { packedAchievementNameSchema, packedAchievementSchema } from '@/models/json-schema/achievement.js';
|
||||
|
||||
export const refs = {
|
||||
UserLite: packedUserLiteSchema,
|
||||
|
|
@ -78,6 +79,8 @@ export const refs = {
|
|||
User: packedUserSchema,
|
||||
|
||||
UserList: packedUserListSchema,
|
||||
Achievement: packedAchievementSchema,
|
||||
AchievementName: packedAchievementNameSchema,
|
||||
Ad: packedAdSchema,
|
||||
Announcement: packedAnnouncementSchema,
|
||||
App: packedAppSchema,
|
||||
|
|
|
|||
|
|
@ -3,14 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { substring } from 'stringz';
|
||||
|
||||
export function truncate(input: string, size: number): string;
|
||||
export function truncate(input: string | undefined, size: number): string | undefined;
|
||||
export function truncate(input: string | undefined, size: number): string | undefined {
|
||||
if (!input) {
|
||||
return input;
|
||||
} else {
|
||||
return substring(input, 0, size);
|
||||
return input.slice(0, size);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { load as cheerio } from 'cheerio';
|
||||
import { load as cheerio } from 'cheerio/slim';
|
||||
import type { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
|
||||
type Field = { name: string, value: string };
|
||||
|
|
|
|||
|
|
@ -770,4 +770,14 @@ export class MiMeta {
|
|||
default: false,
|
||||
})
|
||||
public enableProxyAccount: boolean;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
public deliverSuspendedSoftware: SoftwareSuspension[];
|
||||
}
|
||||
|
||||
export type SoftwareSuspension = {
|
||||
software: string,
|
||||
versionRange: string,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,16 @@ import { MiUser } from './User.js';
|
|||
import { MiChannel } from './Channel.js';
|
||||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
// Note: When you create a new index for existing column of this table,
|
||||
// it might be better to index concurrently under isConcurrentIndexMigrationEnabled flag
|
||||
// by editing generated migration file since this table is very large,
|
||||
// and it will make a long lock to create index in most cases.
|
||||
// Please note that `CREATE INDEX CONCURRENTLY` is not supported in transaction,
|
||||
// so you need to set `transaction = false` in migration if isConcurrentIndexMigrationEnabled() is true.
|
||||
// Please refer 1745378064470-composite-note-index.js for example.
|
||||
// You should not use `@Index({ concurrent: true })` decorator because database initialization for test will fail
|
||||
// because it will always run CREATE INDEX in transaction based on decorators.
|
||||
// Not appending `{ concurrent: true }` to `@Index` will not cause any problem in production,
|
||||
@Index('IDX_724b311e6f883751f261ebe378', ['userId', 'id'])
|
||||
@Index('IDX_note_userHost_id', { synchronize: false }) // (userHost, id desc)
|
||||
@Index('IDX_note_for_timelines', { synchronize: false }) // (id desc, channelId, visibility, userHost)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { id } from './util/id.js';
|
|||
import { MiUser } from './User.js';
|
||||
|
||||
@Entity('note_thread_muting')
|
||||
@Index(['userId', 'threadId'], { unique: true })
|
||||
@Index(['userId', 'threadId', 'isPostMute'], { unique: true })
|
||||
export class MiNoteThreadMuting {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
|
@ -30,4 +30,10 @@ export class MiNoteThreadMuting {
|
|||
length: 256,
|
||||
})
|
||||
public threadId: string;
|
||||
|
||||
@Column('boolean', {
|
||||
comment: 'If true, then this mute applies only to the referenced note. If false (default), then it applies to all replies as well.',
|
||||
default: false,
|
||||
})
|
||||
public isPostMute: boolean;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typ
|
|||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
// TODO: 同じdomain、同じscope、同じkeyのレコードは二つ以上存在しないように制約付けたい
|
||||
@Entity('registry_item')
|
||||
@Index(['userId', 'key', 'scope', 'domain'], { unique: true })
|
||||
export class MiRegistryItem {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
|
|
|
|||
|
|
@ -433,7 +433,7 @@ export type MiPartialRemoteUser = Partial<MiUser> & {
|
|||
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
|
||||
export const passwordSchema = { type: 'string', minLength: 1 } as const;
|
||||
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
||||
export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const;
|
||||
export const descriptionSchema = { type: 'string', minLength: 1 } as const;
|
||||
export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const;
|
||||
export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
||||
export const listenbrainzSchema = { type: 'string', minLength: 1, maxLength: 128 } as const;
|
||||
|
|
|
|||
|
|
@ -43,8 +43,8 @@ export class MiUserProfile {
|
|||
})
|
||||
public listenbrainz: string | null;
|
||||
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
@Column('text', {
|
||||
nullable: true,
|
||||
comment: 'The description (bio) of the User.',
|
||||
})
|
||||
public description: string | null;
|
||||
|
|
@ -288,7 +288,7 @@ export class MiUserProfile {
|
|||
default: [],
|
||||
})
|
||||
public achievements: {
|
||||
name: string;
|
||||
name: typeof ACHIEVEMENT_TYPES[number];
|
||||
unlockedAt: number;
|
||||
}[];
|
||||
|
||||
|
|
@ -322,3 +322,84 @@ export class MiUserProfile {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
25
packages/backend/src/models/json-schema/achievement.ts
Normal file
25
packages/backend/src/models/json-schema/achievement.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
|
||||
|
||||
export const packedAchievementNameSchema = {
|
||||
type: 'string',
|
||||
enum: ACHIEVEMENT_TYPES,
|
||||
optional: false,
|
||||
} as const;
|
||||
|
||||
export const packedAchievementSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
ref: 'AchievementName',
|
||||
},
|
||||
unlockedAt: {
|
||||
type: 'number',
|
||||
optional: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -48,7 +48,7 @@ export const packedFederationInstanceSchema = {
|
|||
suspensionState: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding'],
|
||||
enum: ['none', 'manuallySuspended', 'goneSuspended', 'autoSuspendedForNotResponding', 'softwareSuspended'],
|
||||
},
|
||||
isBlocked: {
|
||||
type: 'boolean',
|
||||
|
|
|
|||
|
|
@ -206,6 +206,14 @@ export const packedMetaLiteSchema = {
|
|||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxBioLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
maxRemoteBioLength: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
ads: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ export const packedNoteSchema = {
|
|||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
threadId: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
format: 'id',
|
||||
example: 'xxxxxxxxxx',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -167,6 +173,22 @@ export const packedNoteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
isMutingThread: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isMutingNote: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isFavorited: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
isRenoted: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
emojis: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
|
||||
import { notificationTypes, userExportableEntities } from '@/types.js';
|
||||
|
||||
const baseSchema = {
|
||||
|
|
@ -312,9 +311,7 @@ export const packedNotificationSchema = {
|
|||
enum: ['achievementEarned'],
|
||||
},
|
||||
achievement: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
enum: ACHIEVEMENT_TYPES,
|
||||
ref: 'AchievementName',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,20 @@ export const packedUserLiteSchema = {
|
|||
example: 'misskey.example.com',
|
||||
description: 'The local host is represented with `null`.',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
approved: {
|
||||
type: 'boolean',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
example: 'Hi masters, I am Ai!',
|
||||
},
|
||||
avatarUrl: {
|
||||
type: 'string',
|
||||
format: 'url',
|
||||
|
|
@ -206,6 +220,18 @@ export const packedUserLiteSchema = {
|
|||
},
|
||||
},
|
||||
},
|
||||
followersCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
followingCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
notesCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
emojis: {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
|
|
@ -278,11 +304,6 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
|
|
@ -324,11 +345,6 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
nullable: false, optional: false,
|
||||
example: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
example: 'Hi masters, I am Ai!',
|
||||
},
|
||||
location: {
|
||||
type: 'string',
|
||||
nullable: true, optional: false,
|
||||
|
|
@ -377,18 +393,6 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
format: 'url',
|
||||
},
|
||||
},
|
||||
followersCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
followingCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
notesCount: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
pinnedNoteIds: {
|
||||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
|
|
@ -715,18 +719,7 @@ export const packedMeDetailedOnlySchema = {
|
|||
type: 'array',
|
||||
nullable: false, optional: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
nullable: false, optional: false,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
unlockedAt: {
|
||||
type: 'number',
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
ref: 'Achievement',
|
||||
},
|
||||
},
|
||||
loggedInDays: {
|
||||
|
|
@ -762,6 +755,10 @@ export const packedMeDetailedOnlySchema = {
|
|||
type: 'boolean',
|
||||
nullable: true, optional: true,
|
||||
},
|
||||
signupReason: {
|
||||
type: 'string',
|
||||
nullable: true, optional: true,
|
||||
},
|
||||
securityKeysList: {
|
||||
type: 'array',
|
||||
nullable: false, optional: true,
|
||||
|
|
|
|||
|
|
@ -612,6 +612,8 @@ export class QueueProcessorService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
public async onApplicationShutdown(signal?: string | undefined): Promise<void> {
|
||||
this.logger.info('Stopping BullMQ workers...');
|
||||
await this.stop();
|
||||
this.logger.info('Workers disposed.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,14 +4,17 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, MoreThan, Not } from 'typeorm';
|
||||
import { IsNull, MoreThan, Not, Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiDriveFile, DriveFilesRepository } from '@/models/_.js';
|
||||
import { MiUser } from '@/models/_.js';
|
||||
import type Logger from '@/logger.js';
|
||||
import { DriveService } from '@/core/DriveService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { CleanRemoteFilesJobData } from '../types.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
@Injectable()
|
||||
export class CleanRemoteFilesProcessorService {
|
||||
|
|
@ -23,35 +26,54 @@ export class CleanRemoteFilesProcessorService {
|
|||
|
||||
private driveService: DriveService,
|
||||
private queueLoggerService: QueueLoggerService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('clean-remote-files');
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async process(job: Bull.Job<Record<string, unknown>>): Promise<void> {
|
||||
public async process(job: Bull.Job<CleanRemoteFilesJobData>): Promise<void> {
|
||||
this.logger.info('Deleting cached remote files...');
|
||||
|
||||
const olderThanTimestamp = Date.now() - (job.data.olderThanSeconds ?? 0) * 1000;
|
||||
const olderThanDate = new Date(olderThanTimestamp);
|
||||
const keepFilesInUse = job.data.keepFilesInUse ?? false;
|
||||
let deletedCount = 0;
|
||||
let cursor: MiDriveFile['id'] | null = null;
|
||||
let errorCount = 0;
|
||||
|
||||
const total = await this.driveFilesRepository.countBy({
|
||||
userHost: Not(IsNull()),
|
||||
isLink: false,
|
||||
});
|
||||
const filesQuery = this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userHost IS NOT NULL') // remote files
|
||||
.andWhere('file.isLink = FALSE') // cached
|
||||
.andWhere('file.id <= :id', { id: this.idService.gen(olderThanTimestamp) }) // and old
|
||||
.orderBy('file.id', 'ASC');
|
||||
|
||||
if (keepFilesInUse) {
|
||||
filesQuery
|
||||
// are they used as avatar&&c?
|
||||
.leftJoinAndSelect(
|
||||
MiUser, 'fileuser',
|
||||
'fileuser."avatarId"="file"."id" OR fileuser."bannerId"="file"."id" OR fileuser."backgroundId"="file"."id"'
|
||||
)
|
||||
.andWhere(
|
||||
new Brackets((qb) => {
|
||||
qb.where('fileuser.id IS NULL') // not used
|
||||
.orWhere( // or attached to a user
|
||||
new Brackets((qb) => {
|
||||
qb.where('fileuser.lastFetchedAt IS NOT NULL') // weird? maybe this only applies to local users
|
||||
.andWhere('fileuser.lastFetchedAt < :old', { old: olderThanDate }); // old user
|
||||
})
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const total = await filesQuery.clone().getCount();
|
||||
|
||||
while (true) {
|
||||
const files = await this.driveFilesRepository.find({
|
||||
where: {
|
||||
userHost: Not(IsNull()),
|
||||
isLink: false,
|
||||
...(cursor ? { id: MoreThan(cursor) } : {}),
|
||||
},
|
||||
take: 256,
|
||||
order: {
|
||||
id: 1,
|
||||
},
|
||||
});
|
||||
const thisBatchQuery = filesQuery.clone();
|
||||
if (cursor) thisBatchQuery.andWhere('file.id > :cursor', { cursor });
|
||||
const files = await thisBatchQuery.take(256).getMany();
|
||||
|
||||
if (files.length === 0) {
|
||||
job.updateProgress(100);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,15 @@ export class DeliverProcessorService {
|
|||
return 'skip (suspended)';
|
||||
}
|
||||
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
// suspend server by software
|
||||
if (i != null && this.utilityService.isDeliverSuspendedSoftware(i)) {
|
||||
return 'skip (software suspended)';
|
||||
}
|
||||
|
||||
try {
|
||||
await this.apRequestService.signedPost(job.data.user, job.data.to, job.data.content, job.data.digest);
|
||||
|
||||
|
|
@ -79,10 +88,6 @@ export class DeliverProcessorService {
|
|||
|
||||
// Update instance stats
|
||||
process.nextTick(async () => {
|
||||
const i = await (this.meta.enableStatsForFederatedInstances
|
||||
? this.federatedInstanceService.fetchOrRegister(host)
|
||||
: this.federatedInstanceService.fetch(host));
|
||||
|
||||
if (i == null) return;
|
||||
|
||||
if (i.isNotResponding) {
|
||||
|
|
|
|||
|
|
@ -40,6 +40,11 @@ export type RelationshipJobData = {
|
|||
withReplies?: boolean;
|
||||
};
|
||||
|
||||
export type CleanRemoteFilesJobData = {
|
||||
keepFilesInUse: boolean;
|
||||
olderThanSeconds: number;
|
||||
};
|
||||
|
||||
export type DbJobData<T extends keyof DbJobMap> = DbJobMap[T];
|
||||
|
||||
export type DbJobMap = {
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ import type Logger from '@/logger.js';
|
|||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
|
||||
|
|
@ -571,7 +571,7 @@ export class ActivityPubServerService {
|
|||
|
||||
const pinnedNotes = (await Promise.all(pinings.map(pining =>
|
||||
this.notesRepository.findOneByOrFail({ id: pining.noteId }))))
|
||||
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility));
|
||||
.filter(note => !note.localOnly && ['public', 'home'].includes(note.visibility) && !isPureRenote(note));
|
||||
|
||||
const renderedNotes = await Promise.all(pinnedNotes.map(note => this.apRendererService.renderNote(note, user)));
|
||||
|
||||
|
|
@ -791,6 +791,10 @@ export class ActivityPubServerService {
|
|||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Expose-Headers', 'Vary');
|
||||
|
||||
// Tell crawlers not to index AP endpoints.
|
||||
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
|
||||
reply.header('X-Robots-Tag', 'noindex');
|
||||
|
||||
/* tell any caching proxy that they should not cache these
|
||||
responses: we wouldn't want the proxy to return a 403 to
|
||||
someone presenting a valid signature, or return a cached
|
||||
|
|
@ -838,6 +842,11 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
// Boosts don't federate directly - they should only be referenced as an activity
|
||||
if (isPureRenote(note)) {
|
||||
return 404;
|
||||
}
|
||||
|
||||
this.setResponseType(request, reply);
|
||||
|
||||
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ export class FileServerService {
|
|||
fastify.addHook('onRequest', (request, reply, done) => {
|
||||
reply.header('Content-Security-Policy', 'default-src \'none\'; img-src \'self\'; media-src \'self\'; style-src \'unsafe-inline\'');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
|
||||
// Tell crawlers not to index files endpoints.
|
||||
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
|
||||
reply.header('X-Robots-Tag', 'noindex');
|
||||
done();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,8 @@ export class NodeinfoServerService {
|
|||
maxRemoteCwLength: this.config.maxRemoteCwLength,
|
||||
maxAltTextLength: this.config.maxAltTextLength,
|
||||
maxRemoteAltTextLength: this.config.maxRemoteAltTextLength,
|
||||
maxBioLength: this.config.maxBioLength,
|
||||
maxRemoteBioLength: this.config.maxRemoteBioLength,
|
||||
enableEmail: meta.enableEmail,
|
||||
enableServiceWorker: meta.enableServiceWorker,
|
||||
proxyAccountName: proxyAccount.username,
|
||||
|
|
|
|||
|
|
@ -309,8 +309,13 @@ export class ServerService implements OnApplicationShutdown {
|
|||
|
||||
@bindThis
|
||||
public async dispose(): Promise<void> {
|
||||
this.logger.info('Disconnecting WebSocket clients...');
|
||||
await this.streamingApiServerService.detach();
|
||||
|
||||
this.logger.info('Disconnecting HTTP clients....;');
|
||||
await this.#fastify.close();
|
||||
|
||||
this.logger.info('Server disposed.');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -148,6 +148,10 @@ export class ApiCallService implements OnApplicationShutdown {
|
|||
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
|
||||
reply: FastifyReply,
|
||||
): void {
|
||||
// Tell crawlers not to index API endpoints.
|
||||
// https://developers.google.com/search/docs/crawling-indexing/block-indexing
|
||||
reply.header('X-Robots-Tag', 'noindex');
|
||||
|
||||
const body = request.method === 'GET'
|
||||
? request.query
|
||||
: request.body;
|
||||
|
|
|
|||
|
|
@ -147,7 +147,7 @@ export class SignupApiService {
|
|||
|
||||
let ticket: MiRegistrationTicket | null = null;
|
||||
|
||||
if (this.meta.disableRegistration) {
|
||||
if (this.meta.disableRegistration && process.env.NODE_ENV !== 'test') {
|
||||
if (invitationCode == null || typeof invitationCode !== 'string') {
|
||||
reply.code(400);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@
|
|||
*/
|
||||
|
||||
import { EventEmitter } from 'events';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import * as WebSocket from 'ws';
|
||||
import proxyAddr from 'proxy-addr';
|
||||
import ms from 'ms';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository, MiAccessToken, MiUser } from '@/models/_.js';
|
||||
import type { UsersRepository, MiAccessToken, MiUser, NoteReactionsRepository, NotesRepository, NoteFavoritesRepository } from '@/models/_.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js';
|
||||
import { NotificationService } from '@/core/NotificationService.js';
|
||||
|
|
@ -22,6 +21,7 @@ import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
|
|||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
|
||||
import MainStreamConnection from './stream/Connection.js';
|
||||
import { ChannelsService } from './stream/ChannelsService.js';
|
||||
|
|
@ -32,11 +32,12 @@ import type * as http from 'node:http';
|
|||
const MAX_CONNECTIONS_PER_CLIENT = 32;
|
||||
|
||||
@Injectable()
|
||||
export class StreamingApiServerService {
|
||||
export class StreamingApiServerService implements OnApplicationShutdown {
|
||||
#wss: WebSocket.WebSocketServer;
|
||||
#connections = new Map<WebSocket.WebSocket, number>();
|
||||
#connectionsByClient = new Map<string, Set<WebSocket.WebSocket>>(); // key: IP / user ID -> value: connection
|
||||
#cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
|
||||
readonly #globalEv = new EventEmitter();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisForSub)
|
||||
|
|
@ -45,6 +46,16 @@ export class StreamingApiServerService {
|
|||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private readonly noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private readonly notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.noteFavoritesRepository)
|
||||
private readonly noteFavoritesRepository: NoteFavoritesRepository,
|
||||
|
||||
private readonly queryService: QueryService,
|
||||
private cacheService: CacheService,
|
||||
private authenticateService: AuthenticateService,
|
||||
private channelsService: ChannelsService,
|
||||
|
|
@ -57,6 +68,14 @@ export class StreamingApiServerService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
this.redisForSub.on('message', this.onRedis);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
onApplicationShutdown() {
|
||||
this.redisForSub.off('message', this.onRedis);
|
||||
this.#globalEv.removeAllListeners();
|
||||
// Other shutdown logic is handled by detach(), which gets called by ServerServer's own shutdown handler.
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -69,6 +88,12 @@ export class StreamingApiServerService {
|
|||
return rateLimit.blocked;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private onRedis(_: string, data: string) {
|
||||
const parsed = JSON.parse(data);
|
||||
this.#globalEv.emit('message', parsed);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public attach(server: http.Server): void {
|
||||
this.#wss = new WebSocket.WebSocketServer({
|
||||
|
|
@ -168,6 +193,10 @@ export class StreamingApiServerService {
|
|||
};
|
||||
|
||||
const stream = new MainStreamConnection(
|
||||
this.noteReactionsRepository,
|
||||
this.notesRepository,
|
||||
this.noteFavoritesRepository,
|
||||
this.queryService,
|
||||
this.channelsService,
|
||||
this.notificationService,
|
||||
this.cacheService,
|
||||
|
|
@ -199,13 +228,6 @@ export class StreamingApiServerService {
|
|||
});
|
||||
});
|
||||
|
||||
const globalEv = new EventEmitter();
|
||||
|
||||
this.redisForSub.on('message', (_: string, data: string) => {
|
||||
const parsed = JSON.parse(data);
|
||||
globalEv.emit('message', parsed);
|
||||
});
|
||||
|
||||
this.#wss.on('connection', async (connection: WebSocket.WebSocket, request: http.IncomingMessage, ctx: {
|
||||
stream: MainStreamConnection,
|
||||
user: MiLocalUser | null;
|
||||
|
|
@ -219,12 +241,13 @@ export class StreamingApiServerService {
|
|||
ev.emit(data.channel, data.message);
|
||||
}
|
||||
|
||||
globalEv.on('message', onRedisMessage);
|
||||
this.#globalEv.on('message', onRedisMessage);
|
||||
|
||||
await stream.listen(ev, connection);
|
||||
|
||||
this.#connections.set(connection, Date.now());
|
||||
|
||||
// TODO use collapsed queue
|
||||
const userUpdateIntervalId = user ? setInterval(() => {
|
||||
this.usersService.updateLastActiveDate(user);
|
||||
}, 1000 * 60 * 5) : null;
|
||||
|
|
@ -235,7 +258,7 @@ export class StreamingApiServerService {
|
|||
connection.once('close', () => {
|
||||
ev.removeAllListeners();
|
||||
stream.dispose();
|
||||
globalEv.off('message', onRedisMessage);
|
||||
this.#globalEv.off('message', onRedisMessage);
|
||||
this.#connections.delete(connection);
|
||||
if (userUpdateIntervalId) clearInterval(userUpdateIntervalId);
|
||||
});
|
||||
|
|
@ -260,13 +283,24 @@ export class StreamingApiServerService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public detach(): Promise<void> {
|
||||
public async detach(): Promise<void> {
|
||||
if (this.#cleanConnectionsIntervalId) {
|
||||
clearInterval(this.#cleanConnectionsIntervalId);
|
||||
this.#cleanConnectionsIntervalId = null;
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
this.#wss.close(() => resolve());
|
||||
|
||||
for (const connection of this.#connections.keys()) {
|
||||
connection.close();
|
||||
}
|
||||
|
||||
this.#connections.clear();
|
||||
this.#connectionsByClient.clear();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
this.#wss.close(err => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,7 @@ export * as 'admin/reset-password' from './endpoints/admin/reset-password.js';
|
|||
export * as 'admin/resolve-abuse-user-report' from './endpoints/admin/resolve-abuse-user-report.js';
|
||||
export * as 'admin/roles/assign' from './endpoints/admin/roles/assign.js';
|
||||
export * as 'admin/roles/create' from './endpoints/admin/roles/create.js';
|
||||
export * as 'admin/roles/clone' from './endpoints/admin/roles/clone.js';
|
||||
export * as 'admin/roles/delete' from './endpoints/admin/roles/delete.js';
|
||||
export * as 'admin/roles/list' from './endpoints/admin/roles/list.js';
|
||||
export * as 'admin/roles/show' from './endpoints/admin/roles/show.js';
|
||||
|
|
|
|||
|
|
@ -18,7 +18,10 @@ export const meta = {
|
|||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
properties: {
|
||||
olderThanSeconds: { type: 'number' },
|
||||
keepFilesInUse: { type: 'boolean' },
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
||||
|
|
@ -30,7 +33,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.moderationLogService.log(me, 'clearRemoteFiles', {});
|
||||
await this.queueService.createCleanRemoteFilesJob();
|
||||
await this.queueService.createCleanRemoteFilesJob(
|
||||
ps.olderThanSeconds ?? 0,
|
||||
ps.keepFilesInUse ?? false,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -613,6 +613,24 @@ export const meta = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
deliverSuspendedSoftware: {
|
||||
type: 'array',
|
||||
optional: false, nullable: false,
|
||||
items: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
software: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
versionRange: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -776,6 +794,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null,
|
||||
allowUnsignedFetch: instance.allowUnsignedFetch,
|
||||
enableProxyAccount: instance.enableProxyAccount,
|
||||
deliverSuspendedSoftware: instance.deliverSuspendedSoftware,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: syuilo and misskey-project
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { RolesRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin', 'role'],
|
||||
|
||||
requireCredential: true,
|
||||
requireAdmin: true,
|
||||
kind: 'write:admin:roles',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
ref: 'Role',
|
||||
},
|
||||
|
||||
errors: {
|
||||
noSuchRole: {
|
||||
message: 'No such role.',
|
||||
code: 'NO_SUCH_ROLE',
|
||||
id: '93cc897a-b5f9-431f-b9b7-ee59035a5aed',
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
roleId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: [
|
||||
'roleId',
|
||||
],
|
||||
} as const;
|
||||
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.rolesRepository)
|
||||
private rolesRepository: RolesRepository,
|
||||
private roleEntityService: RoleEntityService,
|
||||
private roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
|
||||
if (role == null) {
|
||||
throw new ApiError(meta.errors.noSuchRole);
|
||||
}
|
||||
|
||||
const cloned = await this.roleService.clone(role, me);
|
||||
|
||||
return this.roleEntityService.pack(cloned, me);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -221,6 +221,10 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
signupReason: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
|
|||
|
|
@ -215,6 +215,17 @@ export const paramDef = {
|
|||
type: 'boolean',
|
||||
nullable: false,
|
||||
},
|
||||
deliverSuspendedSoftware: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
software: { type: 'string' },
|
||||
versionRange: { type: 'string' },
|
||||
},
|
||||
required: ['software', 'versionRange'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
@ -759,6 +770,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.federation = ps.federation;
|
||||
}
|
||||
|
||||
if (ps.deliverSuspendedSoftware !== undefined) {
|
||||
set.deliverSuspendedSoftware = ps.deliverSuspendedSoftware;
|
||||
}
|
||||
|
||||
if (Array.isArray(ps.federationHosts)) {
|
||||
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
}
|
||||
|
|
@ -778,9 +793,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const after = await this.metaService.fetch(true);
|
||||
|
||||
this.moderationLogService.log(me, 'updateServerSettings', {
|
||||
before,
|
||||
after,
|
||||
before: sanitize(before),
|
||||
after: sanitize(after),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function sanitize(meta: Partial<MiMeta>): Partial<MiMeta> {
|
||||
return {
|
||||
...meta,
|
||||
hcaptchaSecretKey: '<redacted>',
|
||||
mcaptchaSecretKey: '<redacted>',
|
||||
recaptchaSecretKey: '<redacted>',
|
||||
turnstileSecretKey: '<redacted>',
|
||||
fcSecretKey: '<redacted>',
|
||||
smtpPass: '<redacted>',
|
||||
swPrivateKey: '<redacted>',
|
||||
objectStorageAccessKey: '<redacted>',
|
||||
objectStorageSecretKey: '<redacted>',
|
||||
deeplAuthKey: '<redacted>',
|
||||
libreTranslateKey: '<redacted>',
|
||||
verifymailAuthKey: '<redacted>',
|
||||
truemailAuthKey: '<redacted>',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
// https://github.com/misskey-dev/misskey/pull/15346#discussion_r1929950255
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.queryService.makePaginationQuery(this.channelFollowingsRepository.createQueryBuilder(), ps.sinceId, ps.untilId)
|
||||
const query = this.queryService
|
||||
.makePaginationQuery(
|
||||
this.channelFollowingsRepository.createQueryBuilder(),
|
||||
ps.sinceId,
|
||||
ps.untilId,
|
||||
null,
|
||||
null,
|
||||
'followeeId',
|
||||
)
|
||||
.andWhere({ followerId: me.id });
|
||||
|
||||
const followings = await query
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { DI } from '@/di-symbols.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { Brackets } from 'typeorm';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes', 'channels'],
|
||||
|
|
@ -83,6 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private queryService: QueryService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
|
|
@ -106,6 +107,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
|
||||
}
|
||||
|
||||
const threadMutings = me ? await this.cacheService.threadMutingsCache.fetch(me.id) : null;
|
||||
|
||||
return await this.fanoutTimelineEndpointService.timeline({
|
||||
untilId,
|
||||
sinceId,
|
||||
|
|
@ -119,6 +122,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
dbFallback: async (untilId, sinceId, limit) => {
|
||||
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me);
|
||||
},
|
||||
noteFilter: note => {
|
||||
if (threadMutings?.has(note.threadId ?? note.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -144,10 +154,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
this.queryService.generateSilencedUserQueryForNotes(query, me);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
|
|||
import { getJsonSchema } from '@/core/chart/core.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { schema } from '@/core/chart/charts/entities/per-user-following.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['charts', 'users', 'following'],
|
||||
|
|
@ -40,9 +42,84 @@ export const paramDef = {
|
|||
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
return await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
|
||||
const profile = await this.cacheService.userProfileCache.fetch(ps.userId);
|
||||
|
||||
// These are structured weird to avoid un-necessary calls to roleService and cacheService
|
||||
const iAmModeratorOrTarget = me && (me.id === ps.userId || await this.roleService.isModerator(me));
|
||||
const iAmFollowingOrTarget = me && (me.id === ps.userId || await this.cacheService.isFollowing(me.id, ps.userId));
|
||||
|
||||
const canViewFollowing =
|
||||
profile.followingVisibility === 'public'
|
||||
|| iAmModeratorOrTarget
|
||||
|| (profile.followingVisibility === 'followers' && iAmFollowingOrTarget);
|
||||
|
||||
const canViewFollowers =
|
||||
profile.followersVisibility === 'public'
|
||||
|| iAmModeratorOrTarget
|
||||
|| (profile.followersVisibility === 'followers' && iAmFollowingOrTarget);
|
||||
|
||||
if (!canViewFollowing && !canViewFollowers) {
|
||||
return {
|
||||
local: {
|
||||
followings: {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
},
|
||||
followers: {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
},
|
||||
},
|
||||
remote: {
|
||||
followings: {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
},
|
||||
followers: {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const chart = await this.perUserFollowingChart.getChart(ps.span, ps.limit, ps.offset ? new Date(ps.offset) : null, ps.userId);
|
||||
|
||||
if (!canViewFollowers) {
|
||||
chart.local.followers = {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
};
|
||||
chart.remote.followers = {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (!canViewFollowing) {
|
||||
chart.local.followings = {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
};
|
||||
chart.remote.followings = {
|
||||
total: [],
|
||||
inc: [],
|
||||
dec: [],
|
||||
};
|
||||
}
|
||||
|
||||
return chart;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,8 +91,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.andWhere('clipNote.clipId = :clipId', { clipId: clip.id });
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
// this.queryService.generateSuspendedUserQueryForNote(query); // To avoid problems with removing notes, ignoring suspended user for now
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import Parser from 'rss-parser';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { parseFeed } from 'htmlparser2';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
|
||||
const rssParser = new Parser();
|
||||
import { ApiError } from '../error.js';
|
||||
import type { FeedItem } from 'domutils';
|
||||
|
||||
export const meta = {
|
||||
tags: ['meta'],
|
||||
|
|
@ -17,52 +17,32 @@ export const meta = {
|
|||
allowGet: true,
|
||||
cacheSec: 60 * 3,
|
||||
|
||||
errors: {
|
||||
fetchFailed: {
|
||||
id: '88f4356f-719d-4715-b4fc-703a10a812d2',
|
||||
code: 'FETCH_FAILED',
|
||||
message: 'Failed to fetch RSS feed',
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
image: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
properties: {
|
||||
link: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
paginationLinks: {
|
||||
type: 'object',
|
||||
id: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
updated: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
author: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
properties: {
|
||||
self: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
first: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
next: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
last: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
prev: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
link: {
|
||||
type: 'string',
|
||||
|
|
@ -94,113 +74,42 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
creator: {
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
isoDate: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
categories: {
|
||||
media: {
|
||||
type: 'array',
|
||||
optional: true,
|
||||
optional: false,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
contentSnippet: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
enclosure: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
properties: {
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: false,
|
||||
},
|
||||
length: {
|
||||
type: 'number',
|
||||
optional: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
type: 'object',
|
||||
properties: {
|
||||
medium: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
lang: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
feedUrl: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
itunes: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
additionalProperties: true,
|
||||
properties: {
|
||||
image: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
owner: {
|
||||
type: 'object',
|
||||
optional: true,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
author: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
summary: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
explicit: {
|
||||
type: 'string',
|
||||
optional: true,
|
||||
},
|
||||
categories: {
|
||||
type: 'array',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
keywords: {
|
||||
type: 'array',
|
||||
optional: true,
|
||||
items: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -224,7 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
super(meta, paramDef, async (ps) => {
|
||||
const res = await this.httpRequestService.send(ps.url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
|
|
@ -234,8 +143,38 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
|
||||
const text = await res.text();
|
||||
const feed = parseFeed(text, {
|
||||
xmlMode: true,
|
||||
});
|
||||
|
||||
return rssParser.parseString(text);
|
||||
if (!feed) {
|
||||
throw new ApiError(meta.errors.fetchFailed);
|
||||
}
|
||||
|
||||
return {
|
||||
type: feed.type,
|
||||
id: feed.id,
|
||||
title: feed.title,
|
||||
link: feed.link,
|
||||
description: feed.description,
|
||||
updated: feed.updated?.toISOString(),
|
||||
author: feed.author,
|
||||
items: feed.items
|
||||
.filter((item): item is FeedItem & { link: string, title: string } => !!item.link && !!item.title)
|
||||
.map(item => ({
|
||||
guid: item.id,
|
||||
title: item.title,
|
||||
link: item.link,
|
||||
description: item.description,
|
||||
pubDate: item.pubDate?.toISOString(),
|
||||
media: item.media.map(media => ({
|
||||
medium: media.medium,
|
||||
url: media.url,
|
||||
type: media.type,
|
||||
lang: media.lang,
|
||||
})),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { AchievementService, ACHIEVEMENT_TYPES } from '@/core/AchievementService.js';
|
||||
import { AchievementService } from '@/core/AchievementService.js';
|
||||
import { ACHIEVEMENT_TYPES } from '@/models/UserProfile.js';
|
||||
import type { MiMeta } from '@/models/_.js';
|
||||
|
||||
export const meta = {
|
||||
|
|
|
|||
|
|
@ -104,53 +104,88 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
// grouping
|
||||
let groupedNotifications = [notifications[0]] as MiGroupedNotification[];
|
||||
for (let i = 1; i < notifications.length; i++) {
|
||||
const notification = notifications[i];
|
||||
const prev = notifications[i - 1];
|
||||
let prevGroupedNotification = groupedNotifications.at(-1)!;
|
||||
const groupedNotifications : MiGroupedNotification[] = [];
|
||||
// keep track of where reaction / renote notifications are, by note id
|
||||
const reactionIdxByNoteId = new Map<string, number>();
|
||||
const renoteIdxByNoteId = new Map<string, number>();
|
||||
|
||||
if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) {
|
||||
if (prevGroupedNotification.type !== 'reaction:grouped') {
|
||||
groupedNotifications[groupedNotifications.length - 1] = {
|
||||
// group notifications by type+note; notice that we don't try to
|
||||
// split groups if they span a long stretch of time, because
|
||||
// it's probably overkill: if the user has very few
|
||||
// notifications, there should be very little difference; if the
|
||||
// user has many notifications, the pagination will break the
|
||||
// groups
|
||||
|
||||
// scan `notifications` newest-to-oldest
|
||||
for (let i = 0; i < notifications.length; i++) {
|
||||
const notification = notifications[i];
|
||||
|
||||
if (notification.type === 'reaction') {
|
||||
const reactionIdx = reactionIdxByNoteId.get(notification.noteId);
|
||||
if (reactionIdx === undefined) {
|
||||
// first reaction to this note that we see, add it as-is
|
||||
// and remember where we put it
|
||||
groupedNotifications.push(notification);
|
||||
reactionIdxByNoteId.set(notification.noteId, groupedNotifications.length - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let prevReaction = groupedNotifications[reactionIdx] as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'> | FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction'>;
|
||||
// if the previous reaction is not a group, make it into one
|
||||
if (prevReaction.type !== 'reaction:grouped') {
|
||||
prevReaction = groupedNotifications[reactionIdx] = {
|
||||
type: 'reaction:grouped',
|
||||
id: '',
|
||||
createdAt: prev.createdAt,
|
||||
noteId: prev.noteId!,
|
||||
id: prevReaction.id, // this will be the newest id in this group
|
||||
createdAt: prevReaction.createdAt,
|
||||
noteId: prevReaction.noteId!,
|
||||
reactions: [{
|
||||
userId: prev.notifierId!,
|
||||
reaction: prev.reaction!,
|
||||
userId: prevReaction.notifierId!,
|
||||
reaction: prevReaction.reaction!,
|
||||
}],
|
||||
};
|
||||
prevGroupedNotification = groupedNotifications.at(-1)!;
|
||||
}
|
||||
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'>).reactions.push({
|
||||
// add this new reaction to the existing group
|
||||
(prevReaction as FilterUnionByProperty<MiGroupedNotification, 'type', 'reaction:grouped'>).reactions.push({
|
||||
userId: notification.notifierId!,
|
||||
reaction: notification.reaction!,
|
||||
});
|
||||
prevGroupedNotification.id = notification.id;
|
||||
continue;
|
||||
}
|
||||
if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) {
|
||||
if (prevGroupedNotification.type !== 'renote:grouped') {
|
||||
groupedNotifications[groupedNotifications.length - 1] = {
|
||||
type: 'renote:grouped',
|
||||
id: '',
|
||||
createdAt: notification.createdAt,
|
||||
noteId: prev.noteId!,
|
||||
userIds: [prev.notifierId!],
|
||||
};
|
||||
prevGroupedNotification = groupedNotifications.at(-1)!;
|
||||
}
|
||||
(prevGroupedNotification as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
|
||||
prevGroupedNotification.id = notification.id;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (notification.type === 'renote') {
|
||||
const renoteIdx = renoteIdxByNoteId.get(notification.targetNoteId);
|
||||
if (renoteIdx === undefined) {
|
||||
// first renote of this note that we see, add it as-is and
|
||||
// remember where we put it
|
||||
groupedNotifications.push(notification);
|
||||
renoteIdxByNoteId.set(notification.targetNoteId, groupedNotifications.length - 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
let prevRenote = groupedNotifications[renoteIdx] as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'> | FilterUnionByProperty<MiGroupedNotification, 'type', 'renote'>;
|
||||
// if the previous renote is not a group, make it into one
|
||||
if (prevRenote.type !== 'renote:grouped') {
|
||||
prevRenote = groupedNotifications[renoteIdx] = {
|
||||
type: 'renote:grouped',
|
||||
id: prevRenote.id, // this will be the newest id in this group
|
||||
createdAt: prevRenote.createdAt,
|
||||
noteId: prevRenote.noteId!,
|
||||
userIds: [prevRenote.notifierId!],
|
||||
};
|
||||
}
|
||||
// add this new renote to the existing group
|
||||
(prevRenote as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
|
||||
continue;
|
||||
}
|
||||
|
||||
// not a groupable notification, just push it
|
||||
groupedNotifications.push(notification);
|
||||
}
|
||||
|
||||
groupedNotifications = groupedNotifications.slice(0, ps.limit);
|
||||
// sort the groups by their id, newest first
|
||||
groupedNotifications.sort(
|
||||
(a, b) => a.id < b.id ? 1 : a.id > b.id ? -1 : 0,
|
||||
);
|
||||
|
||||
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import * as mfm from '@transfem-org/sfm-js';
|
||||
import * as mfm from 'mfm-js';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import ms from 'ms';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
|
|
@ -141,6 +141,13 @@ export const meta = {
|
|||
code: 'MAX_CW_LENGTH',
|
||||
id: '7004c478-bda3-4b4f-acb2-4316398c9d52',
|
||||
},
|
||||
|
||||
maxBioLength: {
|
||||
message: 'You tried setting a bio which is too long.',
|
||||
code: 'MAX_BIO_LENGTH',
|
||||
id: 'f3bb3543-8bd1-4e6d-9375-55efaf2b4102',
|
||||
httpStatusCode: 422,
|
||||
},
|
||||
},
|
||||
|
||||
res: {
|
||||
|
|
@ -329,7 +336,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
updates.name = trimmedName === '' ? null : trimmedName;
|
||||
}
|
||||
}
|
||||
if (ps.description !== undefined) profileUpdates.description = ps.description;
|
||||
if (ps.description !== undefined) {
|
||||
if (ps.description && ps.description.length > this.config.maxBioLength) {
|
||||
throw new ApiError(meta.errors.maxBioLength);
|
||||
}
|
||||
profileUpdates.description = ps.description;
|
||||
};
|
||||
if (ps.followedMessage !== undefined) profileUpdates.followedMessage = ps.followedMessage;
|
||||
if (ps.lang !== undefined) profileUpdates.lang = ps.lang;
|
||||
if (ps.location !== undefined) profileUpdates.location = ps.location;
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
this.queryService.generateMutedNoteThreadQuery(query, me);
|
||||
}
|
||||
|
||||
if (ps.withFiles) {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
this.queryService.generateVisibilityQuery(query, me);
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
if (me) {
|
||||
this.queryService.generateMutedUserQueryForNotes(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
|
|
|||
|
|
@ -302,6 +302,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.noSuchRenoteTarget);
|
||||
} else if (isRenote(renote) && !isQuote(renote)) {
|
||||
throw new ApiError(meta.errors.cannotReRenote);
|
||||
} else if (!await this.noteEntityService.isVisibleForMe(renote, me.id)) {
|
||||
throw new ApiError(meta.errors.cannotRenoteDueToVisibility);
|
||||
}
|
||||
|
||||
// Check blocking
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.andWhere('user.isExplorable = TRUE');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
||||
const notes = (await query.getMany()).filter(note => {
|
||||
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue