Merge branch Sharkey:develop into trackeropt

This commit is contained in:
Vavency 2025-07-17 15:04:33 +00:00
commit 9dbd2a6bb4
292 changed files with 5783 additions and 3323 deletions

View file

@ -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
View file

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

View file

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

View file

@ -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")`);
}
}

View file

@ -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") `);
}
}

View file

@ -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)`);
}
}

View file

@ -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"`);
}
}

View 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';
}

View file

@ -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',
});

View file

@ -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",

View file

@ -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> {

View file

@ -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();

View file

@ -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

View file

@ -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']]);

View file

@ -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;

View file

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

View file

@ -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

View file

@ -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}`, {

View file

@ -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をキャッシュする

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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'));
}
}
}

View file

@ -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> {

View file

@ -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,

View file

@ -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', {

View file

@ -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: サードパーティアプリが傍受出来てしまうのでどうにかする

View file

@ -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 });

View file

@ -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;

View file

@ -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();
}
}

View file

@ -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 }));
}
}
}

View file

@ -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;

View file

@ -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 => ({

View file

@ -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';

View file

@ -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 [];

View file

@ -6,7 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import { Window } from 'happy-dom';
import { 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;
}

View file

@ -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,
);
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;

View 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';
}

View file

@ -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 }, {

View file

@ -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;

View file

@ -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();
}

View file

@ -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,

View file

@ -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,

View file

@ -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,
},
})));
}

View file

@ -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,
},
)),
);

View file

@ -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();
}
}

View file

@ -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);
}
});
}

View file

@ -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[] {

View file

@ -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[] {

View file

@ -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: 重複を削除

View file

@ -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,

View file

@ -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);
}
}

View file

@ -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 };

View file

@ -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,
};

View file

@ -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)

View file

@ -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;
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View 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;

View file

@ -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',

View file

@ -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,

View file

@ -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,

View file

@ -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',
},
},
}, {

View file

@ -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,

View file

@ -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.');
}
}

View file

@ -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);

View file

@ -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) {

View file

@ -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 = {

View file

@ -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 });

View file

@ -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();
});

View file

@ -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,

View file

@ -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.');
}
/**

View file

@ -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;

View file

@ -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;

View file

@ -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();
});
});
}
}

View file

@ -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';

View file

@ -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,
);
});
}
}

View file

@ -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,
};
});
}

View file

@ -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);
});
}
}

View file

@ -221,6 +221,10 @@ export const meta = {
},
},
},
signupReason: {
type: 'string',
optional: false, nullable: true,
},
},
},
} as const;

View file

@ -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>',
};
}

View file

@ -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);

View file

@ -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

View file

@ -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) {

View file

@ -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;
});
}
}

View file

@ -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);

View file

@ -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,
})),
})),
};
});
}
}

View file

@ -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 = {

View file

@ -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);
});

View file

@ -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;

View file

@ -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) {

View file

@ -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);

View file

@ -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

View file

@ -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