mirror of
https://github.com/MisskeyIO/misskey
synced 2024-11-27 14:28:49 +09:00
なんかもうめっちゃ変えた
This commit is contained in:
parent
d9ab03f086
commit
b75184ec8e
8
.github/workflows/test.yml
vendored
8
.github/workflows/test.yml
vendored
@ -8,7 +8,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
mocha:
|
||||
jest:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
@ -49,7 +49,11 @@ jobs:
|
||||
- name: Build
|
||||
run: yarn build
|
||||
- name: Test
|
||||
run: yarn mocha
|
||||
run: yarn jest-and-coverage
|
||||
- name: Upload Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
files: ./packages/backend/coverage/coverage-final.json
|
||||
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -124,7 +124,7 @@ npm run test
|
||||
|
||||
#### Run specify test
|
||||
```
|
||||
npx cross-env TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT="./test/tsconfig.json" npx mocha test/foo.ts --require ts-node/register
|
||||
npm run jest -- foo.ts
|
||||
```
|
||||
|
||||
### e2e tests
|
||||
|
@ -22,8 +22,10 @@
|
||||
"cy:open": "cypress open --browser --e2e --config-file=cypress.config.ts",
|
||||
"cy:run": "cypress run",
|
||||
"e2e": "start-server-and-test start:test http://localhost:61812 cy:run",
|
||||
"mocha": "cd packages/backend && cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" npx mocha",
|
||||
"test": "npm run mocha",
|
||||
"jest": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --detectOpenHandles --runInBand",
|
||||
"jest-and-coverage": "cd packages/backend && cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --detectOpenHandles --runInBand",
|
||||
"test": "npm run jest",
|
||||
"test-and-coverage": "npm run jest-and-coverage",
|
||||
"format": "gulp format",
|
||||
"clean": "node ./scripts/clean.js",
|
||||
"clean-all": "node ./scripts/clean-all.js",
|
||||
|
@ -1,10 +0,0 @@
|
||||
{
|
||||
"extension": ["ts","js","cjs","mjs"],
|
||||
"node-option": [
|
||||
"experimental-specifier-resolution=node",
|
||||
"loader=./test/loader.js"
|
||||
],
|
||||
"slow": 1000,
|
||||
"timeout": 30000,
|
||||
"exit": true
|
||||
}
|
14
packages/backend/jest-resolver.cjs
Normal file
14
packages/backend/jest-resolver.cjs
Normal file
@ -0,0 +1,14 @@
|
||||
// https://github.com/facebook/jest/issues/12270#issuecomment-1194746382
|
||||
|
||||
const nativeModule = require('node:module');
|
||||
|
||||
function resolver(module, options) {
|
||||
const { basedir, defaultResolver } = options;
|
||||
try {
|
||||
return defaultResolver(module, options);
|
||||
} catch (error) {
|
||||
return nativeModule.createRequire(basedir).resolve(module);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = resolver;
|
214
packages/backend/jest.config.cjs
Normal file
214
packages/backend/jest.config.cjs
Normal file
@ -0,0 +1,214 @@
|
||||
/*
|
||||
* For a detailed explanation regarding each configuration property and type check, visit:
|
||||
* https://jestjs.io/docs/en/configuration.html
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// All imported modules in your tests should be mocked automatically
|
||||
// automock: false,
|
||||
|
||||
// Stop running tests after `n` failures
|
||||
// bail: 0,
|
||||
|
||||
// The directory where Jest should store its cached dependency information
|
||||
// cacheDirectory: "C:\\Users\\ai\\AppData\\Local\\Temp\\jest",
|
||||
|
||||
// Automatically clear mock calls and instances between every test
|
||||
// clearMocks: false,
|
||||
|
||||
// Indicates whether the coverage information should be collected while executing the test
|
||||
// collectCoverage: false,
|
||||
|
||||
// An array of glob patterns indicating a set of files for which coverage information should be collected
|
||||
collectCoverageFrom: ['src/**/*.ts'],
|
||||
|
||||
// The directory where Jest should output its coverage files
|
||||
coverageDirectory: "coverage",
|
||||
|
||||
// An array of regexp pattern strings used to skip coverage collection
|
||||
// coveragePathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// Indicates which provider should be used to instrument code for coverage
|
||||
coverageProvider: "v8",
|
||||
|
||||
// A list of reporter names that Jest uses when writing coverage reports
|
||||
// coverageReporters: [
|
||||
// "json",
|
||||
// "text",
|
||||
// "lcov",
|
||||
// "clover"
|
||||
// ],
|
||||
|
||||
// An object that configures minimum threshold enforcement for coverage results
|
||||
// coverageThreshold: undefined,
|
||||
|
||||
// A path to a custom dependency extractor
|
||||
// dependencyExtractor: undefined,
|
||||
|
||||
// Make calling deprecated APIs throw helpful error messages
|
||||
// errorOnDeprecated: false,
|
||||
|
||||
// Force coverage collection from ignored files using an array of glob patterns
|
||||
// forceCoverageMatch: [],
|
||||
|
||||
// A path to a module which exports an async function that is triggered once before all test suites
|
||||
// globalSetup: undefined,
|
||||
|
||||
// A path to a module which exports an async function that is triggered once after all test suites
|
||||
// globalTeardown: undefined,
|
||||
|
||||
// A set of global variables that need to be available in all test environments
|
||||
globals: {
|
||||
"ts-jest": {
|
||||
"useESM": true,
|
||||
tsconfig: "test/tsconfig.json",
|
||||
diagnostics: {
|
||||
exclude: ['**'],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
|
||||
// maxWorkers: "50%",
|
||||
|
||||
// An array of directory names to be searched recursively up from the requiring module's location
|
||||
// moduleDirectories: [
|
||||
// "node_modules"
|
||||
// ],
|
||||
|
||||
// An array of file extensions your modules use
|
||||
// moduleFileExtensions: [
|
||||
// "js",
|
||||
// "json",
|
||||
// "jsx",
|
||||
// "ts",
|
||||
// "tsx",
|
||||
// "node"
|
||||
// ],
|
||||
|
||||
// A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module
|
||||
moduleNameMapper: {
|
||||
"^@/(.*?).js": "<rootDir>/src/$1.ts",
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
|
||||
// modulePathIgnorePatterns: [],
|
||||
|
||||
// Activates notifications for test results
|
||||
// notify: false,
|
||||
|
||||
// An enum that specifies notification mode. Requires { notify: true }
|
||||
// notifyMode: "failure-change",
|
||||
|
||||
// A preset that is used as a base for Jest's configuration
|
||||
preset: "ts-jest/presets/js-with-ts-esm",
|
||||
|
||||
// Run tests from one or more projects
|
||||
// projects: undefined,
|
||||
|
||||
// Use this configuration option to add custom reporters to Jest
|
||||
// reporters: undefined,
|
||||
|
||||
// Automatically reset mock state between every test
|
||||
// resetMocks: false,
|
||||
|
||||
// Reset the module registry before running each individual test
|
||||
// resetModules: false,
|
||||
|
||||
// A path to a custom resolver
|
||||
resolver: './jest-resolver.cjs',
|
||||
|
||||
// Automatically restore mock state between every test
|
||||
// restoreMocks: false,
|
||||
|
||||
// The root directory that Jest should scan for tests and modules within
|
||||
// rootDir: undefined,
|
||||
|
||||
// A list of paths to directories that Jest should use to search for files in
|
||||
roots: [
|
||||
"<rootDir>"
|
||||
],
|
||||
|
||||
// Allows you to use a custom runner instead of Jest's default test runner
|
||||
// runner: "jest-runner",
|
||||
|
||||
// The paths to modules that run some code to configure or set up the testing environment before each test
|
||||
// setupFiles: [],
|
||||
|
||||
// A list of paths to modules that run some code to configure or set up the testing framework before each test
|
||||
// setupFilesAfterEnv: [],
|
||||
|
||||
// The number of seconds after which a test is considered as slow and reported as such in the results.
|
||||
// slowTestThreshold: 5,
|
||||
|
||||
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
|
||||
// snapshotSerializers: [],
|
||||
|
||||
// The test environment that will be used for testing
|
||||
testEnvironment: "node",
|
||||
|
||||
// Options that will be passed to the testEnvironment
|
||||
// testEnvironmentOptions: {},
|
||||
|
||||
// Adds a location field to test results
|
||||
// testLocationInResults: false,
|
||||
|
||||
// The glob patterns Jest uses to detect test files
|
||||
testMatch: [
|
||||
"<rootDir>/test/unit/**/*.ts",
|
||||
//"<rootDir>/test/e2e/**/*.ts"
|
||||
],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
|
||||
// testPathIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\"
|
||||
// ],
|
||||
|
||||
// The regexp pattern or array of patterns that Jest uses to detect test files
|
||||
// testRegex: [],
|
||||
|
||||
// This option allows the use of a custom results processor
|
||||
// testResultsProcessor: undefined,
|
||||
|
||||
// This option allows use of a custom test runner
|
||||
// testRunner: "jasmine2",
|
||||
|
||||
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
|
||||
// testURL: "http://localhost",
|
||||
|
||||
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
|
||||
// timers: "real",
|
||||
|
||||
// A map from regular expressions to paths to transformers
|
||||
transform: {
|
||||
"<regex_match_files>": [
|
||||
"ts-jest",
|
||||
{
|
||||
"useESM": true
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
|
||||
// transformIgnorePatterns: [
|
||||
// "\\\\node_modules\\\\",
|
||||
// "\\.pnp\\.[^\\\\]+$"
|
||||
// ],
|
||||
|
||||
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
|
||||
// unmockedModulePathPatterns: undefined,
|
||||
|
||||
// Indicates whether each individual test should be reported during the run
|
||||
// verbose: undefined,
|
||||
|
||||
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
|
||||
// watchPathIgnorePatterns: [],
|
||||
|
||||
// Whether to use watchman for file crawling
|
||||
// watchman: true,
|
||||
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
};
|
@ -1,6 +1,8 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import config from './built/config/index.js';
|
||||
import { entities } from './built/db/postgre.js';
|
||||
import { loadConfig } from './built/config.js';
|
||||
import { entities } from './built/postgre.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
export default new DataSource({
|
||||
type: 'postgres',
|
||||
|
@ -1,13 +1,14 @@
|
||||
{
|
||||
"main": "./index.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json || echo done. && tsc-alias -p tsconfig.json",
|
||||
"watch": "node watch.mjs",
|
||||
"lint": "eslint --quiet \"src/**/*.ts\"",
|
||||
"mocha": "cross-env NODE_ENV=test TS_NODE_FILES=true TS_NODE_TRANSPILE_ONLY=true TS_NODE_PROJECT=\"./test/tsconfig.json\" mocha",
|
||||
"test": "npm run mocha"
|
||||
"jest": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --forceExit --detectOpenHandles --runInBand",
|
||||
"jest-and-coverage": "cross-env NODE_ENV=test node --experimental-vm-modules --experimental-import-meta-resolve node_modules/jest/bin/jest.js --coverage --forceExit --detectOpenHandles --runInBand",
|
||||
"test": "npm run jest",
|
||||
"test-and-coverage": "npm run jest-and-coverage"
|
||||
},
|
||||
"resolutions": {
|
||||
"chokidar": "^3.3.1",
|
||||
@ -23,9 +24,13 @@
|
||||
"@koa/cors": "3.1.0",
|
||||
"@koa/multer": "3.0.0",
|
||||
"@koa/router": "9.0.1",
|
||||
"@nestjs/common": "9.0.11",
|
||||
"@nestjs/core": "9.0.11",
|
||||
"@nestjs/testing": "9.0.11",
|
||||
"@peertube/http-signature": "1.7.0",
|
||||
"@sinonjs/fake-timers": "9.1.2",
|
||||
"@syuilo/aiscript": "0.11.1",
|
||||
"@types/pg": "8.6.5",
|
||||
"ajv": "8.11.0",
|
||||
"archiver": "5.3.1",
|
||||
"autobind-decorator": "2.4.0",
|
||||
@ -71,7 +76,6 @@
|
||||
"mfm-js": "0.23.0",
|
||||
"mime-types": "2.1.35",
|
||||
"misskey-js": "0.0.14",
|
||||
"mocha": "10.0.0",
|
||||
"ms": "3.0.0-canary.1",
|
||||
"multer": "1.4.4",
|
||||
"nested-property": "4.0.0",
|
||||
@ -96,6 +100,7 @@
|
||||
"rename": "1.0.4",
|
||||
"rndstr": "1.0.0",
|
||||
"rss-parser": "3.12.0",
|
||||
"rxjs": "7.5.6",
|
||||
"s-age": "1.1.2",
|
||||
"sanitize-html": "2.7.1",
|
||||
"semver": "7.3.7",
|
||||
@ -129,6 +134,7 @@
|
||||
"@types/cbor": "6.0.0",
|
||||
"@types/escape-regexp": "0.0.1",
|
||||
"@types/fluent-ffmpeg": "2.1.20",
|
||||
"@types/jest": "29.0.0",
|
||||
"@types/js-yaml": "4.0.5",
|
||||
"@types/jsdom": "20.0.0",
|
||||
"@types/jsonld": "1.5.6",
|
||||
@ -144,7 +150,6 @@
|
||||
"@types/koa__cors": "3.1.1",
|
||||
"@types/koa__multer": "2.0.4",
|
||||
"@types/koa__router": "8.0.11",
|
||||
"@types/mocha": "9.1.1",
|
||||
"@types/node": "18.7.16",
|
||||
"@types/node-fetch": "3.0.3",
|
||||
"@types/nodemailer": "6.4.5",
|
||||
@ -173,6 +178,8 @@
|
||||
"eslint": "8.23.0",
|
||||
"eslint-plugin-import": "2.26.0",
|
||||
"execa": "6.1.0",
|
||||
"jest": "29.0.1",
|
||||
"ts-jest": "28.0.8",
|
||||
"typescript": "4.8.3"
|
||||
}
|
||||
}
|
||||
|
15
packages/backend/src/AppModule.ts
Normal file
15
packages/backend/src/AppModule.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CoreModule } from '@/core/CoreModule.js';
|
||||
import { ServerModule } from '@/server/ServerModule.js';
|
||||
import { GlobalModule } from '@/GlobalModule.js';
|
||||
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
GlobalModule,
|
||||
CoreModule,
|
||||
ServerModule,
|
||||
QueueProcessorModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
63
packages/backend/src/GlobalModule.ts
Normal file
63
packages/backend/src/GlobalModule.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Global, Inject, Module } from '@nestjs/common';
|
||||
import { Redis } from 'ioredis';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { createRedisConnection } from '@/redis.js';
|
||||
import { DI } from './di-symbols.js';
|
||||
import { loadConfig } from './config.js';
|
||||
import { createPostgreDataSource } from './postgre.js';
|
||||
import { RepositoryModule } from './RepositoryModule.js';
|
||||
import type { Provider, OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const $config: Provider = {
|
||||
provide: DI.config,
|
||||
useValue: config,
|
||||
};
|
||||
|
||||
const $db: Provider = {
|
||||
provide: DI.db,
|
||||
useFactory: async () => {
|
||||
const db = createPostgreDataSource();
|
||||
return await db.initialize();
|
||||
},
|
||||
};
|
||||
|
||||
const $redis: Provider = {
|
||||
provide: DI.redis,
|
||||
useFactory: () => {
|
||||
const redisClient = createRedisConnection();
|
||||
return redisClient;
|
||||
},
|
||||
};
|
||||
|
||||
const $redisSubscriber: Provider = {
|
||||
provide: DI.redisSubscriber,
|
||||
useFactory: () => {
|
||||
const redisSubscriber = createRedisConnection();
|
||||
redisSubscriber.subscribe(config.host);
|
||||
return redisSubscriber;
|
||||
},
|
||||
};
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [RepositoryModule],
|
||||
providers: [$config, $db, $redis, $redisSubscriber],
|
||||
exports: [$config, $db, $redis, $redisSubscriber, RepositoryModule],
|
||||
})
|
||||
export class GlobalModule implements OnApplicationShutdown {
|
||||
constructor(
|
||||
@Inject(DI.db) private db: DataSource,
|
||||
@Inject(DI.redis) private redisClient: Redis,
|
||||
@Inject(DI.redisSubscriber) private redisSubscriber: Redis,
|
||||
) {}
|
||||
|
||||
async onApplicationShutdown(signal: string): Promise<void> {
|
||||
await Promise.all([
|
||||
this.db.destroy(),
|
||||
this.redisClient.disconnect(),
|
||||
this.redisSubscriber.disconnect(),
|
||||
]);
|
||||
}
|
||||
}
|
519
packages/backend/src/RepositoryModule.ts
Normal file
519
packages/backend/src/RepositoryModule.ts
Normal file
@ -0,0 +1,519 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest } from './models/index.js';
|
||||
import type { DataSource } from 'typeorm';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
const $usersRepository: Provider = {
|
||||
provide: DI.usersRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(User),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $notesRepository: Provider = {
|
||||
provide: DI.notesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Note),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $announcementsRepository: Provider = {
|
||||
provide: DI.announcementsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Announcement),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $announcementReadsRepository: Provider = {
|
||||
provide: DI.announcementReadsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AnnouncementRead),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $appsRepository: Provider = {
|
||||
provide: DI.appsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(App),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteFavoritesRepository: Provider = {
|
||||
provide: DI.noteFavoritesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteFavorite),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteThreadMutingsRepository: Provider = {
|
||||
provide: DI.noteThreadMutingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteThreadMuting),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteReactionsRepository: Provider = {
|
||||
provide: DI.noteReactionsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteReaction),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $noteUnreadsRepository: Provider = {
|
||||
provide: DI.noteUnreadsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(NoteUnread),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pollsRepository: Provider = {
|
||||
provide: DI.pollsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Poll),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pollVotesRepository: Provider = {
|
||||
provide: DI.pollVotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PollVote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userProfilesRepository: Provider = {
|
||||
provide: DI.userProfilesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserProfile),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userKeypairsRepository: Provider = {
|
||||
provide: DI.userKeypairsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserKeypair),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userPendingsRepository: Provider = {
|
||||
provide: DI.userPendingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserPending),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $attestationChallengesRepository: Provider = {
|
||||
provide: DI.attestationChallengesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AttestationChallenge),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userSecurityKeysRepository: Provider = {
|
||||
provide: DI.userSecurityKeysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserSecurityKey),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userPublickeysRepository: Provider = {
|
||||
provide: DI.userPublickeysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserPublickey),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userListsRepository: Provider = {
|
||||
provide: DI.userListsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserList),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userListJoiningsRepository: Provider = {
|
||||
provide: DI.userListJoiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserListJoining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userGroupsRepository: Provider = {
|
||||
provide: DI.userGroupsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserGroup),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userGroupJoiningsRepository: Provider = {
|
||||
provide: DI.userGroupJoiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserGroupJoining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userGroupInvitationsRepository: Provider = {
|
||||
provide: DI.userGroupInvitationsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserGroupInvitation),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userNotePiningsRepository: Provider = {
|
||||
provide: DI.userNotePiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserNotePining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $userIpsRepository: Provider = {
|
||||
provide: DI.userIpsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UserIp),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $usedUsernamesRepository: Provider = {
|
||||
provide: DI.usedUsernamesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(UsedUsername),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $followingsRepository: Provider = {
|
||||
provide: DI.followingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Following),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $followRequestsRepository: Provider = {
|
||||
provide: DI.followRequestsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(FollowRequest),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $instancesRepository: Provider = {
|
||||
provide: DI.instancesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Instance),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $emojisRepository: Provider = {
|
||||
provide: DI.emojisRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Emoji),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $driveFilesRepository: Provider = {
|
||||
provide: DI.driveFilesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(DriveFile),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $driveFoldersRepository: Provider = {
|
||||
provide: DI.driveFoldersRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(DriveFolder),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $notificationsRepository: Provider = {
|
||||
provide: DI.notificationsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Notification),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $metasRepository: Provider = {
|
||||
provide: DI.metasRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Meta),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $mutingsRepository: Provider = {
|
||||
provide: DI.mutingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Muting),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $blockingsRepository: Provider = {
|
||||
provide: DI.blockingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Blocking),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $swSubscriptionsRepository: Provider = {
|
||||
provide: DI.swSubscriptionsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(SwSubscription),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $hashtagsRepository: Provider = {
|
||||
provide: DI.hashtagsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Hashtag),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $abuseUserReportsRepository: Provider = {
|
||||
provide: DI.abuseUserReportsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AbuseUserReport),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $registrationTicketsRepository: Provider = {
|
||||
provide: DI.registrationTicketsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(RegistrationTicket),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $authSessionsRepository: Provider = {
|
||||
provide: DI.authSessionsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AuthSession),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $accessTokensRepository: Provider = {
|
||||
provide: DI.accessTokensRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AccessToken),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $signinsRepository: Provider = {
|
||||
provide: DI.signinsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Signin),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $messagingMessagesRepository: Provider = {
|
||||
provide: DI.messagingMessagesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MessagingMessage),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pagesRepository: Provider = {
|
||||
provide: DI.pagesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Page),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $pageLikesRepository: Provider = {
|
||||
provide: DI.pageLikesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PageLike),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $galleryPostsRepository: Provider = {
|
||||
provide: DI.galleryPostsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(GalleryPost),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $galleryLikesRepository: Provider = {
|
||||
provide: DI.galleryLikesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(GalleryLike),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $moderationLogsRepository: Provider = {
|
||||
provide: DI.moderationLogsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ModerationLog),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $clipsRepository: Provider = {
|
||||
provide: DI.clipsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Clip),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $clipNotesRepository: Provider = {
|
||||
provide: DI.clipNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ClipNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $antennasRepository: Provider = {
|
||||
provide: DI.antennasRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Antenna),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $antennaNotesRepository: Provider = {
|
||||
provide: DI.antennaNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(AntennaNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $promoNotesRepository: Provider = {
|
||||
provide: DI.promoNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PromoNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $promoReadsRepository: Provider = {
|
||||
provide: DI.promoReadsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PromoRead),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $relaysRepository: Provider = {
|
||||
provide: DI.relaysRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Relay),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $mutedNotesRepository: Provider = {
|
||||
provide: DI.mutedNotesRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(MutedNote),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelsRepository: Provider = {
|
||||
provide: DI.channelsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Channel),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelFollowingsRepository: Provider = {
|
||||
provide: DI.channelFollowingsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ChannelFollowing),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $channelNotePiningsRepository: Provider = {
|
||||
provide: DI.channelNotePiningsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(ChannelNotePining),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $registryItemsRepository: Provider = {
|
||||
provide: DI.registryItemsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(RegistryItem),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $webhooksRepository: Provider = {
|
||||
provide: DI.webhooksRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Webhook),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $adsRepository: Provider = {
|
||||
provide: DI.adsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Ad),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $passwordResetRequestsRepository: Provider = {
|
||||
provide: DI.passwordResetRequestsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(PasswordResetRequest),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
],
|
||||
providers: [
|
||||
$usersRepository,
|
||||
$notesRepository,
|
||||
$announcementsRepository,
|
||||
$announcementReadsRepository,
|
||||
$appsRepository,
|
||||
$noteFavoritesRepository,
|
||||
$noteThreadMutingsRepository,
|
||||
$noteReactionsRepository,
|
||||
$noteUnreadsRepository,
|
||||
$pollsRepository,
|
||||
$pollVotesRepository,
|
||||
$userProfilesRepository,
|
||||
$userKeypairsRepository,
|
||||
$userPendingsRepository,
|
||||
$attestationChallengesRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userListsRepository,
|
||||
$userListJoiningsRepository,
|
||||
$userGroupsRepository,
|
||||
$userGroupJoiningsRepository,
|
||||
$userGroupInvitationsRepository,
|
||||
$userNotePiningsRepository,
|
||||
$userIpsRepository,
|
||||
$usedUsernamesRepository,
|
||||
$followingsRepository,
|
||||
$followRequestsRepository,
|
||||
$instancesRepository,
|
||||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$blockingsRepository,
|
||||
$swSubscriptionsRepository,
|
||||
$hashtagsRepository,
|
||||
$abuseUserReportsRepository,
|
||||
$registrationTicketsRepository,
|
||||
$authSessionsRepository,
|
||||
$accessTokensRepository,
|
||||
$signinsRepository,
|
||||
$messagingMessagesRepository,
|
||||
$pagesRepository,
|
||||
$pageLikesRepository,
|
||||
$galleryPostsRepository,
|
||||
$galleryLikesRepository,
|
||||
$moderationLogsRepository,
|
||||
$clipsRepository,
|
||||
$clipNotesRepository,
|
||||
$antennasRepository,
|
||||
$antennaNotesRepository,
|
||||
$promoNotesRepository,
|
||||
$promoReadsRepository,
|
||||
$relaysRepository,
|
||||
$mutedNotesRepository,
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelNotePiningsRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$adsRepository,
|
||||
$passwordResetRequestsRepository,
|
||||
],
|
||||
exports: [
|
||||
$usersRepository,
|
||||
$notesRepository,
|
||||
$announcementsRepository,
|
||||
$announcementReadsRepository,
|
||||
$appsRepository,
|
||||
$noteFavoritesRepository,
|
||||
$noteThreadMutingsRepository,
|
||||
$noteReactionsRepository,
|
||||
$noteUnreadsRepository,
|
||||
$pollsRepository,
|
||||
$pollVotesRepository,
|
||||
$userProfilesRepository,
|
||||
$userKeypairsRepository,
|
||||
$userPendingsRepository,
|
||||
$attestationChallengesRepository,
|
||||
$userSecurityKeysRepository,
|
||||
$userPublickeysRepository,
|
||||
$userListsRepository,
|
||||
$userListJoiningsRepository,
|
||||
$userGroupsRepository,
|
||||
$userGroupJoiningsRepository,
|
||||
$userGroupInvitationsRepository,
|
||||
$userNotePiningsRepository,
|
||||
$userIpsRepository,
|
||||
$usedUsernamesRepository,
|
||||
$followingsRepository,
|
||||
$followRequestsRepository,
|
||||
$instancesRepository,
|
||||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$blockingsRepository,
|
||||
$swSubscriptionsRepository,
|
||||
$hashtagsRepository,
|
||||
$abuseUserReportsRepository,
|
||||
$registrationTicketsRepository,
|
||||
$authSessionsRepository,
|
||||
$accessTokensRepository,
|
||||
$signinsRepository,
|
||||
$messagingMessagesRepository,
|
||||
$pagesRepository,
|
||||
$pageLikesRepository,
|
||||
$galleryPostsRepository,
|
||||
$galleryLikesRepository,
|
||||
$moderationLogsRepository,
|
||||
$clipsRepository,
|
||||
$clipNotesRepository,
|
||||
$antennasRepository,
|
||||
$antennaNotesRepository,
|
||||
$promoNotesRepository,
|
||||
$promoReadsRepository,
|
||||
$relaysRepository,
|
||||
$mutedNotesRepository,
|
||||
$channelsRepository,
|
||||
$channelFollowingsRepository,
|
||||
$channelNotePiningsRepository,
|
||||
$registryItemsRepository,
|
||||
$webhooksRepository,
|
||||
$adsRepository,
|
||||
$passwordResetRequestsRepository,
|
||||
],
|
||||
})
|
||||
export class RepositoryModule {}
|
@ -2,7 +2,7 @@ import cluster from 'node:cluster';
|
||||
import chalk from 'chalk';
|
||||
import Xev from 'xev';
|
||||
|
||||
import Logger from '@/services/logger.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { envOption } from '../env.js';
|
||||
|
||||
// for typeorm
|
||||
|
@ -6,14 +6,17 @@ import cluster from 'node:cluster';
|
||||
import chalk from 'chalk';
|
||||
import chalkTemplate from 'chalk-template';
|
||||
import semver from 'semver';
|
||||
|
||||
import Logger from '@/services/logger.js';
|
||||
import loadConfig from '@/config/load.js';
|
||||
import { Config } from '@/config/types.js';
|
||||
import { lessThan } from '@/prelude/array.js';
|
||||
import { envOption } from '../env.js';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import Logger from '@/logger.js';
|
||||
import { loadConfig } from '@/config.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { lessThan } from '@/misc/prelude/array.js';
|
||||
import { showMachineInfo } from '@/misc/show-machine-info.js';
|
||||
import { db, initDb } from '../db/postgre.js';
|
||||
import { DaemonModule } from '@/daemons/DaemonModule.js';
|
||||
import { JanitorService } from '@/daemons/JanitorService.js';
|
||||
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
|
||||
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
|
||||
import { envOption } from '../env.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
@ -60,7 +63,7 @@ export async function masterMain() {
|
||||
await showMachineInfo(bootLogger);
|
||||
showNodejsVersion();
|
||||
config = loadConfigBoot();
|
||||
await connectDb();
|
||||
//await connectDb();
|
||||
} catch (e) {
|
||||
bootLogger.error('Fatal error occurred during initialization', null, true);
|
||||
process.exit(1);
|
||||
@ -75,9 +78,11 @@ export async function masterMain() {
|
||||
bootLogger.succ(`Now listening on port ${config.port} on ${config.url}`, null, true);
|
||||
|
||||
if (!envOption.noDaemons) {
|
||||
import('../daemons/server-stats.js').then(x => x.default());
|
||||
import('../daemons/queue-stats.js').then(x => x.default());
|
||||
import('../daemons/janitor.js').then(x => x.default());
|
||||
const daemons = await NestFactory.createApplicationContext(DaemonModule);
|
||||
daemons.enableShutdownHooks();
|
||||
daemons.get(JanitorService).start();
|
||||
daemons.get(QueueStatsService).start();
|
||||
daemons.get(ServerStatsService).start();
|
||||
}
|
||||
}
|
||||
|
||||
@ -127,6 +132,7 @@ function loadConfigBoot(): Config {
|
||||
return config;
|
||||
}
|
||||
|
||||
/*
|
||||
async function connectDb(): Promise<void> {
|
||||
const dbLogger = bootLogger.createSubLogger('db');
|
||||
|
||||
@ -136,14 +142,15 @@ async function connectDb(): Promise<void> {
|
||||
await initDb();
|
||||
const v = await db.query('SHOW server_version').then(x => x[0].server_version);
|
||||
dbLogger.succ(`Connected: v${v}`);
|
||||
} catch (e) {
|
||||
} catch (err) {
|
||||
dbLogger.error('Cannot connect', null, true);
|
||||
dbLogger.error(e);
|
||||
dbLogger.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
async function spawnWorkers(limit: number = 1) {
|
||||
async function spawnWorkers(limit = 1) {
|
||||
const workers = Math.min(limit, os.cpus().length);
|
||||
bootLogger.info(`Starting ${workers} worker${workers === 1 ? '' : 's'}...`);
|
||||
await Promise.all([...Array(workers)].map(spawnWorker));
|
||||
@ -155,7 +162,7 @@ function spawnWorker(): Promise<void> {
|
||||
const worker = cluster.fork();
|
||||
worker.on('message', message => {
|
||||
if (message === 'listenFailed') {
|
||||
bootLogger.error(`The server Listen failed due to the previous error.`);
|
||||
bootLogger.error('The server Listen failed due to the previous error.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (message !== 'ready') return;
|
||||
|
@ -1,17 +1,29 @@
|
||||
import cluster from 'node:cluster';
|
||||
import { initDb } from '../db/postgre.js';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { envOption } from '@/env.js';
|
||||
import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
|
||||
import { ServerService } from '@/server/ServerService.js';
|
||||
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
|
||||
import { AppModule } from '../AppModule.js';
|
||||
|
||||
/**
|
||||
* Init worker process
|
||||
*/
|
||||
export async function workerMain() {
|
||||
await initDb();
|
||||
const app = await NestFactory.createApplicationContext(AppModule);
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// start server
|
||||
await import('../server/index.js').then(x => x.default());
|
||||
const serverService = app.get(ServerService);
|
||||
serverService.launch();
|
||||
|
||||
// start job queue
|
||||
import('../queue/index.js').then(x => x.default());
|
||||
if (!envOption.onlyServer) {
|
||||
const queueProcessorService = app.get(QueueProcessorService);
|
||||
queueProcessorService.start();
|
||||
}
|
||||
|
||||
app.get(ChartManagementService).run();
|
||||
|
||||
if (cluster.isWorker) {
|
||||
// Send a 'ready' message to parent process
|
||||
|
149
packages/backend/src/config.ts
Normal file
149
packages/backend/src/config.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Config loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
|
||||
/**
|
||||
* ユーザーが設定する必要のある情報
|
||||
*/
|
||||
export type Source = {
|
||||
repository_url?: string;
|
||||
feedback_url?: string;
|
||||
url: string;
|
||||
port: number;
|
||||
disableHsts?: boolean;
|
||||
db: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
elasticsearch: {
|
||||
host: string;
|
||||
port: number;
|
||||
ssl?: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
index?: string;
|
||||
};
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
accesslog?: string;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
id: string;
|
||||
|
||||
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
|
||||
|
||||
deliverJobConcurrency?: number;
|
||||
inboxJobConcurrency?: number;
|
||||
deliverJobPerSec?: number;
|
||||
inboxJobPerSec?: number;
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
syslog: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
|
||||
*/
|
||||
export type Mixin = {
|
||||
version: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
wsScheme: string;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
clientEntry: string;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
/**
|
||||
* Path of configuration directory
|
||||
*/
|
||||
const dir = `${_dirname}/../../../.config`;
|
||||
|
||||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.NODE_ENV === 'test'
|
||||
? `${dir}/test.yml`
|
||||
: `${dir}/default.yml`;
|
||||
|
||||
export function loadConfig() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/_client_dist_/manifest.json`, 'utf-8'));
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
|
||||
const url = tryCreateUrl(config.url);
|
||||
|
||||
config.url = url.origin;
|
||||
|
||||
config.port = config.port ?? parseInt(process.env.PORT ?? '', 10);
|
||||
|
||||
mixin.version = meta.version;
|
||||
mixin.host = url.host;
|
||||
mixin.hostname = url.hostname;
|
||||
mixin.scheme = url.protocol.replace(/:$/, '');
|
||||
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
|
||||
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
|
||||
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
mixin.clientEntry = clientManifest['src/init.ts'];
|
||||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
throw `url="${url}" is not a valid URL.`;
|
||||
}
|
||||
}
|
@ -1,3 +0,0 @@
|
||||
import load from './load.js';
|
||||
|
||||
export default load();
|
@ -1,62 +0,0 @@
|
||||
/**
|
||||
* Config loader
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import * as yaml from 'js-yaml';
|
||||
import { Source, Mixin } from './types.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
/**
|
||||
* Path of configuration directory
|
||||
*/
|
||||
const dir = `${_dirname}/../../../../.config`;
|
||||
|
||||
/**
|
||||
* Path of configuration file
|
||||
*/
|
||||
const path = process.env.NODE_ENV === 'test'
|
||||
? `${dir}/test.yml`
|
||||
: `${dir}/default.yml`;
|
||||
|
||||
export default function load() {
|
||||
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/meta.json`, 'utf-8'));
|
||||
const clientManifest = JSON.parse(fs.readFileSync(`${_dirname}/../../../../built/_client_dist_/manifest.json`, 'utf-8'));
|
||||
const config = yaml.load(fs.readFileSync(path, 'utf-8')) as Source;
|
||||
|
||||
const mixin = {} as Mixin;
|
||||
|
||||
const url = tryCreateUrl(config.url);
|
||||
|
||||
config.url = url.origin;
|
||||
|
||||
config.port = config.port || parseInt(process.env.PORT || '', 10);
|
||||
|
||||
mixin.version = meta.version;
|
||||
mixin.host = url.host;
|
||||
mixin.hostname = url.hostname;
|
||||
mixin.scheme = url.protocol.replace(/:$/, '');
|
||||
mixin.wsScheme = mixin.scheme.replace('http', 'ws');
|
||||
mixin.wsUrl = `${mixin.wsScheme}://${mixin.host}`;
|
||||
mixin.apiUrl = `${mixin.scheme}://${mixin.host}/api`;
|
||||
mixin.authUrl = `${mixin.scheme}://${mixin.host}/auth`;
|
||||
mixin.driveUrl = `${mixin.scheme}://${mixin.host}/files`;
|
||||
mixin.userAgent = `Misskey/${meta.version} (${config.url})`;
|
||||
mixin.clientEntry = clientManifest['src/init.ts'];
|
||||
|
||||
if (!config.redis.prefix) config.redis.prefix = mixin.host;
|
||||
|
||||
return Object.assign(config, mixin);
|
||||
}
|
||||
|
||||
function tryCreateUrl(url: string) {
|
||||
try {
|
||||
return new URL(url);
|
||||
} catch (e) {
|
||||
throw `url="${url}" is not a valid URL.`;
|
||||
}
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
/**
|
||||
* ユーザーが設定する必要のある情報
|
||||
*/
|
||||
export type Source = {
|
||||
repository_url?: string;
|
||||
feedback_url?: string;
|
||||
url: string;
|
||||
port: number;
|
||||
disableHsts?: boolean;
|
||||
db: {
|
||||
host: string;
|
||||
port: number;
|
||||
db: string;
|
||||
user: string;
|
||||
pass: string;
|
||||
disableCache?: boolean;
|
||||
extra?: { [x: string]: string };
|
||||
};
|
||||
redis: {
|
||||
host: string;
|
||||
port: number;
|
||||
family?: number;
|
||||
pass: string;
|
||||
db?: number;
|
||||
prefix?: string;
|
||||
};
|
||||
elasticsearch: {
|
||||
host: string;
|
||||
port: number;
|
||||
ssl?: boolean;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
index?: string;
|
||||
};
|
||||
|
||||
proxy?: string;
|
||||
proxySmtp?: string;
|
||||
proxyBypassHosts?: string[];
|
||||
|
||||
allowedPrivateNetworks?: string[];
|
||||
|
||||
maxFileSize?: number;
|
||||
|
||||
accesslog?: string;
|
||||
|
||||
clusterLimit?: number;
|
||||
|
||||
id: string;
|
||||
|
||||
outgoingAddressFamily?: 'ipv4' | 'ipv6' | 'dual';
|
||||
|
||||
deliverJobConcurrency?: number;
|
||||
inboxJobConcurrency?: number;
|
||||
deliverJobPerSec?: number;
|
||||
inboxJobPerSec?: number;
|
||||
deliverJobMaxAttempts?: number;
|
||||
inboxJobMaxAttempts?: number;
|
||||
|
||||
syslog: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
mediaProxy?: string;
|
||||
proxyRemoteFiles?: boolean;
|
||||
|
||||
signToActivityPubGet?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報
|
||||
*/
|
||||
export type Mixin = {
|
||||
version: string;
|
||||
host: string;
|
||||
hostname: string;
|
||||
scheme: string;
|
||||
wsScheme: string;
|
||||
apiUrl: string;
|
||||
wsUrl: string;
|
||||
authUrl: string;
|
||||
driveUrl: string;
|
||||
userAgent: string;
|
||||
clientEntry: string;
|
||||
};
|
||||
|
||||
export type Config = Source & Mixin;
|
38
packages/backend/src/core/AccountUpdateService.ts
Normal file
38
packages/backend/src/core/AccountUpdateService.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { ApDeliverManagerService } from '@/core/remote/activitypub/ApDeliverManagerService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccountUpdateService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private relayService: RelayService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async publishToFollowers(userId: User['id']) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
// フォロワーがリモートユーザーかつ投稿者がローカルユーザーならUpdateを配信
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderPerson(user), user));
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
}
|
60
packages/backend/src/core/AiService.ts
Normal file
60
packages/backend/src/core/AiService.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as nsfw from 'nsfwjs';
|
||||
import si from 'systeminformation';
|
||||
import { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const REQUIRED_CPU_FLAGS = ['avx2', 'fma'];
|
||||
let isSupportedCpu: undefined | boolean = undefined;
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
#model: nsfw.NSFWJS;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public async detectSensitive(path: string): Promise<nsfw.predictionType[] | null> {
|
||||
try {
|
||||
if (isSupportedCpu === undefined) {
|
||||
const cpuFlags = await this.#getCpuFlags();
|
||||
isSupportedCpu = REQUIRED_CPU_FLAGS.every(required => cpuFlags.includes(required));
|
||||
}
|
||||
|
||||
if (!isSupportedCpu) {
|
||||
console.error('This CPU cannot use TensorFlow.');
|
||||
return null;
|
||||
}
|
||||
|
||||
const tf = await import('@tensorflow/tfjs-node');
|
||||
|
||||
if (this.#model == null) this.#model = await nsfw.load(`file://${_dirname}/../../nsfw-model/`, { size: 299 });
|
||||
|
||||
const buffer = await fs.promises.readFile(path);
|
||||
const image = await tf.node.decodeImage(buffer, 3) as any;
|
||||
try {
|
||||
const predictions = await this.#model.classify(image);
|
||||
return predictions;
|
||||
} finally {
|
||||
image.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async #getCpuFlags(): Promise<string[]> {
|
||||
const str = await si.cpuFlags();
|
||||
return str.split(/\s+/);
|
||||
}
|
||||
}
|
228
packages/backend/src/core/AntennaService.ts
Normal file
228
packages/backend/src/core/AntennaService.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import type { Antenna } from '@/models/entities/Antenna.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isUserRelated } from '@/misc/is-user-related.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MutingsRepository, BlockingsRepository, NotesRepository, AntennaNotesRepository, AntennasRepository, UserGroupJoiningsRepository, UserListJoiningsRepository } from '@/models/index.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AntennaService implements OnApplicationShutdown {
|
||||
#antennasFetched: boolean;
|
||||
#antennas: Antenna[];
|
||||
#blockingCache: Cache<User['id'][]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
@Inject(DI.antennasRepository)
|
||||
private antennasRepository: AntennasRepository,
|
||||
|
||||
@Inject(DI.userGroupJoiningsRepository)
|
||||
private userGroupJoiningsRepository: UserGroupJoiningsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
) {
|
||||
this.#antennasFetched = false;
|
||||
this.#antennas = [];
|
||||
this.#blockingCache = new Cache<User['id'][]>(1000 * 60 * 5);
|
||||
|
||||
this.redisSubscriber.on('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onRedisMessage);
|
||||
}
|
||||
|
||||
private async onRedisMessage(_, data) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message;
|
||||
switch (type) {
|
||||
case 'antennaCreated':
|
||||
this.#antennas.push(body);
|
||||
break;
|
||||
case 'antennaUpdated':
|
||||
this.#antennas[this.#antennas.findIndex(a => a.id === body.id)] = body;
|
||||
break;
|
||||
case 'antennaDeleted':
|
||||
this.#antennas = this.#antennas.filter(a => a.id !== body.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async addNoteToAntenna(antenna: Antenna, note: Note, noteUser: { id: User['id']; }): Promise<void> {
|
||||
// 通知しない設定になっているか、自分自身の投稿なら既読にする
|
||||
const read = !antenna.notify || (antenna.userId === noteUser.id);
|
||||
|
||||
this.antennaNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
antennaId: antenna.id,
|
||||
noteId: note.id,
|
||||
read: read,
|
||||
});
|
||||
|
||||
this.globalEventServie.publishAntennaStream(antenna.id, 'note', note);
|
||||
|
||||
if (!read) {
|
||||
const mutings = await this.mutingsRepository.find({
|
||||
where: {
|
||||
muterId: antenna.userId,
|
||||
},
|
||||
select: ['muteeId'],
|
||||
});
|
||||
|
||||
// Copy
|
||||
const _note: Note = {
|
||||
...note,
|
||||
};
|
||||
|
||||
if (note.replyId != null) {
|
||||
_note.reply = await this.notesRepository.findOneByOrFail({ id: note.replyId });
|
||||
}
|
||||
if (note.renoteId != null) {
|
||||
_note.renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||
}
|
||||
|
||||
if (isUserRelated(_note, new Set<string>(mutings.map(x => x.muteeId)))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2秒経っても既読にならなかったら通知
|
||||
setTimeout(async () => {
|
||||
const unread = await this.antennaNotesRepository.findOneBy({ antennaId: antenna.id, read: false });
|
||||
if (unread) {
|
||||
this.globalEventServie.publishMainStream(antenna.userId, 'unreadAntenna', antenna);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: フォローしているユーザーのノート、リストのユーザーのノート、グループのユーザーのノート指定はパフォーマンス上の理由で無効になっている
|
||||
|
||||
/**
|
||||
* noteUserFollowers / antennaUserFollowing はどちらか一方が指定されていればよい
|
||||
*/
|
||||
public async checkHitAntenna(antenna: Antenna, note: (Note | Packed<'Note'>), noteUser: { id: User['id']; username: string; host: string | null; }, noteUserFollowers?: User['id'][], antennaUserFollowing?: User['id'][]): Promise<boolean> {
|
||||
if (note.visibility === 'specified') return false;
|
||||
|
||||
// アンテナ作成者がノート作成者にブロックされていたらスキップ
|
||||
const blockings = await this.#blockingCache.fetch(noteUser.id, () => this.blockingsRepository.findBy({ blockerId: noteUser.id }).then(res => res.map(x => x.blockeeId)));
|
||||
if (blockings.some(blocking => blocking === antenna.userId)) return false;
|
||||
|
||||
if (note.visibility === 'followers') {
|
||||
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
|
||||
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
|
||||
}
|
||||
|
||||
if (!antenna.withReplies && note.replyId != null) return false;
|
||||
|
||||
if (antenna.src === 'home') {
|
||||
if (noteUserFollowers && !noteUserFollowers.includes(antenna.userId)) return false;
|
||||
if (antennaUserFollowing && !antennaUserFollowing.includes(note.userId)) return false;
|
||||
} else if (antenna.src === 'list') {
|
||||
const listUsers = (await this.userListJoiningsRepository.findBy({
|
||||
userListId: antenna.userListId!,
|
||||
})).map(x => x.userId);
|
||||
|
||||
if (!listUsers.includes(note.userId)) return false;
|
||||
} else if (antenna.src === 'group') {
|
||||
const joining = await this.userGroupJoiningsRepository.findOneByOrFail({ id: antenna.userGroupJoiningId! });
|
||||
|
||||
const groupUsers = (await this.userGroupJoiningsRepository.findBy({
|
||||
userGroupId: joining.userGroupId,
|
||||
})).map(x => x.userId);
|
||||
|
||||
if (!groupUsers.includes(note.userId)) return false;
|
||||
} else if (antenna.src === 'users') {
|
||||
const accts = antenna.users.map(x => {
|
||||
const { username, host } = Acct.parse(x);
|
||||
return this.utilityService.getFullApAccount(username, host).toLowerCase();
|
||||
});
|
||||
if (!accts.includes(this.utilityService.getFullApAccount(noteUser.username, noteUser.host).toLowerCase())) return false;
|
||||
}
|
||||
|
||||
const keywords = antenna.keywords
|
||||
// Clean up
|
||||
.map(xs => xs.filter(x => x !== ''))
|
||||
.filter(xs => xs.length > 0);
|
||||
|
||||
if (keywords.length > 0) {
|
||||
if (note.text == null) return false;
|
||||
|
||||
const matched = keywords.some(and =>
|
||||
and.every(keyword =>
|
||||
antenna.caseSensitive
|
||||
? note.text!.includes(keyword)
|
||||
: note.text!.toLowerCase().includes(keyword.toLowerCase()),
|
||||
));
|
||||
|
||||
if (!matched) return false;
|
||||
}
|
||||
|
||||
const excludeKeywords = antenna.excludeKeywords
|
||||
// Clean up
|
||||
.map(xs => xs.filter(x => x !== ''))
|
||||
.filter(xs => xs.length > 0);
|
||||
|
||||
if (excludeKeywords.length > 0) {
|
||||
if (note.text == null) return false;
|
||||
|
||||
const matched = excludeKeywords.some(and =>
|
||||
and.every(keyword =>
|
||||
antenna.caseSensitive
|
||||
? note.text!.includes(keyword)
|
||||
: note.text!.toLowerCase().includes(keyword.toLowerCase()),
|
||||
));
|
||||
|
||||
if (matched) return false;
|
||||
}
|
||||
|
||||
if (antenna.withFile) {
|
||||
if (note.fileIds && note.fileIds.length === 0) return false;
|
||||
}
|
||||
|
||||
// TODO: eval expression
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAntennas() {
|
||||
if (!this.#antennasFetched) {
|
||||
this.#antennas = await this.antennasRepository.find();
|
||||
this.#antennasFetched = true;
|
||||
}
|
||||
|
||||
return this.#antennas;
|
||||
}
|
||||
}
|
40
packages/backend/src/core/AppLockService.ts
Normal file
40
packages/backend/src/core/AppLockService.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { promisify } from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import redisLock from 'redis-lock';
|
||||
import Redis from 'ioredis';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
/**
|
||||
* Retry delay (ms) for lock acquisition
|
||||
*/
|
||||
const retryDelay = 100;
|
||||
|
||||
@Injectable()
|
||||
export class AppLockService {
|
||||
#lock: (key: string, timeout?: number) => Promise<() => void>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
) {
|
||||
this.#lock = promisify(redisLock(this.redisClient, retryDelay));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get AP Object lock
|
||||
* @param uri AP object ID
|
||||
* @param timeout Lock timeout (ms), The timeout releases previous lock.
|
||||
* @returns Unlock function
|
||||
*/
|
||||
public getApLock(uri: string, timeout = 30 * 1000): Promise<() => void> {
|
||||
return this.#lock(`ap-object:${uri}`, timeout);
|
||||
}
|
||||
|
||||
public getFetchInstanceMetadataLock(host: string, timeout = 30 * 1000): Promise<() => void> {
|
||||
return this.#lock(`instance:${host}`, timeout);
|
||||
}
|
||||
|
||||
public getChartInsertLock(lockKey: string, timeout = 30 * 1000): Promise<() => void> {
|
||||
return this.#lock(`chart-insert:${lockKey}`, timeout);
|
||||
}
|
||||
}
|
70
packages/backend/src/core/CaptchaService.ts
Normal file
70
packages/backend/src/core/CaptchaService.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
|
||||
type CaptchaResponse = {
|
||||
success: boolean;
|
||||
'error-codes'?: string[];
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
async #getCaptchaResponse(url: string, secret: string, response: string): Promise<CaptchaResponse> {
|
||||
const params = new URLSearchParams({
|
||||
secret,
|
||||
response,
|
||||
});
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
// TODO
|
||||
//timeout: 10 * 1000,
|
||||
agent: (url, bypassProxy) => this.httpRequestService.getAgentByUrl(url, bypassProxy),
|
||||
}).catch(err => {
|
||||
throw `${err.message ?? err}`;
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw `${res.status}`;
|
||||
}
|
||||
|
||||
return await res.json() as CaptchaResponse;
|
||||
}
|
||||
|
||||
public async verifyRecaptcha(secret: string, response: string): Promise<void> {
|
||||
const result = await this.#getCaptchaResponse('https://www.recaptcha.net/recaptcha/api/siteverify', secret, response).catch(e => {
|
||||
throw `recaptcha-request-failed: ${e}`;
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `recaptcha-failed: ${errorCodes}`;
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyHcaptcha(secret: string, response: string): Promise<void> {
|
||||
const result = await this.#getCaptchaResponse('https://hcaptcha.com/siteverify', secret, response).catch(e => {
|
||||
throw `hcaptcha-request-failed: ${e}`;
|
||||
});
|
||||
|
||||
if (result.success !== true) {
|
||||
const errorCodes = result['error-codes'] ? result['error-codes'].join(', ') : '';
|
||||
throw `hcaptcha-failed: ${errorCodes}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
696
packages/backend/src/core/CoreModule.ts
Normal file
696
packages/backend/src/core/CoreModule.ts
Normal file
@ -0,0 +1,696 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DI } from '../di-symbols.js';
|
||||
import { AccountUpdateService } from './AccountUpdateService.js';
|
||||
import { AiService } from './AiService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
import { AppLockService } from './AppLockService.js';
|
||||
import { CaptchaService } from './CaptchaService.js';
|
||||
import { CreateNotificationService } from './CreateNotificationService.js';
|
||||
import { CreateSystemUserService } from './CreateSystemUserService.js';
|
||||
import { CustomEmojiService } from './CustomEmojiService.js';
|
||||
import { DeleteAccountService } from './DeleteAccountService.js';
|
||||
import { DownloadService } from './DownloadService.js';
|
||||
import { DriveService } from './DriveService.js';
|
||||
import { EmailService } from './EmailService.js';
|
||||
import { FederatedInstanceService } from './FederatedInstanceService.js';
|
||||
import { FetchInstanceMetadataService } from './FetchInstanceMetadataService.js';
|
||||
import { GlobalEventService } from './GlobalEventService.js';
|
||||
import { HashtagService } from './HashtagService.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
import { IdService } from './IdService.js';
|
||||
import { ImageProcessingService } from './ImageProcessingService.js';
|
||||
import { InstanceActorService } from './InstanceActorService.js';
|
||||
import { InternalStorageService } from './InternalStorageService.js';
|
||||
import { MessagingService } from './MessagingService.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
import { MfmService } from './MfmService.js';
|
||||
import { ModerationLogService } from './ModerationLogService.js';
|
||||
import { NoteCreateService } from './NoteCreateService.js';
|
||||
import { NoteDeleteService } from './NoteDeleteService.js';
|
||||
import { NotePiningService } from './NotePiningService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { PollService } from './PollService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
import { QueryService } from './QueryService.js';
|
||||
import { ReactionService } from './ReactionService.js';
|
||||
import { RelayService } from './RelayService.js';
|
||||
import { S3Service } from './S3Service.js';
|
||||
import { SignupService } from './SignupService.js';
|
||||
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
|
||||
import { UserBlockingService } from './UserBlockingService.js';
|
||||
import { UserCacheService } from './UserCacheService.js';
|
||||
import { UserFollowingService } from './UserFollowingService.js';
|
||||
import { UserKeypairStoreService } from './UserKeypairStoreService.js';
|
||||
import { UserListService } from './UserListService.js';
|
||||
import { UserMutingService } from './UserMutingService.js';
|
||||
import { UserSuspendService } from './UserSuspendService.js';
|
||||
import { VideoProcessingService } from './VideoProcessingService.js';
|
||||
import { WebhookService } from './WebhookService.js';
|
||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import { FileInfoService } from './FileInfoService.js';
|
||||
import FederationChart from './chart/charts/federation.js';
|
||||
import NotesChart from './chart/charts/notes.js';
|
||||
import UsersChart from './chart/charts/users.js';
|
||||
import ActiveUsersChart from './chart/charts/active-users.js';
|
||||
import InstanceChart from './chart/charts/instance.js';
|
||||
import PerUserNotesChart from './chart/charts/per-user-notes.js';
|
||||
import DriveChart from './chart/charts/drive.js';
|
||||
import PerUserReactionsChart from './chart/charts/per-user-reactions.js';
|
||||
import HashtagChart from './chart/charts/hashtag.js';
|
||||
import PerUserFollowingChart from './chart/charts/per-user-following.js';
|
||||
import PerUserDriveChart from './chart/charts/per-user-drive.js';
|
||||
import ApRequestChart from './chart/charts/ap-request.js';
|
||||
import { ChartManagementService } from './chart/ChartManagementService.js';
|
||||
import { AbuseUserReportEntityService } from './entities/AbuseUserReportEntityService.js';
|
||||
import { AntennaEntityService } from './entities/AntennaEntityService.js';
|
||||
import { AppEntityService } from './entities/AppEntityService.js';
|
||||
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
|
||||
import { BlockingEntityService } from './entities/BlockingEntityService.js';
|
||||
import { ChannelEntityService } from './entities/ChannelEntityService.js';
|
||||
import { ClipEntityService } from './entities/ClipEntityService.js';
|
||||
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
|
||||
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
|
||||
import { EmojiEntityService } from './entities/EmojiEntityService.js';
|
||||
import { FollowingEntityService } from './entities/FollowingEntityService.js';
|
||||
import { FollowRequestEntityService } from './entities/FollowRequestEntityService.js';
|
||||
import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js';
|
||||
import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js';
|
||||
import { HashtagEntityService } from './entities/HashtagEntityService.js';
|
||||
import { InstanceEntityService } from './entities/InstanceEntityService.js';
|
||||
import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js';
|
||||
import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js';
|
||||
import { MutingEntityService } from './entities/MutingEntityService.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js';
|
||||
import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js';
|
||||
import { NotificationEntityService } from './entities/NotificationEntityService.js';
|
||||
import { PageEntityService } from './entities/PageEntityService.js';
|
||||
import { PageLikeEntityService } from './entities/PageLikeEntityService.js';
|
||||
import { SigninEntityService } from './entities/SigninEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { UserGroupEntityService } from './entities/UserGroupEntityService.js';
|
||||
import { UserGroupInvitationEntityService } from './entities/UserGroupInvitationEntityService.js';
|
||||
import { UserListEntityService } from './entities/UserListEntityService.js';
|
||||
import { ApAudienceService } from './remote/activitypub/ApAudienceService.js';
|
||||
import { ApDbResolverService } from './remote/activitypub/ApDbResolverService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { ApInboxService } from './remote/activitypub/ApInboxService.js';
|
||||
import { ApLoggerService } from './remote/activitypub/ApLoggerService.js';
|
||||
import { ApMfmService } from './remote/activitypub/ApMfmService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { ApRequestService } from './remote/activitypub/ApRequestService.js';
|
||||
import { ApResolverService } from './remote/activitypub/ApResolverService.js';
|
||||
import { LdSignatureService } from './remote/activitypub/LdSignatureService.js';
|
||||
import { RemoteLoggerService } from './remote/RemoteLoggerService.js';
|
||||
import { ResolveUserService } from './remote/ResolveUserService.js';
|
||||
import { WebfingerService } from './remote/WebfingerService.js';
|
||||
import { ApImageService } from './remote/activitypub/models/ApImageService.js';
|
||||
import { ApMentionService } from './remote/activitypub/models/ApMentionService.js';
|
||||
import { ApNoteService } from './remote/activitypub/models/ApNoteService.js';
|
||||
import { ApPersonService } from './remote/activitypub/models/ApPersonService.js';
|
||||
import { ApQuestionService } from './remote/activitypub/models/ApQuestionService.js';
|
||||
import { QueueModule } from './queue/QueueModule.js';
|
||||
import { QueueService } from './QueueService.js';
|
||||
import type { Provider } from '@nestjs/common';
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
const $AccountUpdateService: Provider = { provide: 'AccountUpdateService', useClass: AccountUpdateService };
|
||||
const $AiService: Provider = { provide: 'AiService', useClass: AiService };
|
||||
const $AntennaService: Provider = { provide: 'AntennaService', useClass: AntennaService };
|
||||
const $AppLockService: Provider = { provide: 'AppLockService', useClass: AppLockService };
|
||||
const $CaptchaService: Provider = { provide: 'CaptchaService', useClass: CaptchaService };
|
||||
const $CreateNotificationService: Provider = { provide: 'CreateNotificationService', useClass: CreateNotificationService };
|
||||
const $CreateSystemUserService: Provider = { provide: 'CreateSystemUserService', useClass: CreateSystemUserService };
|
||||
const $CustomEmojiService: Provider = { provide: 'CustomEmojiService', useClass: CustomEmojiService };
|
||||
const $DeleteAccountService: Provider = { provide: 'DeleteAccountService', useClass: DeleteAccountService };
|
||||
const $DownloadService: Provider = { provide: 'DownloadService', useClass: DownloadService };
|
||||
const $DriveService: Provider = { provide: 'DriveService', useClass: DriveService };
|
||||
const $EmailService: Provider = { provide: 'EmailService', useClass: EmailService };
|
||||
const $FederatedInstanceService: Provider = { provide: 'FederatedInstanceService', useClass: FederatedInstanceService };
|
||||
const $FetchInstanceMetadataService: Provider = { provide: 'FetchInstanceMetadataService', useClass: FetchInstanceMetadataService };
|
||||
const $GlobalEventService: Provider = { provide: 'GlobalEventService', useClass: GlobalEventService };
|
||||
const $HashtagService: Provider = { provide: 'HashtagService', useClass: HashtagService };
|
||||
const $HttpRequestService: Provider = { provide: 'HttpRequestService', useClass: HttpRequestService };
|
||||
const $IdService: Provider = { provide: 'IdService', useClass: IdService };
|
||||
const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useClass: ImageProcessingService };
|
||||
const $InstanceActorService: Provider = { provide: 'InstanceActorService', useClass: InstanceActorService };
|
||||
const $InternalStorageService: Provider = { provide: 'InternalStorageService', useClass: InternalStorageService };
|
||||
const $MessagingService: Provider = { provide: 'MessagingService', useClass: MessagingService };
|
||||
const $MetaService: Provider = { provide: 'MetaService', useClass: MetaService };
|
||||
const $MfmService: Provider = { provide: 'MfmService', useClass: MfmService };
|
||||
const $ModerationLogService: Provider = { provide: 'ModerationLogService', useClass: ModerationLogService };
|
||||
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useClass: NoteCreateService };
|
||||
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useClass: NoteDeleteService };
|
||||
const $NotePiningService: Provider = { provide: 'NotePiningService', useClass: NotePiningService };
|
||||
const $NoteReadService: Provider = { provide: 'NoteReadService', useClass: NoteReadService };
|
||||
const $NotificationService: Provider = { provide: 'NotificationService', useClass: NotificationService };
|
||||
const $PollService: Provider = { provide: 'PollService', useClass: PollService };
|
||||
const $ProxyAccountService: Provider = { provide: 'ProxyAccountService', useClass: ProxyAccountService };
|
||||
const $PushNotificationService: Provider = { provide: 'PushNotificationService', useClass: PushNotificationService };
|
||||
const $QueryService: Provider = { provide: 'QueryService', useClass: QueryService };
|
||||
const $ReactionService: Provider = { provide: 'ReactionService', useClass: ReactionService };
|
||||
const $RelayService: Provider = { provide: 'RelayService', useClass: RelayService };
|
||||
const $S3Service: Provider = { provide: 'S3Service', useClass: S3Service };
|
||||
const $SignupService: Provider = { provide: 'SignupService', useClass: SignupService };
|
||||
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useClass: TwoFactorAuthenticationService };
|
||||
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useClass: UserBlockingService };
|
||||
const $UserCacheService: Provider = { provide: 'UserCacheService', useClass: UserCacheService };
|
||||
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useClass: UserFollowingService };
|
||||
const $UserKeypairStoreService: Provider = { provide: 'UserKeypairStoreService', useClass: UserKeypairStoreService };
|
||||
const $UserListService: Provider = { provide: 'UserListService', useClass: UserListService };
|
||||
const $UserMutingService: Provider = { provide: 'UserMutingService', useClass: UserMutingService };
|
||||
const $UserSuspendService: Provider = { provide: 'UserSuspendService', useClass: UserSuspendService };
|
||||
const $VideoProcessingService: Provider = { provide: 'VideoProcessingService', useClass: VideoProcessingService };
|
||||
const $WebhookService: Provider = { provide: 'WebhookService', useClass: WebhookService };
|
||||
const $UtilityService: Provider = { provide: 'UtilityService', useClass: UtilityService };
|
||||
const $FileInfoService: Provider = { provide: 'FileInfoService', useClass: FileInfoService };
|
||||
const $FederationChart: Provider = { provide: 'FederationChart', useClass: FederationChart };
|
||||
const $NotesChart: Provider = { provide: 'NotesChart', useClass: NotesChart };
|
||||
const $UsersChart: Provider = { provide: 'UsersChart', useClass: UsersChart };
|
||||
const $ActiveUsersChart: Provider = { provide: 'ActiveUsersChart', useClass: ActiveUsersChart };
|
||||
const $InstanceChart: Provider = { provide: 'InstanceChart', useClass: InstanceChart };
|
||||
const $PerUserNotesChart: Provider = { provide: 'PerUserNotesChart', useClass: PerUserNotesChart };
|
||||
const $DriveChart: Provider = { provide: 'DriveChart', useClass: DriveChart };
|
||||
const $PerUserReactionsChart: Provider = { provide: 'PerUserReactionsChart', useClass: PerUserReactionsChart };
|
||||
const $HashtagChart: Provider = { provide: 'HashtagChart', useClass: HashtagChart };
|
||||
const $PerUserFollowingChart: Provider = { provide: 'PerUserFollowingChart', useClass: PerUserFollowingChart };
|
||||
const $PerUserDriveChart: Provider = { provide: 'PerUserDriveChart', useClass: PerUserDriveChart };
|
||||
const $ApRequestChart: Provider = { provide: 'ApRequestChart', useClass: ApRequestChart };
|
||||
const $ChartManagementService: Provider = { provide: 'ChartManagementService', useClass: ChartManagementService };
|
||||
|
||||
const $AbuseUserReportEntityService: Provider = { provide: 'AbuseUserReportEntityService', useClass: AbuseUserReportEntityService };
|
||||
const $AntennaEntityService: Provider = { provide: 'AntennaEntityService', useClass: AntennaEntityService };
|
||||
const $AppEntityService: Provider = { provide: 'AppEntityService', useClass: AppEntityService };
|
||||
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useClass: AuthSessionEntityService };
|
||||
const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useClass: BlockingEntityService };
|
||||
const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useClass: ChannelEntityService };
|
||||
const $ClipEntityService: Provider = { provide: 'ClipEntityService', useClass: ClipEntityService };
|
||||
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useClass: DriveFileEntityService };
|
||||
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useClass: DriveFolderEntityService };
|
||||
const $EmojiEntityService: Provider = { provide: 'EmojiEntityService', useClass: EmojiEntityService };
|
||||
const $FollowingEntityService: Provider = { provide: 'FollowingEntityService', useClass: FollowingEntityService };
|
||||
const $FollowRequestEntityService: Provider = { provide: 'FollowRequestEntityService', useClass: FollowRequestEntityService };
|
||||
const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService', useClass: GalleryLikeEntityService };
|
||||
const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useClass: GalleryPostEntityService };
|
||||
const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useClass: HashtagEntityService };
|
||||
const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useClass: InstanceEntityService };
|
||||
const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useClass: MessagingMessageEntityService };
|
||||
const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useClass: ModerationLogEntityService };
|
||||
const $MutingEntityService: Provider = { provide: 'MutingEntityService', useClass: MutingEntityService };
|
||||
const $NoteEntityService: Provider = { provide: 'NoteEntityService', useClass: NoteEntityService };
|
||||
const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useClass: NoteFavoriteEntityService };
|
||||
const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useClass: NoteReactionEntityService };
|
||||
const $NotificationEntityService: Provider = { provide: 'NotificationEntityService', useClass: NotificationEntityService };
|
||||
const $PageEntityService: Provider = { provide: 'PageEntityService', useClass: PageEntityService };
|
||||
const $PageLikeEntityService: Provider = { provide: 'PageLikeEntityService', useClass: PageLikeEntityService };
|
||||
const $SigninEntityService: Provider = { provide: 'SigninEntityService', useClass: SigninEntityService };
|
||||
const $UserEntityService: Provider = { provide: 'UserEntityService', useClass: UserEntityService };
|
||||
const $UserGroupEntityService: Provider = { provide: 'UserGroupEntityService', useClass: UserGroupEntityService };
|
||||
const $UserGroupInvitationEntityService: Provider = { provide: 'UserGroupInvitationEntityService', useClass: UserGroupInvitationEntityService };
|
||||
const $UserListEntityService: Provider = { provide: 'UserListEntityService', useClass: UserListEntityService };
|
||||
|
||||
const $ApAudienceService: Provider = { provide: 'ApAudienceService', useClass: ApAudienceService };
|
||||
const $ApDbResolverService: Provider = { provide: 'ApDbResolverService', useClass: ApDbResolverService };
|
||||
const $ApDeliverManagerService: Provider = { provide: 'ApDeliverManagerService', useClass: ApDeliverManagerService };
|
||||
const $ApInboxService: Provider = { provide: 'ApInboxService', useClass: ApInboxService };
|
||||
const $ApLoggerService: Provider = { provide: 'ApLoggerService', useClass: ApLoggerService };
|
||||
const $ApMfmService: Provider = { provide: 'ApMfmService', useClass: ApMfmService };
|
||||
const $ApRendererService: Provider = { provide: 'ApRendererService', useClass: ApRendererService };
|
||||
const $ApRequestService: Provider = { provide: 'ApRequestService', useClass: ApRequestService };
|
||||
const $ApResolverService: Provider = { provide: 'ApResolverService', useClass: ApResolverService };
|
||||
const $LdSignatureService: Provider = { provide: 'LdSignatureService', useClass: LdSignatureService };
|
||||
const $RemoteLoggerService: Provider = { provide: 'RemoteLoggerService', useClass: RemoteLoggerService };
|
||||
const $ResolveUserService: Provider = { provide: 'ResolveUserService', useClass: ResolveUserService };
|
||||
const $WebfingerService: Provider = { provide: 'WebfingerService', useClass: WebfingerService };
|
||||
const $ApImageService: Provider = { provide: 'ApImageService', useClass: ApImageService };
|
||||
const $ApMentionService: Provider = { provide: 'ApMentionService', useClass: ApMentionService };
|
||||
const $ApNoteService: Provider = { provide: 'ApNoteService', useClass: ApNoteService };
|
||||
const $ApPersonService: Provider = { provide: 'ApPersonService', useClass: ApPersonService };
|
||||
const $ApQuestionService: Provider = { provide: 'ApQuestionService', useClass: ApQuestionService };
|
||||
//#endregion
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
QueueModule,
|
||||
],
|
||||
providers: [
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
AntennaService,
|
||||
AppLockService,
|
||||
CaptchaService,
|
||||
CreateNotificationService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
DriveService,
|
||||
EmailService,
|
||||
FederatedInstanceService,
|
||||
FetchInstanceMetadataService,
|
||||
GlobalEventService,
|
||||
HashtagService,
|
||||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalStorageService,
|
||||
MessagingService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
ModerationLogService,
|
||||
NoteCreateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
RelayService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
UserBlockingService,
|
||||
UserCacheService,
|
||||
UserFollowingService,
|
||||
UserKeypairStoreService,
|
||||
UserListService,
|
||||
UserMutingService,
|
||||
UserSuspendService,
|
||||
VideoProcessingService,
|
||||
WebhookService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
ActiveUsersChart,
|
||||
InstanceChart,
|
||||
PerUserNotesChart,
|
||||
DriveChart,
|
||||
PerUserReactionsChart,
|
||||
HashtagChart,
|
||||
PerUserFollowingChart,
|
||||
PerUserDriveChart,
|
||||
ApRequestChart,
|
||||
ChartManagementService,
|
||||
AbuseUserReportEntityService,
|
||||
AntennaEntityService,
|
||||
AppEntityService,
|
||||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
EmojiEntityService,
|
||||
FollowingEntityService,
|
||||
FollowRequestEntityService,
|
||||
GalleryLikeEntityService,
|
||||
GalleryPostEntityService,
|
||||
HashtagEntityService,
|
||||
InstanceEntityService,
|
||||
MessagingMessageEntityService,
|
||||
ModerationLogEntityService,
|
||||
MutingEntityService,
|
||||
NoteEntityService,
|
||||
NoteFavoriteEntityService,
|
||||
NoteReactionEntityService,
|
||||
NotificationEntityService,
|
||||
PageEntityService,
|
||||
PageLikeEntityService,
|
||||
SigninEntityService,
|
||||
UserEntityService,
|
||||
UserGroupEntityService,
|
||||
UserGroupInvitationEntityService,
|
||||
UserListEntityService,
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
ApDeliverManagerService,
|
||||
ApInboxService,
|
||||
ApLoggerService,
|
||||
ApMfmService,
|
||||
ApRendererService,
|
||||
ApRequestService,
|
||||
ApResolverService,
|
||||
LdSignatureService,
|
||||
RemoteLoggerService,
|
||||
ResolveUserService,
|
||||
WebfingerService,
|
||||
ApImageService,
|
||||
ApMentionService,
|
||||
ApNoteService,
|
||||
ApPersonService,
|
||||
ApQuestionService,
|
||||
QueueService,
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$AntennaService,
|
||||
$AppLockService,
|
||||
$CaptchaService,
|
||||
$CreateNotificationService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
$DriveService,
|
||||
$EmailService,
|
||||
$FederatedInstanceService,
|
||||
$FetchInstanceMetadataService,
|
||||
$GlobalEventService,
|
||||
$HashtagService,
|
||||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalStorageService,
|
||||
$MessagingService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
$ModerationLogService,
|
||||
$NoteCreateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
$RelayService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$UserBlockingService,
|
||||
$UserCacheService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairStoreService,
|
||||
$UserListService,
|
||||
$UserMutingService,
|
||||
$UserSuspendService,
|
||||
$VideoProcessingService,
|
||||
$WebhookService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
$ActiveUsersChart,
|
||||
$InstanceChart,
|
||||
$PerUserNotesChart,
|
||||
$DriveChart,
|
||||
$PerUserReactionsChart,
|
||||
$HashtagChart,
|
||||
$PerUserFollowingChart,
|
||||
$PerUserDriveChart,
|
||||
$ApRequestChart,
|
||||
$ChartManagementService,
|
||||
$AbuseUserReportEntityService,
|
||||
$AntennaEntityService,
|
||||
$AppEntityService,
|
||||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
$EmojiEntityService,
|
||||
$FollowingEntityService,
|
||||
$FollowRequestEntityService,
|
||||
$GalleryLikeEntityService,
|
||||
$GalleryPostEntityService,
|
||||
$HashtagEntityService,
|
||||
$InstanceEntityService,
|
||||
$MessagingMessageEntityService,
|
||||
$ModerationLogEntityService,
|
||||
$MutingEntityService,
|
||||
$NoteEntityService,
|
||||
$NoteFavoriteEntityService,
|
||||
$NoteReactionEntityService,
|
||||
$NotificationEntityService,
|
||||
$PageEntityService,
|
||||
$PageLikeEntityService,
|
||||
$SigninEntityService,
|
||||
$UserEntityService,
|
||||
$UserGroupEntityService,
|
||||
$UserGroupInvitationEntityService,
|
||||
$UserListEntityService,
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
$ApDeliverManagerService,
|
||||
$ApInboxService,
|
||||
$ApLoggerService,
|
||||
$ApMfmService,
|
||||
$ApRendererService,
|
||||
$ApRequestService,
|
||||
$ApResolverService,
|
||||
$LdSignatureService,
|
||||
$RemoteLoggerService,
|
||||
$ResolveUserService,
|
||||
$WebfingerService,
|
||||
$ApImageService,
|
||||
$ApMentionService,
|
||||
$ApNoteService,
|
||||
$ApPersonService,
|
||||
$ApQuestionService,
|
||||
//#endregion
|
||||
],
|
||||
exports: [
|
||||
QueueModule,
|
||||
AccountUpdateService,
|
||||
AiService,
|
||||
AntennaService,
|
||||
AppLockService,
|
||||
CaptchaService,
|
||||
CreateNotificationService,
|
||||
CreateSystemUserService,
|
||||
CustomEmojiService,
|
||||
DeleteAccountService,
|
||||
DownloadService,
|
||||
DriveService,
|
||||
EmailService,
|
||||
FederatedInstanceService,
|
||||
FetchInstanceMetadataService,
|
||||
GlobalEventService,
|
||||
HashtagService,
|
||||
HttpRequestService,
|
||||
IdService,
|
||||
ImageProcessingService,
|
||||
InstanceActorService,
|
||||
InternalStorageService,
|
||||
MessagingService,
|
||||
MetaService,
|
||||
MfmService,
|
||||
ModerationLogService,
|
||||
NoteCreateService,
|
||||
NoteDeleteService,
|
||||
NotePiningService,
|
||||
NoteReadService,
|
||||
NotificationService,
|
||||
PollService,
|
||||
ProxyAccountService,
|
||||
PushNotificationService,
|
||||
QueryService,
|
||||
ReactionService,
|
||||
RelayService,
|
||||
S3Service,
|
||||
SignupService,
|
||||
TwoFactorAuthenticationService,
|
||||
UserBlockingService,
|
||||
UserCacheService,
|
||||
UserFollowingService,
|
||||
UserKeypairStoreService,
|
||||
UserListService,
|
||||
UserMutingService,
|
||||
UserSuspendService,
|
||||
VideoProcessingService,
|
||||
WebhookService,
|
||||
UtilityService,
|
||||
FileInfoService,
|
||||
FederationChart,
|
||||
NotesChart,
|
||||
UsersChart,
|
||||
ActiveUsersChart,
|
||||
InstanceChart,
|
||||
PerUserNotesChart,
|
||||
DriveChart,
|
||||
PerUserReactionsChart,
|
||||
HashtagChart,
|
||||
PerUserFollowingChart,
|
||||
PerUserDriveChart,
|
||||
ApRequestChart,
|
||||
ChartManagementService,
|
||||
AbuseUserReportEntityService,
|
||||
AntennaEntityService,
|
||||
AppEntityService,
|
||||
AuthSessionEntityService,
|
||||
BlockingEntityService,
|
||||
ChannelEntityService,
|
||||
ClipEntityService,
|
||||
DriveFileEntityService,
|
||||
DriveFolderEntityService,
|
||||
EmojiEntityService,
|
||||
FollowingEntityService,
|
||||
FollowRequestEntityService,
|
||||
GalleryLikeEntityService,
|
||||
GalleryPostEntityService,
|
||||
HashtagEntityService,
|
||||
InstanceEntityService,
|
||||
MessagingMessageEntityService,
|
||||
ModerationLogEntityService,
|
||||
MutingEntityService,
|
||||
NoteEntityService,
|
||||
NoteFavoriteEntityService,
|
||||
NoteReactionEntityService,
|
||||
NotificationEntityService,
|
||||
PageEntityService,
|
||||
PageLikeEntityService,
|
||||
SigninEntityService,
|
||||
UserEntityService,
|
||||
UserGroupEntityService,
|
||||
UserGroupInvitationEntityService,
|
||||
UserListEntityService,
|
||||
ApAudienceService,
|
||||
ApDbResolverService,
|
||||
ApDeliverManagerService,
|
||||
ApInboxService,
|
||||
ApLoggerService,
|
||||
ApMfmService,
|
||||
ApRendererService,
|
||||
ApRequestService,
|
||||
ApResolverService,
|
||||
LdSignatureService,
|
||||
RemoteLoggerService,
|
||||
ResolveUserService,
|
||||
WebfingerService,
|
||||
ApImageService,
|
||||
ApMentionService,
|
||||
ApNoteService,
|
||||
ApPersonService,
|
||||
ApQuestionService,
|
||||
QueueService,
|
||||
|
||||
//#region 文字列ベースでのinjection用(循環参照対応のため)
|
||||
$AccountUpdateService,
|
||||
$AiService,
|
||||
$AntennaService,
|
||||
$AppLockService,
|
||||
$CaptchaService,
|
||||
$CreateNotificationService,
|
||||
$CreateSystemUserService,
|
||||
$CustomEmojiService,
|
||||
$DeleteAccountService,
|
||||
$DownloadService,
|
||||
$DriveService,
|
||||
$EmailService,
|
||||
$FederatedInstanceService,
|
||||
$FetchInstanceMetadataService,
|
||||
$GlobalEventService,
|
||||
$HashtagService,
|
||||
$HttpRequestService,
|
||||
$IdService,
|
||||
$ImageProcessingService,
|
||||
$InstanceActorService,
|
||||
$InternalStorageService,
|
||||
$MessagingService,
|
||||
$MetaService,
|
||||
$MfmService,
|
||||
$ModerationLogService,
|
||||
$NoteCreateService,
|
||||
$NoteDeleteService,
|
||||
$NotePiningService,
|
||||
$NoteReadService,
|
||||
$NotificationService,
|
||||
$PollService,
|
||||
$ProxyAccountService,
|
||||
$PushNotificationService,
|
||||
$QueryService,
|
||||
$ReactionService,
|
||||
$RelayService,
|
||||
$S3Service,
|
||||
$SignupService,
|
||||
$TwoFactorAuthenticationService,
|
||||
$UserBlockingService,
|
||||
$UserCacheService,
|
||||
$UserFollowingService,
|
||||
$UserKeypairStoreService,
|
||||
$UserListService,
|
||||
$UserMutingService,
|
||||
$UserSuspendService,
|
||||
$VideoProcessingService,
|
||||
$WebhookService,
|
||||
$UtilityService,
|
||||
$FileInfoService,
|
||||
$FederationChart,
|
||||
$NotesChart,
|
||||
$UsersChart,
|
||||
$ActiveUsersChart,
|
||||
$InstanceChart,
|
||||
$PerUserNotesChart,
|
||||
$DriveChart,
|
||||
$PerUserReactionsChart,
|
||||
$HashtagChart,
|
||||
$PerUserFollowingChart,
|
||||
$PerUserDriveChart,
|
||||
$ApRequestChart,
|
||||
$ChartManagementService,
|
||||
$AbuseUserReportEntityService,
|
||||
$AntennaEntityService,
|
||||
$AppEntityService,
|
||||
$AuthSessionEntityService,
|
||||
$BlockingEntityService,
|
||||
$ChannelEntityService,
|
||||
$ClipEntityService,
|
||||
$DriveFileEntityService,
|
||||
$DriveFolderEntityService,
|
||||
$EmojiEntityService,
|
||||
$FollowingEntityService,
|
||||
$FollowRequestEntityService,
|
||||
$GalleryLikeEntityService,
|
||||
$GalleryPostEntityService,
|
||||
$HashtagEntityService,
|
||||
$InstanceEntityService,
|
||||
$MessagingMessageEntityService,
|
||||
$ModerationLogEntityService,
|
||||
$MutingEntityService,
|
||||
$NoteEntityService,
|
||||
$NoteFavoriteEntityService,
|
||||
$NoteReactionEntityService,
|
||||
$NotificationEntityService,
|
||||
$PageEntityService,
|
||||
$PageLikeEntityService,
|
||||
$SigninEntityService,
|
||||
$UserEntityService,
|
||||
$UserGroupEntityService,
|
||||
$UserGroupInvitationEntityService,
|
||||
$UserListEntityService,
|
||||
$ApAudienceService,
|
||||
$ApDbResolverService,
|
||||
$ApDeliverManagerService,
|
||||
$ApInboxService,
|
||||
$ApLoggerService,
|
||||
$ApMfmService,
|
||||
$ApRendererService,
|
||||
$ApRequestService,
|
||||
$ApResolverService,
|
||||
$LdSignatureService,
|
||||
$RemoteLoggerService,
|
||||
$ResolveUserService,
|
||||
$WebfingerService,
|
||||
$ApImageService,
|
||||
$ApMentionService,
|
||||
$ApNoteService,
|
||||
$ApPersonService,
|
||||
$ApQuestionService,
|
||||
//#endregion
|
||||
],
|
||||
})
|
||||
export class CoreModule {}
|
114
packages/backend/src/core/CreateNotificationService.ts
Normal file
114
packages/backend/src/core/CreateNotificationService.ts
Normal file
@ -0,0 +1,114 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Notification } from '@/models/entities/Notification.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotificationEntityService } from './entities/NotificationEntityService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateNotificationService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private notificationEntityService: NotificationEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async createNotification(
|
||||
notifieeId: User['id'],
|
||||
type: Notification['type'],
|
||||
data: Partial<Notification>,
|
||||
): Promise<Notification | null> {
|
||||
if (data.notifierId && (notifieeId === data.notifierId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
|
||||
|
||||
const isMuted = profile?.mutingNotificationTypes.includes(type);
|
||||
|
||||
// Create notification
|
||||
const notification = await this.notificationsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
notifieeId: notifieeId,
|
||||
type: type,
|
||||
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
|
||||
isRead: isMuted,
|
||||
...data,
|
||||
} as Partial<Notification>)
|
||||
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const packed = await this.notificationEntityService.pack(notification, {});
|
||||
|
||||
// Publish notification event
|
||||
this.globalEventServie.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
|
||||
if (fresh == null) return; // 既に削除されているかもしれない
|
||||
if (fresh.isRead) return;
|
||||
|
||||
//#region ただしミュートしているユーザーからの通知なら無視
|
||||
const mutings = await this.mutingsRepository.findBy({
|
||||
muterId: notifieeId,
|
||||
});
|
||||
if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.globalEventServie.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||
|
||||
if (type === 'follow') this.#emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
|
||||
if (type === 'receiveFollowRequest') this.#emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! }));
|
||||
}, 2000);
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
// TODO
|
||||
//const locales = await import('../../../../locales/index.js');
|
||||
|
||||
// TODO: locale ファイルをクライアント用とサーバー用で分けたい
|
||||
|
||||
async #emailNotificationFollow(userId: User['id'], follower: User) {
|
||||
/*
|
||||
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
|
||||
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('follow')) return;
|
||||
const locale = locales[userProfile.lang ?? 'ja-JP'];
|
||||
const i18n = new I18n(locale);
|
||||
// TODO: render user information html
|
||||
sendEmail(userProfile.email, i18n.t('_email._follow.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
|
||||
*/
|
||||
}
|
||||
|
||||
async #emailNotificationReceiveFollowRequest(userId: User['id'], follower: User) {
|
||||
/*
|
||||
const userProfile = await UserProfiles.findOneByOrFail({ userId: userId });
|
||||
if (!userProfile.email || !userProfile.emailNotificationTypes.includes('receiveFollowRequest')) return;
|
||||
const locale = locales[userProfile.lang ?? 'ja-JP'];
|
||||
const i18n = new I18n(locale);
|
||||
// TODO: render user information html
|
||||
sendEmail(userProfile.email, i18n.t('_email._receiveFollowRequest.title'), `${follower.name} (@${Acct.toString(follower)})`, `${follower.name} (@${Acct.toString(follower)})`);
|
||||
*/
|
||||
}
|
||||
}
|
80
packages/backend/src/core/CreateSystemUserService.ts
Normal file
80
packages/backend/src/core/CreateSystemUserService.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import { IsNull, DataSource } from 'typeorm';
|
||||
import { genRsaKeyPair } from '@/misc/gen-key-pair.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { UsedUsername } from '@/models/entities/UsedUsername.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import generateNativeUserToken from '@/misc/generate-native-user-token.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateSystemUserService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async createSystemUser(username: string): Promise<User> {
|
||||
const password = uuid();
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
const hash = await bcrypt.hash(password, salt);
|
||||
|
||||
// Generate secret
|
||||
const secret = generateNativeUserToken();
|
||||
|
||||
const keyPair = await genRsaKeyPair(4096);
|
||||
|
||||
let account!: User;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const exist = await transactionalEntityManager.findOneBy(User, {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (exist) throw new Error('the user is already exists');
|
||||
|
||||
account = await transactionalEntityManager.insert(User, {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
username: username,
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: null,
|
||||
token: secret,
|
||||
isAdmin: false,
|
||||
isLocked: true,
|
||||
isExplorable: false,
|
||||
isBot: true,
|
||||
}).then(x => transactionalEntityManager.findOneByOrFail(User, x.identifiers[0]));
|
||||
|
||||
await transactionalEntityManager.insert(UserKeypair, {
|
||||
publicKey: keyPair.publicKey,
|
||||
privateKey: keyPair.privateKey,
|
||||
userId: account.id,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(UserProfile, {
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: false,
|
||||
password: hash,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(UsedUsername, {
|
||||
createdAt: new Date(),
|
||||
username: username.toLowerCase(),
|
||||
});
|
||||
});
|
||||
|
||||
return account;
|
||||
}
|
||||
}
|
175
packages/backend/src/core/CustomEmojiService.ts
Normal file
175
packages/backend/src/core/CustomEmojiService.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, In, IsNull } from 'typeorm';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Emoji } from '@/models/entities/Emoji.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { query } from '@/misc/prelude/url.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { EmojisRepository } from '@/models/index.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
import { ReactionService } from './ReactionService.js';
|
||||
|
||||
/**
|
||||
* 添付用絵文字情報
|
||||
*/
|
||||
type PopulatedEmoji = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class CustomEmojiService {
|
||||
#cache: Cache<Emoji | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private utilityService: UtilityService,
|
||||
private reactionService: ReactionService,
|
||||
) {
|
||||
this.#cache = new Cache<Emoji | null>(1000 * 60 * 60 * 12);
|
||||
}
|
||||
|
||||
public async add(data: {
|
||||
driveFile: DriveFile;
|
||||
name: string;
|
||||
category: string | null;
|
||||
aliases: string[];
|
||||
host: string | null;
|
||||
}): Promise<Emoji> {
|
||||
const emoji = await this.emojisRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
updatedAt: new Date(),
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
host: data.host,
|
||||
aliases: data.aliases,
|
||||
originalUrl: data.driveFile.url,
|
||||
publicUrl: data.driveFile.webpublicUrl ?? data.driveFile.url,
|
||||
type: data.driveFile.webpublicType ?? data.driveFile.type,
|
||||
}).then(x => this.emojisRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
await this.db.queryResultCache!.remove(['meta_emojis']);
|
||||
|
||||
return emoji;
|
||||
}
|
||||
|
||||
#normalizeHost(src: string | undefined, noteUserHost: string | null): string | null {
|
||||
// クエリに使うホスト
|
||||
let host = src === '.' ? null // .はローカルホスト (ここがマッチするのはリアクションのみ)
|
||||
: src === undefined ? noteUserHost // ノートなどでホスト省略表記の場合はローカルホスト (ここがリアクションにマッチすることはない)
|
||||
: this.utilityService.isSelfHost(src) ? null // 自ホスト指定
|
||||
: (src || noteUserHost); // 指定されたホスト || ノートなどの所有者のホスト (こっちがリアクションにマッチすることはない)
|
||||
|
||||
host = this.utilityService.toPunyNullable(host);
|
||||
|
||||
return host;
|
||||
}
|
||||
|
||||
#parseEmojiStr(emojiName: string, noteUserHost: string | null) {
|
||||
const match = emojiName.match(/^(\w+)(?:@([\w.-]+))?$/);
|
||||
if (!match) return { name: null, host: null };
|
||||
|
||||
const name = match[1];
|
||||
|
||||
// ホスト正規化
|
||||
const host = this.utilityService.toPunyNullable(this.#normalizeHost(match[2], noteUserHost));
|
||||
|
||||
return { name, host };
|
||||
}
|
||||
|
||||
/**
|
||||
* 添付用絵文字情報を解決する
|
||||
* @param emojiName ノートやユーザープロフィールに添付された、またはリアクションのカスタム絵文字名 (:は含めない, リアクションでローカルホストの場合は@.を付ける (これはdecodeReactionで可能))
|
||||
* @param noteUserHost ノートやユーザープロフィールの所有者のホスト
|
||||
* @returns 絵文字情報, nullは未マッチを意味する
|
||||
*/
|
||||
public async populateEmoji(emojiName: string, noteUserHost: string | null): Promise<PopulatedEmoji | null> {
|
||||
const { name, host } = this.#parseEmojiStr(emojiName, noteUserHost);
|
||||
if (name == null) return null;
|
||||
|
||||
const queryOrNull = async () => (await this.emojisRepository.findOneBy({
|
||||
name,
|
||||
host: host ?? IsNull(),
|
||||
})) ?? null;
|
||||
|
||||
const emoji = await this.#cache.fetch(`${name} ${host}`, queryOrNull);
|
||||
|
||||
if (emoji == null) return null;
|
||||
|
||||
const isLocal = emoji.host == null;
|
||||
const emojiUrl = emoji.publicUrl || emoji.originalUrl; // || emoji.originalUrl してるのは後方互換性のため
|
||||
const url = isLocal ? emojiUrl : `${this.config.url}/proxy/${encodeURIComponent((new URL(emojiUrl)).pathname)}?${query({ url: emojiUrl })}`;
|
||||
|
||||
return {
|
||||
name: emojiName,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 複数の添付用絵文字情報を解決する (キャシュ付き, 存在しないものは結果から除外される)
|
||||
*/
|
||||
public async populateEmojis(emojiNames: string[], noteUserHost: string | null): Promise<PopulatedEmoji[]> {
|
||||
const emojis = await Promise.all(emojiNames.map(x => this.populateEmoji(x, noteUserHost)));
|
||||
return emojis.filter((x): x is PopulatedEmoji => x != null);
|
||||
}
|
||||
|
||||
public aggregateNoteEmojis(notes: Note[]) {
|
||||
let emojis: { name: string | null; host: string | null; }[] = [];
|
||||
for (const note of notes) {
|
||||
emojis = emojis.concat(note.emojis
|
||||
.map(e => this.#parseEmojiStr(e, note.userHost)));
|
||||
if (note.renote) {
|
||||
emojis = emojis.concat(note.renote.emojis
|
||||
.map(e => this.#parseEmojiStr(e, note.renote!.userHost)));
|
||||
if (note.renote.user) {
|
||||
emojis = emojis.concat(note.renote.user.emojis
|
||||
.map(e => this.#parseEmojiStr(e, note.renote!.userHost)));
|
||||
}
|
||||
}
|
||||
const customReactions = Object.keys(note.reactions).map(x => this.reactionService.decodeReaction(x)).filter(x => x.name != null) as typeof emojis;
|
||||
emojis = emojis.concat(customReactions);
|
||||
if (note.user) {
|
||||
emojis = emojis.concat(note.user.emojis
|
||||
.map(e => this.#parseEmojiStr(e, note.userHost)));
|
||||
}
|
||||
}
|
||||
return emojis.filter(x => x.name != null) as { name: string; host: string | null; }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 与えられた絵文字のリストをデータベースから取得し、キャッシュに追加します
|
||||
*/
|
||||
public async prefetchEmojis(emojis: { name: string; host: string | null; }[]): Promise<void> {
|
||||
const notCachedEmojis = emojis.filter(emoji => this.#cache.get(`${emoji.name} ${emoji.host}`) == null);
|
||||
const emojisQuery: any[] = [];
|
||||
const hosts = new Set(notCachedEmojis.map(e => e.host));
|
||||
for (const host of hosts) {
|
||||
emojisQuery.push({
|
||||
name: In(notCachedEmojis.filter(e => e.host === host).map(e => e.name)),
|
||||
host: host ?? IsNull(),
|
||||
});
|
||||
}
|
||||
const _emojis = emojisQuery.length > 0 ? await this.emojisRepository.find({
|
||||
where: emojisQuery,
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
}) : [];
|
||||
for (const emoji of _emojis) {
|
||||
this.#cache.set(`${emoji.name} ${emoji.host}`, emoji);
|
||||
}
|
||||
}
|
||||
}
|
38
packages/backend/src/core/DeleteAccountService.ts
Normal file
38
packages/backend/src/core/DeleteAccountService.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { UserSuspendService } from '@/core/UserSuspendService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteAccountService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userSuspendService: UserSuspendService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async deleteAccount(user: {
|
||||
id: string;
|
||||
host: string | null;
|
||||
}): Promise<void> {
|
||||
// 物理削除する前にDelete activityを送信する
|
||||
await this.userSuspendService.doPostSuspend(user).catch(e => {});
|
||||
|
||||
this.queueService.createDeleteAccountJob(user, {
|
||||
soft: false,
|
||||
});
|
||||
|
||||
await this.usersRepository.update(user.id, {
|
||||
isDeleted: true,
|
||||
});
|
||||
|
||||
// Terminate streaming
|
||||
this.globalEventServie.publishUserEvent(user.id, 'terminate', {});
|
||||
}
|
||||
}
|
123
packages/backend/src/core/DownloadService.ts
Normal file
123
packages/backend/src/core/DownloadService.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as stream from 'node:stream';
|
||||
import * as util from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import IPCIDR from 'ip-cidr';
|
||||
import PrivateIp from 'private-ip';
|
||||
import got, * as Got from 'got';
|
||||
import chalk from 'chalk';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
@Injectable()
|
||||
export class DownloadService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
this.#logger = new Logger('download');
|
||||
}
|
||||
|
||||
public async downloadUrl(url: string, path: string): Promise<void> {
|
||||
this.#logger.info(`Downloading ${chalk.cyan(url)} ...`);
|
||||
|
||||
const timeout = 30 * 1000;
|
||||
const operationTimeout = 60 * 1000;
|
||||
const maxSize = this.config.maxFileSize ?? 262144000;
|
||||
|
||||
const req = got.stream(url, {
|
||||
headers: {
|
||||
'User-Agent': this.config.userAgent,
|
||||
},
|
||||
timeout: {
|
||||
lookup: timeout,
|
||||
connect: timeout,
|
||||
secureConnect: timeout,
|
||||
socket: timeout, // read timeout
|
||||
response: timeout,
|
||||
send: timeout,
|
||||
request: operationTimeout, // whole operation timeout
|
||||
},
|
||||
agent: {
|
||||
http: this.httpRequestService.httpAgent,
|
||||
https: this.httpRequestService.httpsAgent,
|
||||
},
|
||||
http2: false, // default
|
||||
retry: {
|
||||
limit: 0,
|
||||
},
|
||||
}).on('response', (res: Got.Response) => {
|
||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
||||
if (this.#isPrivateIp(res.ip)) {
|
||||
this.#logger.warn(`Blocked address: ${res.ip}`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
const contentLength = res.headers['content-length'];
|
||||
if (contentLength != null) {
|
||||
const size = Number(contentLength);
|
||||
if (size > maxSize) {
|
||||
this.#logger.warn(`maxSize exceeded (${size} > ${maxSize}) on response`);
|
||||
req.destroy();
|
||||
}
|
||||
}
|
||||
}).on('downloadProgress', (progress: Got.Progress) => {
|
||||
if (progress.transferred > maxSize) {
|
||||
this.#logger.warn(`maxSize exceeded (${progress.transferred} > ${maxSize}) on downloadProgress`);
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
await pipeline(req, fs.createWriteStream(path));
|
||||
} catch (e) {
|
||||
if (e instanceof Got.HTTPError) {
|
||||
throw new StatusError(`${e.response.statusCode} ${e.response.statusMessage}`, e.response.statusCode, e.response.statusMessage);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
this.#logger.succ(`Download finished: ${chalk.cyan(url)}`);
|
||||
}
|
||||
|
||||
public async downloadTextFile(url: string): Promise<string> {
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
this.#logger.info(`text file: Temp file is ${path}`);
|
||||
|
||||
try {
|
||||
// write content at URL to temp file
|
||||
await this.downloadUrl(url, path);
|
||||
|
||||
const text = await util.promisify(fs.readFile)(path, 'utf8');
|
||||
|
||||
return text;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
#isPrivateIp(ip: string): boolean {
|
||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||
const cidr = new IPCIDR(net);
|
||||
if (cidr.contains(ip)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return PrivateIp(ip);
|
||||
}
|
||||
}
|
740
packages/backend/src/core/DriveService.ts
Normal file
740
packages/backend/src/core/DriveService.ts
Normal file
@ -0,0 +1,740 @@
|
||||
import * as fs from 'node:fs';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import sharp from 'sharp';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DriveFilesRepository, UsersRepository, DriveFoldersRepository, UserProfilesRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import Logger from '@/Logger.js';
|
||||
import type { IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { FILE_TYPE_BROWSERSAFE } from '@/const.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { contentDisposition } from '@/misc/content-disposition.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { VideoProcessingService } from '@/core/VideoProcessingService.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { DriveFolder } from '@/models/entities/DriveFolder.js';
|
||||
import { createTemp } from '@/misc/create-temp.js';
|
||||
import DriveChart from '@/core/chart/charts/drive.js';
|
||||
import PerUserDriveChart from '@/core/chart/charts/per-user-drive.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { DownloadService } from '@/core/DownloadService.js';
|
||||
import { S3Service } from '@/core/S3Service.js';
|
||||
import { InternalStorageService } from '@/core/InternalStorageService.js';
|
||||
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { FileInfoService } from './FileInfoService.js';
|
||||
import type S3 from 'aws-sdk/clients/s3.js';
|
||||
|
||||
type AddFileArgs = {
|
||||
/** User who wish to add file */
|
||||
user: { id: User['id']; host: User['host']; driveCapacityOverrideMb: User['driveCapacityOverrideMb'] } | null;
|
||||
/** File path */
|
||||
path: string;
|
||||
/** Name */
|
||||
name?: string | null;
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
/** Folder ID */
|
||||
folderId?: any;
|
||||
/** If set to true, forcibly upload the file even if there is a file with the same hash. */
|
||||
force?: boolean;
|
||||
/** Do not save file to local */
|
||||
isLink?: boolean;
|
||||
/** URL of source (URLからアップロードされた場合(ローカル/リモート)の元URL) */
|
||||
url?: string | null;
|
||||
/** URL of source (リモートインスタンスのURLからアップロードされた場合の元URL) */
|
||||
uri?: string | null;
|
||||
/** Mark file as sensitive */
|
||||
sensitive?: boolean | null;
|
||||
|
||||
requestIp?: string | null;
|
||||
requestHeaders?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
type UploadFromUrlArgs = {
|
||||
url: string;
|
||||
user: { id: User['id']; host: User['host'] } | null;
|
||||
folderId?: DriveFolder['id'] | null;
|
||||
uri?: string | null;
|
||||
sensitive?: boolean;
|
||||
force?: boolean;
|
||||
isLink?: boolean;
|
||||
comment?: string | null;
|
||||
requestIp?: string | null;
|
||||
requestHeaders?: Record<string, string> | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class DriveService {
|
||||
#registerLogger: Logger;
|
||||
#downloaderLogger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.driveFoldersRepository)
|
||||
private driveFoldersRepository: DriveFoldersRepository,
|
||||
|
||||
private fileInfoService: FileInfoService,
|
||||
private userEntityService: UserEntityService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
private idService: IdService,
|
||||
private metaService: MetaService,
|
||||
private downloadService: DownloadService,
|
||||
private internalStorageService: InternalStorageService,
|
||||
private s3Service: S3Service,
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
private videoProcessingService: VideoProcessingService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private driveChart: DriveChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {
|
||||
const logger = new Logger('drive', 'blue');
|
||||
this.#registerLogger = logger.createSubLogger('register', 'yellow');
|
||||
this.#downloaderLogger = logger.createSubLogger('downloader');
|
||||
}
|
||||
|
||||
/***
|
||||
* Save file
|
||||
* @param path Path for original
|
||||
* @param name Name for original
|
||||
* @param type Content-Type for original
|
||||
* @param hash Hash for original
|
||||
* @param size Size for original
|
||||
*/
|
||||
async #save(file: DriveFile, path: string, name: string, type: string, hash: string, size: number): Promise<DriveFile> {
|
||||
// thunbnail, webpublic を必要なら生成
|
||||
const alts = await this.generateAlts(path, type, !file.uri);
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (meta.useObjectStorage) {
|
||||
//#region ObjectStorage params
|
||||
let [ext] = (name.match(/\.([a-zA-Z0-9_-]+)$/) ?? ['']);
|
||||
|
||||
if (ext === '') {
|
||||
if (type === 'image/jpeg') ext = '.jpg';
|
||||
if (type === 'image/png') ext = '.png';
|
||||
if (type === 'image/webp') ext = '.webp';
|
||||
if (type === 'image/apng') ext = '.apng';
|
||||
if (type === 'image/vnd.mozilla.apng') ext = '.apng';
|
||||
}
|
||||
|
||||
// 拡張子からContent-Typeを設定してそうな挙動を示すオブジェクトストレージ (upcloud?) も存在するので、
|
||||
// 許可されているファイル形式でしか拡張子をつけない
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) {
|
||||
ext = '';
|
||||
}
|
||||
|
||||
const baseUrl = meta.objectStorageBaseUrl
|
||||
|| `${ meta.objectStorageUseSSL ? 'https' : 'http' }://${ meta.objectStorageEndpoint }${ meta.objectStoragePort ? `:${meta.objectStoragePort}` : '' }/${ meta.objectStorageBucket }`;
|
||||
|
||||
// for original
|
||||
const key = `${meta.objectStoragePrefix}/${uuid()}${ext}`;
|
||||
const url = `${ baseUrl }/${ key }`;
|
||||
|
||||
// for alts
|
||||
let webpublicKey: string | null = null;
|
||||
let webpublicUrl: string | null = null;
|
||||
let thumbnailKey: string | null = null;
|
||||
let thumbnailUrl: string | null = null;
|
||||
//#endregion
|
||||
|
||||
//#region Uploads
|
||||
this.#registerLogger.info(`uploading original: ${key}`);
|
||||
const uploads = [
|
||||
this.#upload(key, fs.createReadStream(path), type, name),
|
||||
];
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicKey = `${meta.objectStoragePrefix}/webpublic-${uuid()}.${alts.webpublic.ext}`;
|
||||
webpublicUrl = `${ baseUrl }/${ webpublicKey }`;
|
||||
|
||||
this.#registerLogger.info(`uploading webpublic: ${webpublicKey}`);
|
||||
uploads.push(this.#upload(webpublicKey, alts.webpublic.data, alts.webpublic.type, name));
|
||||
}
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailKey = `${meta.objectStoragePrefix}/thumbnail-${uuid()}.${alts.thumbnail.ext}`;
|
||||
thumbnailUrl = `${ baseUrl }/${ thumbnailKey }`;
|
||||
|
||||
this.#registerLogger.info(`uploading thumbnail: ${thumbnailKey}`);
|
||||
uploads.push(this.#upload(thumbnailKey, alts.thumbnail.data, alts.thumbnail.type));
|
||||
}
|
||||
|
||||
await Promise.all(uploads);
|
||||
//#endregion
|
||||
|
||||
file.url = url;
|
||||
file.thumbnailUrl = thumbnailUrl;
|
||||
file.webpublicUrl = webpublicUrl;
|
||||
file.accessKey = key;
|
||||
file.thumbnailAccessKey = thumbnailKey;
|
||||
file.webpublicAccessKey = webpublicKey;
|
||||
file.webpublicType = alts.webpublic?.type ?? null;
|
||||
file.name = name;
|
||||
file.type = type;
|
||||
file.md5 = hash;
|
||||
file.size = size;
|
||||
file.storedInternal = false;
|
||||
|
||||
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
} else { // use internal storage
|
||||
const accessKey = uuid();
|
||||
const thumbnailAccessKey = 'thumbnail-' + uuid();
|
||||
const webpublicAccessKey = 'webpublic-' + uuid();
|
||||
|
||||
const url = this.internalStorageService.saveFromPath(accessKey, path);
|
||||
|
||||
let thumbnailUrl: string | null = null;
|
||||
let webpublicUrl: string | null = null;
|
||||
|
||||
if (alts.thumbnail) {
|
||||
thumbnailUrl = this.internalStorageService.saveFromBuffer(thumbnailAccessKey, alts.thumbnail.data);
|
||||
this.#registerLogger.info(`thumbnail stored: ${thumbnailAccessKey}`);
|
||||
}
|
||||
|
||||
if (alts.webpublic) {
|
||||
webpublicUrl = this.internalStorageService.saveFromBuffer(webpublicAccessKey, alts.webpublic.data);
|
||||
this.#registerLogger.info(`web stored: ${webpublicAccessKey}`);
|
||||
}
|
||||
|
||||
file.storedInternal = true;
|
||||
file.url = url;
|
||||
file.thumbnailUrl = thumbnailUrl;
|
||||
file.webpublicUrl = webpublicUrl;
|
||||
file.accessKey = accessKey;
|
||||
file.thumbnailAccessKey = thumbnailAccessKey;
|
||||
file.webpublicAccessKey = webpublicAccessKey;
|
||||
file.webpublicType = alts.webpublic?.type ?? null;
|
||||
file.name = name;
|
||||
file.type = type;
|
||||
file.md5 = hash;
|
||||
file.size = size;
|
||||
|
||||
return await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate webpublic, thumbnail, etc
|
||||
* @param path Path for original
|
||||
* @param type Content-Type for original
|
||||
* @param generateWeb Generate webpublic or not
|
||||
*/
|
||||
public async generateAlts(path: string, type: string, generateWeb: boolean) {
|
||||
if (type.startsWith('video/')) {
|
||||
try {
|
||||
const thumbnail = await this.videoProcessingService.generateVideoThumbnail(path);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail,
|
||||
};
|
||||
} catch (err) {
|
||||
this.#registerLogger.warn(`GenerateVideoThumbnail failed: ${err}`);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml'].includes(type)) {
|
||||
this.#registerLogger.debug('web image and thumbnail not created (not an required file)');
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
let img: sharp.Sharp | null = null;
|
||||
let satisfyWebpublic: boolean;
|
||||
|
||||
try {
|
||||
img = sharp(path);
|
||||
const metadata = await img.metadata();
|
||||
const isAnimated = metadata.pages && metadata.pages > 1;
|
||||
|
||||
// skip animated
|
||||
if (isAnimated) {
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
satisfyWebpublic = !!(
|
||||
type !== 'image/svg+xml' && type !== 'image/webp' &&
|
||||
!(metadata.exif || metadata.iptc || metadata.xmp || metadata.tifftagPhotoshop) &&
|
||||
metadata.width && metadata.width <= 2048 &&
|
||||
metadata.height && metadata.height <= 2048
|
||||
);
|
||||
} catch (err) {
|
||||
this.#registerLogger.warn(`sharp failed: ${err}`);
|
||||
return {
|
||||
webpublic: null,
|
||||
thumbnail: null,
|
||||
};
|
||||
}
|
||||
|
||||
// #region webpublic
|
||||
let webpublic: IImage | null = null;
|
||||
|
||||
if (generateWeb && !satisfyWebpublic) {
|
||||
this.#registerLogger.info('creating web image');
|
||||
|
||||
try {
|
||||
if (['image/jpeg', 'image/webp'].includes(type)) {
|
||||
webpublic = await this.imageProcessingService.convertSharpToJpeg(img, 2048, 2048);
|
||||
} else if (['image/png'].includes(type)) {
|
||||
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
|
||||
} else if (['image/svg+xml'].includes(type)) {
|
||||
webpublic = await this.imageProcessingService.convertSharpToPng(img, 2048, 2048);
|
||||
} else {
|
||||
this.#registerLogger.debug('web image not created (not an required image)');
|
||||
}
|
||||
} catch (err) {
|
||||
this.#registerLogger.warn('web image not created (an error occured)', err as Error);
|
||||
}
|
||||
} else {
|
||||
if (satisfyWebpublic) this.#registerLogger.info('web image not created (original satisfies webpublic)');
|
||||
else this.#registerLogger.info('web image not created (from remote)');
|
||||
}
|
||||
// #endregion webpublic
|
||||
|
||||
// #region thumbnail
|
||||
let thumbnail: IImage | null = null;
|
||||
|
||||
try {
|
||||
if (['image/jpeg', 'image/webp', 'image/png', 'image/svg+xml'].includes(type)) {
|
||||
thumbnail = await this.imageProcessingService.convertSharpToWebp(img, 498, 280);
|
||||
} else {
|
||||
this.#registerLogger.debug('thumbnail not created (not an required file)');
|
||||
}
|
||||
} catch (err) {
|
||||
this.#registerLogger.warn('thumbnail not created (an error occured)', err as Error);
|
||||
}
|
||||
// #endregion thumbnail
|
||||
|
||||
return {
|
||||
webpublic,
|
||||
thumbnail,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload to ObjectStorage
|
||||
*/
|
||||
async #upload(key: string, stream: fs.ReadStream | Buffer, type: string, filename?: string) {
|
||||
if (type === 'image/apng') type = 'image/png';
|
||||
if (!FILE_TYPE_BROWSERSAFE.includes(type)) type = 'application/octet-stream';
|
||||
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const params = {
|
||||
Bucket: meta.objectStorageBucket,
|
||||
Key: key,
|
||||
Body: stream,
|
||||
ContentType: type,
|
||||
CacheControl: 'max-age=31536000, immutable',
|
||||
} as S3.PutObjectRequest;
|
||||
|
||||
if (filename) params.ContentDisposition = contentDisposition('inline', filename);
|
||||
if (meta.objectStorageSetPublicRead) params.ACL = 'public-read';
|
||||
|
||||
const s3 = this.s3Service.getS3(meta);
|
||||
|
||||
const upload = s3.upload(params, {
|
||||
partSize: s3.endpoint.hostname === 'storage.googleapis.com' ? 500 * 1024 * 1024 : 8 * 1024 * 1024,
|
||||
});
|
||||
|
||||
const result = await upload.promise();
|
||||
if (result) this.#registerLogger.debug(`Uploaded: ${result.Bucket}/${result.Key} => ${result.Location}`);
|
||||
}
|
||||
|
||||
async #deleteOldFile(user: IRemoteUser) {
|
||||
const q = this.driveFilesRepository.createQueryBuilder('file')
|
||||
.where('file.userId = :userId', { userId: user.id })
|
||||
.andWhere('file.isLink = FALSE');
|
||||
|
||||
if (user.avatarId) {
|
||||
q.andWhere('file.id != :avatarId', { avatarId: user.avatarId });
|
||||
}
|
||||
|
||||
if (user.bannerId) {
|
||||
q.andWhere('file.id != :bannerId', { bannerId: user.bannerId });
|
||||
}
|
||||
|
||||
q.orderBy('file.id', 'ASC');
|
||||
|
||||
const oldFile = await q.getOne();
|
||||
|
||||
if (oldFile) {
|
||||
this.deleteFile(oldFile, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add file to drive
|
||||
*
|
||||
*/
|
||||
public async addFile({
|
||||
user,
|
||||
path,
|
||||
name = null,
|
||||
comment = null,
|
||||
folderId = null,
|
||||
force = false,
|
||||
isLink = false,
|
||||
url = null,
|
||||
uri = null,
|
||||
sensitive = null,
|
||||
requestIp = null,
|
||||
requestHeaders = null,
|
||||
}: AddFileArgs): Promise<DriveFile> {
|
||||
let skipNsfwCheck = false;
|
||||
const instance = await this.metaService.fetch();
|
||||
if (user == null) skipNsfwCheck = true;
|
||||
if (instance.sensitiveMediaDetection === 'none') skipNsfwCheck = true;
|
||||
if (user && instance.sensitiveMediaDetection === 'local' && this.userEntityService.isRemoteUser(user)) skipNsfwCheck = true;
|
||||
if (user && instance.sensitiveMediaDetection === 'remote' && this.userEntityService.isLocalUser(user)) skipNsfwCheck = true;
|
||||
|
||||
const info = await this.fileInfoService.getFileInfo(path, {
|
||||
skipSensitiveDetection: skipNsfwCheck,
|
||||
sensitiveThreshold: // 感度が高いほどしきい値は低くすることになる
|
||||
instance.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 0.1 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'high' ? 0.3 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'low' ? 0.7 :
|
||||
instance.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0.9 :
|
||||
0.5,
|
||||
sensitiveThresholdForPorn: 0.75,
|
||||
enableSensitiveMediaDetectionForVideos: instance.enableSensitiveMediaDetectionForVideos,
|
||||
});
|
||||
this.#registerLogger.info(`${JSON.stringify(info)}`);
|
||||
|
||||
// 現状 false positive が多すぎて実用に耐えない
|
||||
//if (info.porn && instance.disallowUploadWhenPredictedAsPorn) {
|
||||
// throw new IdentifiableError('282f77bf-5816-4f72-9264-aa14d8261a21', 'Detected as porn.');
|
||||
//}
|
||||
|
||||
// detect name
|
||||
const detectedName = name || (info.type.ext ? `untitled.${info.type.ext}` : 'untitled');
|
||||
|
||||
if (user && !force) {
|
||||
// Check if there is a file with the same hash
|
||||
const much = await this.driveFilesRepository.findOneBy({
|
||||
md5: info.md5,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (much) {
|
||||
this.#registerLogger.info(`file with same hash is found: ${much.id}`);
|
||||
return much;
|
||||
}
|
||||
}
|
||||
|
||||
//#region Check drive usage
|
||||
if (user && !isLink) {
|
||||
const usage = await this.driveFileEntityService.calcDriveUsageOf(user);
|
||||
const u = await this.usersRepository.findOneBy({ id: user.id });
|
||||
|
||||
const instance = await this.metaService.fetch();
|
||||
let driveCapacity = 1024 * 1024 * (this.userEntityService.isLocalUser(user) ? instance.localDriveCapacityMb : instance.remoteDriveCapacityMb);
|
||||
|
||||
if (this.userEntityService.isLocalUser(user) && u?.driveCapacityOverrideMb != null) {
|
||||
driveCapacity = 1024 * 1024 * u.driveCapacityOverrideMb;
|
||||
this.#registerLogger.debug('drive capacity override applied');
|
||||
this.#registerLogger.debug(`overrideCap: ${driveCapacity}bytes, usage: ${usage}bytes, u+s: ${usage + info.size}bytes`);
|
||||
}
|
||||
|
||||
this.#registerLogger.debug(`drive usage is ${usage} (max: ${driveCapacity})`);
|
||||
|
||||
// If usage limit exceeded
|
||||
if (usage + info.size > driveCapacity) {
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
throw new IdentifiableError('c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6', 'No free space.');
|
||||
} else {
|
||||
// (アバターまたはバナーを含まず)最も古いファイルを削除する
|
||||
this.#deleteOldFile(await this.usersRepository.findOneByOrFail({ id: user.id }) as IRemoteUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
const fetchFolder = async () => {
|
||||
if (!folderId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const driveFolder = await this.driveFoldersRepository.findOneBy({
|
||||
id: folderId,
|
||||
userId: user ? user.id : IsNull(),
|
||||
});
|
||||
|
||||
if (driveFolder == null) throw new Error('folder-not-found');
|
||||
|
||||
return driveFolder;
|
||||
};
|
||||
|
||||
const properties: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
} = {};
|
||||
|
||||
if (info.width) {
|
||||
properties['width'] = info.width;
|
||||
properties['height'] = info.height;
|
||||
}
|
||||
if (info.orientation != null) {
|
||||
properties['orientation'] = info.orientation;
|
||||
}
|
||||
|
||||
const profile = user ? await this.userProfilesRepository.findOneBy({ userId: user.id }) : null;
|
||||
|
||||
const folder = await fetchFolder();
|
||||
|
||||
let file = new DriveFile();
|
||||
file.id = this.idService.genId();
|
||||
file.createdAt = new Date();
|
||||
file.userId = user ? user.id : null;
|
||||
file.userHost = user ? user.host : null;
|
||||
file.folderId = folder !== null ? folder.id : null;
|
||||
file.comment = comment;
|
||||
file.properties = properties;
|
||||
file.blurhash = info.blurhash ?? null;
|
||||
file.isLink = isLink;
|
||||
file.requestIp = requestIp;
|
||||
file.requestHeaders = requestHeaders;
|
||||
file.maybeSensitive = info.sensitive;
|
||||
file.maybePorn = info.porn;
|
||||
file.isSensitive = user
|
||||
? this.userEntityService.isLocalUser(user) && profile!.alwaysMarkNsfw ? true :
|
||||
(sensitive !== null && sensitive !== undefined)
|
||||
? sensitive
|
||||
: false
|
||||
: false;
|
||||
|
||||
if (info.sensitive && profile!.autoSensitive) file.isSensitive = true;
|
||||
if (info.sensitive && instance.setSensitiveFlagAutomatically) file.isSensitive = true;
|
||||
|
||||
if (url !== null) {
|
||||
file.src = url;
|
||||
|
||||
if (isLink) {
|
||||
file.url = url;
|
||||
// ローカルプロキシ用
|
||||
file.accessKey = uuid();
|
||||
file.thumbnailAccessKey = 'thumbnail-' + uuid();
|
||||
file.webpublicAccessKey = 'webpublic-' + uuid();
|
||||
}
|
||||
}
|
||||
|
||||
if (uri !== null) {
|
||||
file.uri = uri;
|
||||
}
|
||||
|
||||
if (isLink) {
|
||||
try {
|
||||
file.size = 0;
|
||||
file.md5 = info.md5;
|
||||
file.name = detectedName;
|
||||
file.type = info.type.mime;
|
||||
file.storedInternal = false;
|
||||
|
||||
file = await this.driveFilesRepository.insert(file).then(x => this.driveFilesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
} catch (err) {
|
||||
// duplicate key error (when already registered)
|
||||
if (isDuplicateKeyValueError(err)) {
|
||||
this.#registerLogger.info(`already registered ${file.uri}`);
|
||||
|
||||
file = await this.driveFilesRepository.findOneBy({
|
||||
uri: file.uri!,
|
||||
userId: user ? user.id : IsNull(),
|
||||
}) as DriveFile;
|
||||
} else {
|
||||
this.#registerLogger.error(err as Error);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
file = await (this.#save(file, path, detectedName, info.type.mime, info.md5, info.size));
|
||||
}
|
||||
|
||||
this.#registerLogger.succ(`drive file has been created ${file.id}`);
|
||||
|
||||
if (user) {
|
||||
this.driveFileEntityService.pack(file, { self: true }).then(packedFile => {
|
||||
// Publish driveFileCreated event
|
||||
this.globalEventService.publishMainStream(user.id, 'driveFileCreated', packedFile);
|
||||
this.globalEventService.publishDriveStream(user.id, 'fileCreated', packedFile);
|
||||
});
|
||||
}
|
||||
|
||||
// 統計を更新
|
||||
this.driveChart.update(file, true);
|
||||
this.perUserDriveChart.update(file, true);
|
||||
if (file.userHost !== null) {
|
||||
this.instanceChart.updateDrive(file, true);
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
public async deleteFile(file: DriveFile, isExpired = false) {
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.queueService.createDeleteObjectStorageFileJob(file.webpublicAccessKey!);
|
||||
}
|
||||
}
|
||||
|
||||
this.#deletePostProcess(file, isExpired);
|
||||
}
|
||||
|
||||
public async deleteFileSync(file: DriveFile, isExpired = false) {
|
||||
if (file.storedInternal) {
|
||||
this.internalStorageService.del(file.accessKey!);
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
this.internalStorageService.del(file.thumbnailAccessKey!);
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
this.internalStorageService.del(file.webpublicAccessKey!);
|
||||
}
|
||||
} else if (!file.isLink) {
|
||||
const promises = [];
|
||||
|
||||
promises.push(this.deleteObjectStorageFile(file.accessKey!));
|
||||
|
||||
if (file.thumbnailUrl) {
|
||||
promises.push(this.deleteObjectStorageFile(file.thumbnailAccessKey!));
|
||||
}
|
||||
|
||||
if (file.webpublicUrl) {
|
||||
promises.push(this.deleteObjectStorageFile(file.webpublicAccessKey!));
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
this.#deletePostProcess(file, isExpired);
|
||||
}
|
||||
|
||||
async #deletePostProcess(file: DriveFile, isExpired = false) {
|
||||
// リモートファイル期限切れ削除後は直リンクにする
|
||||
if (isExpired && file.userHost !== null && file.uri != null) {
|
||||
this.driveFilesRepository.update(file.id, {
|
||||
isLink: true,
|
||||
url: file.uri,
|
||||
thumbnailUrl: null,
|
||||
webpublicUrl: null,
|
||||
storedInternal: false,
|
||||
// ローカルプロキシ用
|
||||
accessKey: uuid(),
|
||||
thumbnailAccessKey: 'thumbnail-' + uuid(),
|
||||
webpublicAccessKey: 'webpublic-' + uuid(),
|
||||
});
|
||||
} else {
|
||||
this.driveFilesRepository.delete(file.id);
|
||||
}
|
||||
|
||||
// 統計を更新
|
||||
this.driveChart.update(file, false);
|
||||
this.perUserDriveChart.update(file, false);
|
||||
if (file.userHost !== null) {
|
||||
this.instanceChart.updateDrive(file, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteObjectStorageFile(key: string) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const s3 = this.s3Service.getS3(meta);
|
||||
|
||||
await s3.deleteObject({
|
||||
Bucket: meta.objectStorageBucket!,
|
||||
Key: key,
|
||||
}).promise();
|
||||
}
|
||||
|
||||
public async uploadFromUrl({
|
||||
url,
|
||||
user,
|
||||
folderId = null,
|
||||
uri = null,
|
||||
sensitive = false,
|
||||
force = false,
|
||||
isLink = false,
|
||||
comment = null,
|
||||
requestIp = null,
|
||||
requestHeaders = null,
|
||||
}: UploadFromUrlArgs): Promise<DriveFile> {
|
||||
let name = new URL(url).pathname.split('/').pop() ?? null;
|
||||
if (name == null || !this.driveFileEntityService.validateFileName(name)) {
|
||||
name = null;
|
||||
}
|
||||
|
||||
// If the comment is same as the name, skip comment
|
||||
// (image.name is passed in when receiving attachment)
|
||||
if (comment !== null && name === comment) {
|
||||
comment = null;
|
||||
}
|
||||
|
||||
// Create temp file
|
||||
const [path, cleanup] = await createTemp();
|
||||
|
||||
try {
|
||||
// write content at URL to temp file
|
||||
await this.downloadService.downloadUrl(url, path);
|
||||
|
||||
const driveFile = await this.addFile({ user, path, name, comment, folderId, force, isLink, url, uri, sensitive, requestIp, requestHeaders });
|
||||
this.#downloaderLogger.succ(`Got: ${driveFile.id}`);
|
||||
return driveFile!;
|
||||
} catch (err) {
|
||||
this.#downloaderLogger.error(`Failed to create drive file: ${err}`, {
|
||||
url: url,
|
||||
e: err,
|
||||
});
|
||||
throw err;
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
175
packages/backend/src/core/EmailService.ts
Normal file
175
packages/backend/src/core/EmailService.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import * as nodemailer from 'nodemailer';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { validate as validateEmail } from 'deep-email-validator';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { UserProfilesRepository } from '@/models/index.js';
|
||||
|
||||
@Injectable()
|
||||
export class EmailService {
|
||||
#logger: Logger;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
this.#logger = new Logger('email');
|
||||
}
|
||||
|
||||
public async sendEmail(to: string, subject: string, html: string, text: string) {
|
||||
const meta = await this.metaService.fetch(true);
|
||||
|
||||
const iconUrl = `${this.config.url}/static-assets/mi-white.png`;
|
||||
const emailSettingUrl = `${this.config.url}/settings/email`;
|
||||
|
||||
const enableAuth = meta.smtpUser != null && meta.smtpUser !== '';
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: meta.smtpHost,
|
||||
port: meta.smtpPort,
|
||||
secure: meta.smtpSecure,
|
||||
ignoreTLS: !enableAuth,
|
||||
proxy: this.config.proxySmtp,
|
||||
auth: enableAuth ? {
|
||||
user: meta.smtpUser,
|
||||
pass: meta.smtpPass,
|
||||
} : undefined,
|
||||
} as any);
|
||||
|
||||
try {
|
||||
// TODO: htmlサニタイズ
|
||||
const info = await transporter.sendMail({
|
||||
from: meta.email!,
|
||||
to: to,
|
||||
subject: subject,
|
||||
text: text,
|
||||
html: `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${ subject }</title>
|
||||
<style>
|
||||
html {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 16px;
|
||||
margin: 0;
|
||||
font-family: sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #86b300;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
main {
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
color: #555;
|
||||
}
|
||||
main > header {
|
||||
padding: 32px;
|
||||
background: #86b300;
|
||||
}
|
||||
main > header > img {
|
||||
max-width: 128px;
|
||||
max-height: 28px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
main > article {
|
||||
padding: 32px;
|
||||
}
|
||||
main > article > h1 {
|
||||
margin: 0 0 1em 0;
|
||||
}
|
||||
main > footer {
|
||||
padding: 32px;
|
||||
border-top: solid 1px #eee;
|
||||
}
|
||||
|
||||
nav {
|
||||
box-sizing: border-box;
|
||||
max-width: 500px;
|
||||
margin: 16px auto 0 auto;
|
||||
padding: 0 32px;
|
||||
}
|
||||
nav > a {
|
||||
color: #888;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<img src="${ meta.logoImageUrl ?? meta.iconUrl ?? iconUrl }"/>
|
||||
</header>
|
||||
<article>
|
||||
<h1>${ subject }</h1>
|
||||
<div>${ html }</div>
|
||||
</article>
|
||||
<footer>
|
||||
<a href="${ emailSettingUrl }">${ 'Email setting' }</a>
|
||||
</footer>
|
||||
</main>
|
||||
<nav>
|
||||
<a href="${ this.config.url }">${ this.config.host }</a>
|
||||
</nav>
|
||||
</body>
|
||||
</html>`,
|
||||
});
|
||||
|
||||
this.#logger.info(`Message sent: ${info.messageId}`);
|
||||
} catch (err) {
|
||||
this.#logger.error(err as Error);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async validateEmailForAccount(emailAddress: string): Promise<{
|
||||
available: boolean;
|
||||
reason: null | 'used' | 'format' | 'disposable' | 'mx' | 'smtp';
|
||||
}> {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const exist = await this.userProfilesRepository.countBy({
|
||||
emailVerified: true,
|
||||
email: emailAddress,
|
||||
});
|
||||
|
||||
const validated = meta.enableActiveEmailValidation ? await validateEmail({
|
||||
email: emailAddress,
|
||||
validateRegex: true,
|
||||
validateMx: true,
|
||||
validateTypo: false, // TLDを見ているみたいだけどclubとか弾かれるので
|
||||
validateDisposable: true, // 捨てアドかどうかチェック
|
||||
validateSMTP: false, // 日本だと25ポートが殆どのプロバイダーで塞がれていてタイムアウトになるので
|
||||
}) : { valid: true };
|
||||
|
||||
const available = exist === 0 && validated.valid;
|
||||
|
||||
return {
|
||||
available,
|
||||
reason: available ? null :
|
||||
exist !== 0 ? 'used' :
|
||||
validated.reason === 'regex' ? 'format' :
|
||||
validated.reason === 'disposable' ? 'disposable' :
|
||||
validated.reason === 'mx' ? 'mx' :
|
||||
validated.reason === 'smtp' ? 'smtp' :
|
||||
null,
|
||||
};
|
||||
}
|
||||
}
|
46
packages/backend/src/core/FederatedInstanceService.ts
Normal file
46
packages/backend/src/core/FederatedInstanceService.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { InstancesRepository } from '@/models/index.js';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class FederatedInstanceService {
|
||||
#cache: Cache<Instance>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
this.#cache = new Cache<Instance>(1000 * 60 * 60);
|
||||
}
|
||||
|
||||
public async registerOrFetchInstanceDoc(host: string): Promise<Instance> {
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
const cached = this.#cache.get(host);
|
||||
if (cached) return cached;
|
||||
|
||||
const index = await this.instancesRepository.findOneBy({ host });
|
||||
|
||||
if (index == null) {
|
||||
const i = await this.instancesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
host,
|
||||
caughtAt: new Date(),
|
||||
lastCommunicatedAt: new Date(),
|
||||
}).then(x => this.instancesRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
this.#cache.set(host, i);
|
||||
return i;
|
||||
} else {
|
||||
this.#cache.set(host, index);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
}
|
283
packages/backend/src/core/FetchInstanceMetadataService.ts
Normal file
283
packages/backend/src/core/FetchInstanceMetadataService.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import fetch from 'node-fetch';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import type { Instance } from '@/models/entities/Instance.js';
|
||||
import { InstancesRepository } from '@/models/index.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import Logger from '@/logger.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
import type { DOMWindow } from 'jsdom';
|
||||
|
||||
const logger = new Logger('metadata', 'cyan');
|
||||
|
||||
type NodeInfo = {
|
||||
openRegistrations?: any;
|
||||
software?: {
|
||||
name?: any;
|
||||
version?: any;
|
||||
};
|
||||
metadata?: {
|
||||
name?: any;
|
||||
nodeName?: any;
|
||||
nodeDescription?: any;
|
||||
description?: any;
|
||||
maintainer?: {
|
||||
name?: any;
|
||||
email?: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class FetchInstanceMetadataService {
|
||||
constructor(
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async fetchInstanceMetadata(instance: Instance, force = false): Promise<void> {
|
||||
const unlock = await this.appLockService.getFetchInstanceMetadataLock(instance.host);
|
||||
|
||||
if (!force) {
|
||||
const _instance = await this.instancesRepository.findOneBy({ host: instance.host });
|
||||
const now = Date.now();
|
||||
if (_instance && _instance.infoUpdatedAt && (now - _instance.infoUpdatedAt.getTime() < 1000 * 60 * 60 * 24)) {
|
||||
unlock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Fetching metadata of ${instance.host} ...`);
|
||||
|
||||
try {
|
||||
const [info, dom, manifest] = await Promise.all([
|
||||
this.#fetchNodeinfo(instance).catch(() => null),
|
||||
this.#fetchDom(instance).catch(() => null),
|
||||
this.#fetchManifest(instance).catch(() => null),
|
||||
]);
|
||||
|
||||
const [favicon, icon, themeColor, name, description] = await Promise.all([
|
||||
this.#fetchFaviconUrl(instance, dom).catch(() => null),
|
||||
this.#fetchIconUrl(instance, dom, manifest).catch(() => null),
|
||||
this.#getThemeColor(info, dom, manifest).catch(() => null),
|
||||
this.#getSiteName(info, dom, manifest).catch(() => null),
|
||||
this.#getDescription(info, dom, manifest).catch(() => null),
|
||||
]);
|
||||
|
||||
logger.succ(`Successfuly fetched metadata of ${instance.host}`);
|
||||
|
||||
const updates = {
|
||||
infoUpdatedAt: new Date(),
|
||||
} as Record<string, any>;
|
||||
|
||||
if (info) {
|
||||
updates.softwareName = info.software?.name.toLowerCase();
|
||||
updates.softwareVersion = info.software?.version;
|
||||
updates.openRegistrations = info.openRegistrations;
|
||||
updates.maintainerName = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.name ?? null) : null : null;
|
||||
updates.maintainerEmail = info.metadata ? info.metadata.maintainer ? (info.metadata.maintainer.email ?? null) : null : null;
|
||||
}
|
||||
|
||||
if (name) updates.name = name;
|
||||
if (description) updates.description = description;
|
||||
if (icon || favicon) updates.iconUrl = icon ?? favicon;
|
||||
if (favicon) updates.faviconUrl = favicon;
|
||||
if (themeColor) updates.themeColor = themeColor;
|
||||
|
||||
await this.instancesRepository.update(instance.id, updates);
|
||||
|
||||
logger.succ(`Successfuly updated metadata of ${instance.host}`);
|
||||
} catch (e) {
|
||||
logger.error(`Failed to update metadata of ${instance.host}: ${e}`);
|
||||
} finally {
|
||||
unlock();
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchNodeinfo(instance: Instance): Promise<NodeInfo> {
|
||||
logger.info(`Fetching nodeinfo of ${instance.host} ...`);
|
||||
|
||||
try {
|
||||
const wellknown = await this.httpRequestService.getJson('https://' + instance.host + '/.well-known/nodeinfo')
|
||||
.catch(err => {
|
||||
if (err.statusCode === 404) {
|
||||
throw 'No nodeinfo provided';
|
||||
} else {
|
||||
throw err.statusCode ?? err.message;
|
||||
}
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
if (wellknown.links == null || !Array.isArray(wellknown.links)) {
|
||||
throw 'No wellknown links';
|
||||
}
|
||||
|
||||
const links = wellknown.links as any[];
|
||||
|
||||
const lnik1_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/1.0');
|
||||
const lnik2_0 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.0');
|
||||
const lnik2_1 = links.find(link => link.rel === 'http://nodeinfo.diaspora.software/ns/schema/2.1');
|
||||
const link = lnik2_1 ?? lnik2_0 ?? lnik1_0;
|
||||
|
||||
if (link == null) {
|
||||
throw 'No nodeinfo link provided';
|
||||
}
|
||||
|
||||
const info = await this.httpRequestService.getJson(link.href)
|
||||
.catch(err => {
|
||||
throw err.statusCode ?? err.message;
|
||||
});
|
||||
|
||||
logger.succ(`Successfuly fetched nodeinfo of ${instance.host}`);
|
||||
|
||||
return info as NodeInfo;
|
||||
} catch (err) {
|
||||
logger.error(`Failed to fetch nodeinfo of ${instance.host}: ${err}`);
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async #fetchDom(instance: Instance): Promise<DOMWindow['document']> {
|
||||
logger.info(`Fetching HTML of ${instance.host} ...`);
|
||||
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
const html = await this.httpRequestService.getHtml(url);
|
||||
|
||||
const { window } = new JSDOM(html);
|
||||
const doc = window.document;
|
||||
|
||||
return doc;
|
||||
}
|
||||
|
||||
async #fetchManifest(instance: Instance): Promise<Record<string, unknown> | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
const manifestUrl = url + '/manifest.json';
|
||||
|
||||
const manifest = await this.httpRequestService.getJson(manifestUrl) as Record<string, unknown>;
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async #fetchFaviconUrl(instance: Instance, doc: DOMWindow['document'] | null): Promise<string | null> {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
if (doc) {
|
||||
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
|
||||
const href = Array.from(doc.getElementsByTagName('link')).reverse().find(link => link.relList.contains('icon'))?.href;
|
||||
|
||||
if (href) {
|
||||
return (new URL(href, url)).href;
|
||||
}
|
||||
}
|
||||
|
||||
const faviconUrl = url + '/favicon.ico';
|
||||
|
||||
const favicon = await fetch(faviconUrl, {
|
||||
// TODO
|
||||
//timeout: 10000,
|
||||
agent: url => this.httpRequestService.getAgentByUrl(url),
|
||||
});
|
||||
|
||||
if (favicon.ok) {
|
||||
return faviconUrl;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async #fetchIconUrl(instance: Instance, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (manifest && manifest.icons && manifest.icons.length > 0 && manifest.icons[0].src) {
|
||||
const url = 'https://' + instance.host;
|
||||
return (new URL(manifest.icons[0].src, url)).href;
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
const url = 'https://' + instance.host;
|
||||
|
||||
// https://github.com/misskey-dev/misskey/pull/8220#issuecomment-1025104043
|
||||
const links = Array.from(doc.getElementsByTagName('link')).reverse();
|
||||
// https://github.com/misskey-dev/misskey/pull/8220/files/0ec4eba22a914e31b86874f12448f88b3e58dd5a#r796487559
|
||||
const href =
|
||||
[
|
||||
links.find(link => link.relList.contains('apple-touch-icon-precomposed'))?.href,
|
||||
links.find(link => link.relList.contains('apple-touch-icon'))?.href,
|
||||
links.find(link => link.relList.contains('icon'))?.href,
|
||||
]
|
||||
.find(href => href);
|
||||
|
||||
if (href) {
|
||||
return (new URL(href, url)).href;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async #getThemeColor(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
const themeColor = info?.metadata?.themeColor ?? doc?.querySelector('meta[name="theme-color"]')?.getAttribute('content') ?? manifest?.theme_color;
|
||||
|
||||
if (themeColor) {
|
||||
const color = new tinycolor(themeColor);
|
||||
if (color.isValid()) return color.toHexString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async #getSiteName(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (info.metadata.nodeName || info.metadata.name) {
|
||||
return info.metadata.nodeName ?? info.metadata.name;
|
||||
}
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
const og = doc.querySelector('meta[property="og:title"]')?.getAttribute('content');
|
||||
|
||||
if (og) {
|
||||
return og;
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
return manifest.name ?? manifest.short_name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async #getDescription(info: NodeInfo | null, doc: DOMWindow['document'] | null, manifest: Record<string, any> | null): Promise<string | null> {
|
||||
if (info && info.metadata) {
|
||||
if (info.metadata.nodeDescription || info.metadata.description) {
|
||||
return info.metadata.nodeDescription ?? info.metadata.description;
|
||||
}
|
||||
}
|
||||
|
||||
if (doc) {
|
||||
const meta = doc.querySelector('meta[name="description"]')?.getAttribute('content');
|
||||
if (meta) {
|
||||
return meta;
|
||||
}
|
||||
|
||||
const og = doc.querySelector('meta[property="og:description"]')?.getAttribute('content');
|
||||
if (og) {
|
||||
return og;
|
||||
}
|
||||
}
|
||||
|
||||
if (manifest) {
|
||||
return manifest.name ?? manifest.short_name;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
382
packages/backend/src/core/FileInfoService.ts
Normal file
382
packages/backend/src/core/FileInfoService.ts
Normal file
@ -0,0 +1,382 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as crypto from 'node:crypto';
|
||||
import { join } from 'node:path';
|
||||
import * as stream from 'node:stream';
|
||||
import * as util from 'node:util';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { FSWatcher } from 'chokidar';
|
||||
import { fileTypeFromFile } from 'file-type';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import isSvg from 'is-svg';
|
||||
import probeImageSize from 'probe-image-size';
|
||||
import { type predictionType } from 'nsfwjs';
|
||||
import sharp from 'sharp';
|
||||
import { encode } from 'blurhash';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
import { AiService } from '@/core/AiService.js';
|
||||
|
||||
const pipeline = util.promisify(stream.pipeline);
|
||||
|
||||
export type FileInfo = {
|
||||
size: number;
|
||||
md5: string;
|
||||
type: {
|
||||
mime: string;
|
||||
ext: string | null;
|
||||
};
|
||||
width?: number;
|
||||
height?: number;
|
||||
orientation?: number;
|
||||
blurhash?: string;
|
||||
sensitive: boolean;
|
||||
porn: boolean;
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const TYPE_OCTET_STREAM = {
|
||||
mime: 'application/octet-stream',
|
||||
ext: null,
|
||||
};
|
||||
|
||||
const TYPE_SVG = {
|
||||
mime: 'image/svg+xml',
|
||||
ext: 'svg',
|
||||
};
|
||||
@Injectable()
|
||||
export class FileInfoService {
|
||||
constructor(
|
||||
private aiService: AiService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file information
|
||||
*/
|
||||
public async getFileInfo(path: string, opts: {
|
||||
skipSensitiveDetection: boolean;
|
||||
sensitiveThreshold?: number;
|
||||
sensitiveThresholdForPorn?: number;
|
||||
enableSensitiveMediaDetectionForVideos?: boolean;
|
||||
}): Promise<FileInfo> {
|
||||
const warnings = [] as string[];
|
||||
|
||||
const size = await this.getFileSize(path);
|
||||
const md5 = await this.#calcHash(path);
|
||||
|
||||
let type = await this.detectType(path);
|
||||
|
||||
// image dimensions
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
let orientation: number | undefined;
|
||||
|
||||
if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/bmp', 'image/tiff', 'image/svg+xml', 'image/vnd.adobe.photoshop'].includes(type.mime)) {
|
||||
const imageSize = await this.#detectImageSize(path).catch(e => {
|
||||
warnings.push(`detectImageSize failed: ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// うまく判定できない画像は octet-stream にする
|
||||
if (!imageSize) {
|
||||
warnings.push('cannot detect image dimensions');
|
||||
type = TYPE_OCTET_STREAM;
|
||||
} else if (imageSize.wUnits === 'px') {
|
||||
width = imageSize.width;
|
||||
height = imageSize.height;
|
||||
orientation = imageSize.orientation;
|
||||
|
||||
// 制限を超えている画像は octet-stream にする
|
||||
if (imageSize.width > 16383 || imageSize.height > 16383) {
|
||||
warnings.push('image dimensions exceeds limits');
|
||||
type = TYPE_OCTET_STREAM;
|
||||
}
|
||||
} else {
|
||||
warnings.push(`unsupported unit type: ${imageSize.wUnits}`);
|
||||
}
|
||||
}
|
||||
|
||||
let blurhash: string | undefined;
|
||||
|
||||
if (['image/jpeg', 'image/gif', 'image/png', 'image/apng', 'image/webp', 'image/svg+xml'].includes(type.mime)) {
|
||||
blurhash = await this.#getBlurhash(path).catch(e => {
|
||||
warnings.push(`getBlurhash failed: ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
if (!opts.skipSensitiveDetection) {
|
||||
await this.#detectSensitivity(
|
||||
path,
|
||||
type.mime,
|
||||
opts.sensitiveThreshold ?? 0.5,
|
||||
opts.sensitiveThresholdForPorn ?? 0.75,
|
||||
opts.enableSensitiveMediaDetectionForVideos ?? false,
|
||||
).then(value => {
|
||||
[sensitive, porn] = value;
|
||||
}, error => {
|
||||
warnings.push(`detectSensitivity failed: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
size,
|
||||
md5,
|
||||
type,
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
blurhash,
|
||||
sensitive,
|
||||
porn,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async #detectSensitivity(source: string, mime: string, sensitiveThreshold: number, sensitiveThresholdForPorn: number, analyzeVideo: boolean): Promise<[sensitive: boolean, porn: boolean]> {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
function judgePrediction(result: readonly predictionType[]): [sensitive: boolean, porn: boolean] {
|
||||
let sensitive = false;
|
||||
let porn = false;
|
||||
|
||||
if ((result.find(x => x.className === 'Sexy')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
|
||||
if ((result.find(x => x.className === 'Hentai')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
|
||||
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThreshold) sensitive = true;
|
||||
|
||||
if ((result.find(x => x.className === 'Porn')?.probability ?? 0) > sensitiveThresholdForPorn) porn = true;
|
||||
|
||||
return [sensitive, porn];
|
||||
}
|
||||
|
||||
if (['image/jpeg', 'image/png', 'image/webp'].includes(mime)) {
|
||||
const result = await this.aiService.detectSensitive(source);
|
||||
if (result) {
|
||||
[sensitive, porn] = judgePrediction(result);
|
||||
}
|
||||
} else if (analyzeVideo && (mime === 'image/apng' || mime.startsWith('video/'))) {
|
||||
const [outDir, disposeOutDir] = await createTempDir();
|
||||
try {
|
||||
const command = FFmpeg()
|
||||
.input(source)
|
||||
.inputOptions([
|
||||
'-skip_frame', 'nokey', // 可能ならキーフレームのみを取得してほしいとする(そうなるとは限らない)
|
||||
'-lowres', '3', // 元の画質でデコードする必要はないので 1/8 画質でデコードしてもよいとする(そうなるとは限らない)
|
||||
])
|
||||
.noAudio()
|
||||
.videoFilters([
|
||||
{
|
||||
filter: 'select', // フレームのフィルタリング
|
||||
options: {
|
||||
e: 'eq(pict_type,PICT_TYPE_I)', // I-Frame のみをフィルタする(VP9 とかはデコードしてみないとわからないっぽい)
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: 'blackframe', // 暗いフレームの検出
|
||||
options: {
|
||||
amount: '0', // 暗さに関わらず全てのフレームで測定値を取る
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: 'metadata',
|
||||
options: {
|
||||
mode: 'select', // フレーム選択モード
|
||||
key: 'lavfi.blackframe.pblack', // フレームにおける暗部の百分率(前のフィルタからのメタデータを参照する)
|
||||
value: '50',
|
||||
function: 'less', // 50% 未満のフレームを選択する(50% 以上暗部があるフレームだと誤検知を招くかもしれないので)
|
||||
},
|
||||
},
|
||||
{
|
||||
filter: 'scale',
|
||||
options: {
|
||||
w: 299,
|
||||
h: 299,
|
||||
},
|
||||
},
|
||||
])
|
||||
.format('image2')
|
||||
.output(join(outDir, '%d.png'))
|
||||
.outputOptions(['-vsync', '0']); // 可変フレームレートにすることで穴埋めをさせない
|
||||
const results: ReturnType<typeof judgePrediction>[] = [];
|
||||
let frameIndex = 0;
|
||||
let targetIndex = 0;
|
||||
let nextIndex = 1;
|
||||
for await (const path of this.#asyncIterateFrames(outDir, command)) {
|
||||
try {
|
||||
const index = frameIndex++;
|
||||
if (index !== targetIndex) {
|
||||
continue;
|
||||
}
|
||||
targetIndex = nextIndex;
|
||||
nextIndex += index; // fibonacci sequence によってフレーム数制限を掛ける
|
||||
const result = await this.aiService.detectSensitive(path);
|
||||
if (result) {
|
||||
results.push(judgePrediction(result));
|
||||
}
|
||||
} finally {
|
||||
fs.promises.unlink(path);
|
||||
}
|
||||
}
|
||||
sensitive = results.filter(x => x[0]).length >= Math.ceil(results.length * sensitiveThreshold);
|
||||
porn = results.filter(x => x[1]).length >= Math.ceil(results.length * sensitiveThresholdForPorn);
|
||||
} finally {
|
||||
disposeOutDir();
|
||||
}
|
||||
}
|
||||
|
||||
return [sensitive, porn];
|
||||
}
|
||||
|
||||
async *#asyncIterateFrames(cwd: string, command: FFmpeg.FfmpegCommand): AsyncGenerator<string, void> {
|
||||
const watcher = new FSWatcher({
|
||||
cwd,
|
||||
disableGlobbing: true,
|
||||
});
|
||||
let finished = false;
|
||||
command.once('end', () => {
|
||||
finished = true;
|
||||
watcher.close();
|
||||
});
|
||||
command.run();
|
||||
for (let i = 1; true; i++) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
const current = `${i}.png`;
|
||||
const next = `${i + 1}.png`;
|
||||
const framePath = join(cwd, current);
|
||||
if (await this.#exists(join(cwd, next))) {
|
||||
yield framePath;
|
||||
} else if (!finished) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
|
||||
watcher.add(next);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
watcher.on('add', function onAdd(path) {
|
||||
if (path === next) { // 次フレームの書き出しが始まっているなら、現在フレームの書き出しは終わっている
|
||||
watcher.unwatch(current);
|
||||
watcher.off('add', onAdd);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
command.once('end', resolve); // 全てのフレームを処理し終わったなら、最終フレームである現在フレームの書き出しは終わっている
|
||||
command.once('error', reject);
|
||||
});
|
||||
yield framePath;
|
||||
} else if (await this.#exists(framePath)) {
|
||||
yield framePath;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#exists(path: string): Promise<boolean> {
|
||||
return fs.promises.access(path).then(() => true, () => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect MIME Type and extension
|
||||
*/
|
||||
public async detectType(path: string): Promise<{
|
||||
mime: string;
|
||||
ext: string | null;
|
||||
}> {
|
||||
// Check 0 byte
|
||||
const fileSize = await this.getFileSize(path);
|
||||
if (fileSize === 0) {
|
||||
return TYPE_OCTET_STREAM;
|
||||
}
|
||||
|
||||
const type = await fileTypeFromFile(path);
|
||||
|
||||
if (type) {
|
||||
// XMLはSVGかもしれない
|
||||
if (type.mime === 'application/xml' && await this.checkSvg(path)) {
|
||||
return TYPE_SVG;
|
||||
}
|
||||
|
||||
return {
|
||||
mime: type.mime,
|
||||
ext: type.ext,
|
||||
};
|
||||
}
|
||||
|
||||
// 種類が不明でもSVGかもしれない
|
||||
if (await this.checkSvg(path)) {
|
||||
return TYPE_SVG;
|
||||
}
|
||||
|
||||
// それでも種類が不明なら application/octet-stream にする
|
||||
return TYPE_OCTET_STREAM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the file is SVG or not
|
||||
*/
|
||||
public async checkSvg(path: string) {
|
||||
try {
|
||||
const size = await this.getFileSize(path);
|
||||
if (size > 1 * 1024 * 1024) return false;
|
||||
return isSvg(fs.readFileSync(path));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size
|
||||
*/
|
||||
public async getFileSize(path: string): Promise<number> {
|
||||
const getStat = util.promisify(fs.stat);
|
||||
return (await getStat(path)).size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate MD5 hash
|
||||
*/
|
||||
async #calcHash(path: string): Promise<string> {
|
||||
const hash = crypto.createHash('md5').setEncoding('hex');
|
||||
await pipeline(fs.createReadStream(path), hash);
|
||||
return hash.read();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect dimensions of image
|
||||
*/
|
||||
async #detectImageSize(path: string): Promise<{
|
||||
width: number;
|
||||
height: number;
|
||||
wUnits: string;
|
||||
hUnits: string;
|
||||
orientation?: number;
|
||||
}> {
|
||||
const readable = fs.createReadStream(path);
|
||||
const imageSize = await probeImageSize(readable);
|
||||
readable.destroy();
|
||||
return imageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate average color of image
|
||||
*/
|
||||
#getBlurhash(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
sharp(path)
|
||||
.raw()
|
||||
.ensureAlpha()
|
||||
.resize(64, 64, { fit: 'inside' })
|
||||
.toBuffer((err, buffer, { width, height }) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let hash;
|
||||
|
||||
try {
|
||||
hash = encode(new Uint8ClampedArray(buffer), width, height, 7, 7);
|
||||
} catch (e) {
|
||||
return reject(e);
|
||||
}
|
||||
|
||||
resolve(hash);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
109
packages/backend/src/core/GlobalEventService.ts
Normal file
109
packages/backend/src/core/GlobalEventService.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { UserList } from '@/models/entities/UserList.js';
|
||||
import type { UserGroup } from '@/models/entities/UserGroup.js';
|
||||
import type { Antenna } from '@/models/entities/Antenna.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import type {
|
||||
StreamChannels,
|
||||
AdminStreamTypes,
|
||||
AntennaStreamTypes,
|
||||
BroadcastTypes,
|
||||
ChannelStreamTypes,
|
||||
DriveStreamTypes,
|
||||
GroupMessagingStreamTypes,
|
||||
InternalStreamTypes,
|
||||
MainStreamTypes,
|
||||
MessagingIndexStreamTypes,
|
||||
MessagingStreamTypes,
|
||||
NoteStreamTypes,
|
||||
UserListStreamTypes,
|
||||
UserStreamTypes,
|
||||
} from '@/server/api/stream/types.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class GlobalEventService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
) {
|
||||
}
|
||||
|
||||
private publish(channel: StreamChannels, type: string | null, value?: any): void {
|
||||
const message = type == null ? value : value == null ?
|
||||
{ type: type, body: null } :
|
||||
{ type: type, body: value };
|
||||
|
||||
this.redisClient.publish(this.config.host, JSON.stringify({
|
||||
channel: channel,
|
||||
message: message,
|
||||
}));
|
||||
}
|
||||
|
||||
public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void {
|
||||
this.publish('internal', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishUserEvent<K extends keyof UserStreamTypes>(userId: User['id'], type: K, value?: UserStreamTypes[K]): void {
|
||||
this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishBroadcastStream<K extends keyof BroadcastTypes>(type: K, value?: BroadcastTypes[K]): void {
|
||||
this.publish('broadcast', type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishMainStream<K extends keyof MainStreamTypes>(userId: User['id'], type: K, value?: MainStreamTypes[K]): void {
|
||||
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishDriveStream<K extends keyof DriveStreamTypes>(userId: User['id'], type: K, value?: DriveStreamTypes[K]): void {
|
||||
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void {
|
||||
this.publish(`noteStream:${noteId}`, type, {
|
||||
id: noteId,
|
||||
body: value,
|
||||
});
|
||||
}
|
||||
|
||||
public publishChannelStream<K extends keyof ChannelStreamTypes>(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void {
|
||||
this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishUserListStream<K extends keyof UserListStreamTypes>(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void {
|
||||
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void {
|
||||
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishMessagingStream<K extends keyof MessagingStreamTypes>(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void {
|
||||
this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishGroupMessagingStream<K extends keyof GroupMessagingStreamTypes>(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void {
|
||||
this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishMessagingIndexStream<K extends keyof MessagingIndexStreamTypes>(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void {
|
||||
this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
|
||||
public publishNotesStream(note: Packed<'Note'>): void {
|
||||
this.publish('notesStream', null, note);
|
||||
}
|
||||
|
||||
public publishAdminStream<K extends keyof AdminStreamTypes>(userId: User['id'], type: K, value?: AdminStreamTypes[K]): void {
|
||||
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
|
||||
}
|
||||
}
|
147
packages/backend/src/core/HashtagService.ts
Normal file
147
packages/backend/src/core/HashtagService.ts
Normal file
@ -0,0 +1,147 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { Hashtag } from '@/models/entities/Hashtag.js';
|
||||
import HashtagChart from '@/core/chart/charts/hashtag.js';
|
||||
import { HashtagsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.hashtagsRepository)
|
||||
private hashtagsRepository: HashtagsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private hashtagChart: HashtagChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async updateHashtags(user: { id: User['id']; host: User['host']; }, tags: string[]) {
|
||||
for (const tag of tags) {
|
||||
await this.updateHashtag(user, tag);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateUsertags(user: User, tags: string[]) {
|
||||
for (const tag of tags) {
|
||||
await this.updateHashtag(user, tag, true, true);
|
||||
}
|
||||
|
||||
for (const tag of (user.tags ?? []).filter(x => !tags.includes(x))) {
|
||||
await this.updateHashtag(user, tag, true, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async updateHashtag(user: { id: User['id']; host: User['host']; }, tag: string, isUserAttached = false, inc = true) {
|
||||
tag = normalizeForSearch(tag);
|
||||
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (index == null && !inc) return;
|
||||
|
||||
if (index != null) {
|
||||
const q = this.hashtagsRepository.createQueryBuilder('tag').update()
|
||||
.where('name = :name', { name: tag });
|
||||
|
||||
const set = {} as any;
|
||||
|
||||
if (isUserAttached) {
|
||||
if (inc) {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.attachedUserIds.some(id => id === user.id)) {
|
||||
set.attachedUserIds = () => `array_append("attachedUserIds", '${user.id}')`;
|
||||
set.attachedUsersCount = () => '"attachedUsersCount" + 1';
|
||||
}
|
||||
// 自分が(ローカル内で)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isLocalUser(user) && !index.attachedLocalUserIds.some(id => id === user.id)) {
|
||||
set.attachedLocalUserIds = () => `array_append("attachedLocalUserIds", '${user.id}')`;
|
||||
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" + 1';
|
||||
}
|
||||
// 自分が(リモートで)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isRemoteUser(user) && !index.attachedRemoteUserIds.some(id => id === user.id)) {
|
||||
set.attachedRemoteUserIds = () => `array_append("attachedRemoteUserIds", '${user.id}')`;
|
||||
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" + 1';
|
||||
}
|
||||
} else {
|
||||
set.attachedUserIds = () => `array_remove("attachedUserIds", '${user.id}')`;
|
||||
set.attachedUsersCount = () => '"attachedUsersCount" - 1';
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
set.attachedLocalUserIds = () => `array_remove("attachedLocalUserIds", '${user.id}')`;
|
||||
set.attachedLocalUsersCount = () => '"attachedLocalUsersCount" - 1';
|
||||
} else {
|
||||
set.attachedRemoteUserIds = () => `array_remove("attachedRemoteUserIds", '${user.id}')`;
|
||||
set.attachedRemoteUsersCount = () => '"attachedRemoteUsersCount" - 1';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.mentionedUserIds.some(id => id === user.id)) {
|
||||
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
|
||||
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
|
||||
}
|
||||
// 自分が(ローカル内で)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isLocalUser(user) && !index.mentionedLocalUserIds.some(id => id === user.id)) {
|
||||
set.mentionedLocalUserIds = () => `array_append("mentionedLocalUserIds", '${user.id}')`;
|
||||
set.mentionedLocalUsersCount = () => '"mentionedLocalUsersCount" + 1';
|
||||
}
|
||||
// 自分が(リモートで)初めてこのタグを使ったなら
|
||||
if (this.userEntityService.isRemoteUser(user) && !index.mentionedRemoteUserIds.some(id => id === user.id)) {
|
||||
set.mentionedRemoteUserIds = () => `array_append("mentionedRemoteUserIds", '${user.id}')`;
|
||||
set.mentionedRemoteUsersCount = () => '"mentionedRemoteUsersCount" + 1';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(set).length > 0) {
|
||||
q.set(set);
|
||||
q.execute();
|
||||
}
|
||||
} else {
|
||||
if (isUserAttached) {
|
||||
this.hashtagsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
name: tag,
|
||||
mentionedUserIds: [],
|
||||
mentionedUsersCount: 0,
|
||||
mentionedLocalUserIds: [],
|
||||
mentionedLocalUsersCount: 0,
|
||||
mentionedRemoteUserIds: [],
|
||||
mentionedRemoteUsersCount: 0,
|
||||
attachedUserIds: [user.id],
|
||||
attachedUsersCount: 1,
|
||||
attachedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
attachedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
attachedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
attachedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
} as Hashtag);
|
||||
} else {
|
||||
this.hashtagsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
name: tag,
|
||||
mentionedUserIds: [user.id],
|
||||
mentionedUsersCount: 1,
|
||||
mentionedLocalUserIds: this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
mentionedLocalUsersCount: this.userEntityService.isLocalUser(user) ? 1 : 0,
|
||||
mentionedRemoteUserIds: this.userEntityService.isRemoteUser(user) ? [user.id] : [],
|
||||
mentionedRemoteUsersCount: this.userEntityService.isRemoteUser(user) ? 1 : 0,
|
||||
attachedUserIds: [],
|
||||
attachedUsersCount: 0,
|
||||
attachedLocalUserIds: [],
|
||||
attachedLocalUsersCount: 0,
|
||||
attachedRemoteUserIds: [],
|
||||
attachedRemoteUsersCount: 0,
|
||||
} as Hashtag);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUserAttached) {
|
||||
this.hashtagChart.update(tag, user);
|
||||
}
|
||||
}
|
||||
}
|
154
packages/backend/src/core/HttpRequestService.ts
Normal file
154
packages/backend/src/core/HttpRequestService.ts
Normal file
@ -0,0 +1,154 @@
|
||||
import * as http from 'node:http';
|
||||
import * as https from 'node:https';
|
||||
import CacheableLookup from 'cacheable-lookup';
|
||||
import fetch from 'node-fetch';
|
||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { StatusError } from '@/misc/status-error.js';
|
||||
import type { Response } from 'node-fetch';
|
||||
import type { URL } from 'node:url';
|
||||
|
||||
@Injectable()
|
||||
export class HttpRequestService {
|
||||
/**
|
||||
* Get http non-proxy agent
|
||||
*/
|
||||
#http: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https non-proxy agent
|
||||
*/
|
||||
#https: https.Agent;
|
||||
|
||||
/**
|
||||
* Get http proxy or non-proxy agent
|
||||
*/
|
||||
public httpAgent: http.Agent;
|
||||
|
||||
/**
|
||||
* Get https proxy or non-proxy agent
|
||||
*/
|
||||
public httpsAgent: https.Agent;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
const cache = new CacheableLookup({
|
||||
maxTtl: 3600, // 1hours
|
||||
errorTtl: 30, // 30secs
|
||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||
});
|
||||
|
||||
this.#http = new http.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup,
|
||||
} as http.AgentOptions);
|
||||
|
||||
this.#https = new https.Agent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
lookup: cache.lookup,
|
||||
} as https.AgentOptions);
|
||||
|
||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||
|
||||
this.httpAgent = config.proxy
|
||||
? new HttpProxyAgent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
maxSockets,
|
||||
maxFreeSockets: 256,
|
||||
scheduling: 'lifo',
|
||||
proxy: config.proxy,
|
||||
})
|
||||
: this.#http;
|
||||
|
||||
this.httpsAgent = config.proxy
|
||||
? new HttpsProxyAgent({
|
||||
keepAlive: true,
|
||||
keepAliveMsecs: 30 * 1000,
|
||||
maxSockets,
|
||||
maxFreeSockets: 256,
|
||||
scheduling: 'lifo',
|
||||
proxy: config.proxy,
|
||||
})
|
||||
: this.#https;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get agent by URL
|
||||
* @param url URL
|
||||
* @param bypassProxy Allways bypass proxy
|
||||
*/
|
||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
||||
if (bypassProxy || (this.config.proxyBypassHosts || []).includes(url.hostname)) {
|
||||
return url.protocol === 'http:' ? this.#http : this.#https;
|
||||
} else {
|
||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||
}
|
||||
}
|
||||
|
||||
public async getJson(url: string, accept = 'application/json, */*', timeout = 10000, headers?: Record<string, string>): Promise<unknown> {
|
||||
const res = await this.getResponse({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
'User-Agent': this.config.userAgent,
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout,
|
||||
});
|
||||
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
public async getHtml(url: string, accept = 'text/html, */*', timeout = 10000, headers?: Record<string, string>): Promise<string> {
|
||||
const res = await this.getResponse({
|
||||
url,
|
||||
method: 'GET',
|
||||
headers: Object.assign({
|
||||
'User-Agent': this.config.userAgent,
|
||||
Accept: accept,
|
||||
}, headers ?? {}),
|
||||
timeout,
|
||||
});
|
||||
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
public async getResponse(args: {
|
||||
url: string,
|
||||
method: string,
|
||||
body?: string,
|
||||
headers: Record<string, string>,
|
||||
timeout?: number,
|
||||
size?: number,
|
||||
}): Promise<Response> {
|
||||
const timeout = args.timeout ?? 10 * 1000;
|
||||
|
||||
const controller = new AbortController();
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, timeout * 6);
|
||||
|
||||
const res = await fetch(args.url, {
|
||||
method: args.method,
|
||||
headers: args.headers,
|
||||
body: args.body,
|
||||
timeout,
|
||||
size: args.size ?? 10 * 1024 * 1024,
|
||||
agent: (url) => this.getAgentByUrl(url),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new StatusError(`${res.status} ${res.statusText}`, res.status, res.statusText);
|
||||
}
|
||||
|
||||
return res;
|
||||
}
|
||||
}
|
33
packages/backend/src/core/IdService.ts
Normal file
33
packages/backend/src/core/IdService.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ulid } from 'ulid';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { genAid } from '@/misc/id/aid.js';
|
||||
import { genMeid } from '@/misc/id/meid.js';
|
||||
import { genMeidg } from '@/misc/id/meidg.js';
|
||||
import { genObjectId } from '@/misc/id/object-id.js';
|
||||
|
||||
@Injectable()
|
||||
export class IdService {
|
||||
#metohd: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
this.#metohd = config.id.toLowerCase();
|
||||
}
|
||||
|
||||
public genId(date?: Date): string {
|
||||
if (!date || (date > new Date())) date = new Date();
|
||||
|
||||
switch (this.#metohd) {
|
||||
case 'aid': return genAid(date);
|
||||
case 'meid': return genMeid(date);
|
||||
case 'meidg': return genMeidg(date);
|
||||
case 'ulid': return ulid(date.getTime());
|
||||
case 'objectid': return genObjectId(date);
|
||||
default: throw new Error('unrecognized id generation method');
|
||||
}
|
||||
}
|
||||
}
|
99
packages/backend/src/core/ImageProcessingService.ts
Normal file
99
packages/backend/src/core/ImageProcessingService.ts
Normal file
@ -0,0 +1,99 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import sharp from 'sharp';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
export type IImage = {
|
||||
data: Buffer;
|
||||
ext: string | null;
|
||||
type: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ImageProcessingService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to JPEG
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
public async convertToJpeg(path: string, width: number, height: number): Promise<IImage> {
|
||||
return this.convertSharpToJpeg(await sharp(path), width, height);
|
||||
}
|
||||
|
||||
public async convertSharpToJpeg(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.jpeg({
|
||||
quality: 85,
|
||||
progressive: true,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'jpg',
|
||||
type: 'image/jpeg',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to WebP
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
public async convertToWebp(path: string, width: number, height: number, quality = 85): Promise<IImage> {
|
||||
return this.convertSharpToWebp(await sharp(path), width, height, quality);
|
||||
}
|
||||
|
||||
public async convertSharpToWebp(sharp: sharp.Sharp, width: number, height: number, quality = 85): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.webp({
|
||||
quality,
|
||||
})
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'webp',
|
||||
type: 'image/webp',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to PNG
|
||||
* with resize, remove metadata, resolve orientation, stop animation
|
||||
*/
|
||||
public async convertToPng(path: string, width: number, height: number): Promise<IImage> {
|
||||
return this.convertSharpToPng(await sharp(path), width, height);
|
||||
}
|
||||
|
||||
public async convertSharpToPng(sharp: sharp.Sharp, width: number, height: number): Promise<IImage> {
|
||||
const data = await sharp
|
||||
.resize(width, height, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true,
|
||||
})
|
||||
.rotate()
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
return {
|
||||
data,
|
||||
ext: 'png',
|
||||
type: 'image/png',
|
||||
};
|
||||
}
|
||||
}
|
42
packages/backend/src/core/InstanceActorService.ts
Normal file
42
packages/backend/src/core/InstanceActorService.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { ILocalUser } from '@/models/entities/User.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CreateSystemUserService } from './CreateSystemUserService.js';
|
||||
|
||||
const ACTOR_USERNAME = 'instance.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class InstanceActorService {
|
||||
#cache: Cache<ILocalUser>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
) {
|
||||
this.#cache = new Cache<ILocalUser>(Infinity);
|
||||
}
|
||||
|
||||
public async getInstanceActor(): Promise<ILocalUser> {
|
||||
const cached = this.#cache.get(null);
|
||||
if (cached) return cached;
|
||||
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
username: ACTOR_USERNAME,
|
||||
}) as ILocalUser | undefined;
|
||||
|
||||
if (user) {
|
||||
this.#cache.set(null, user);
|
||||
return user;
|
||||
} else {
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as ILocalUser;
|
||||
this.#cache.set(null, created);
|
||||
return created;
|
||||
}
|
||||
}
|
||||
}
|
45
packages/backend/src/core/InternalStorageService.ts
Normal file
45
packages/backend/src/core/InternalStorageService.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as Path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname } from 'node:path';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
const _filename = fileURLToPath(import.meta.url);
|
||||
const _dirname = dirname(_filename);
|
||||
|
||||
const path = Path.resolve(_dirname, '../../../../files');
|
||||
|
||||
@Injectable()
|
||||
export class InternalStorageService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public resolvePath(key: string) {
|
||||
return Path.resolve(path, key);
|
||||
}
|
||||
|
||||
public read(key: string) {
|
||||
return fs.createReadStream(this.resolvePath(key));
|
||||
}
|
||||
|
||||
public saveFromPath(key: string, srcPath: string) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.copyFileSync(srcPath, this.resolvePath(key));
|
||||
return `${this.config.url}/files/${key}`;
|
||||
}
|
||||
|
||||
public saveFromBuffer(key: string, data: Buffer) {
|
||||
fs.mkdirSync(path, { recursive: true });
|
||||
fs.writeFileSync(this.resolvePath(key), data);
|
||||
return `${this.config.url}/files/${key}`;
|
||||
}
|
||||
|
||||
public del(key: string) {
|
||||
fs.unlink(this.resolvePath(key), () => {});
|
||||
}
|
||||
}
|
300
packages/backend/src/core/MessagingService.ts
Normal file
300
packages/backend/src/core/MessagingService.ts
Normal file
@ -0,0 +1,300 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { MessagingMessage } from '@/models/entities/MessagingMessage.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import type { User, CacheableUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import type { UserGroup } from '@/models/entities/UserGroup.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { toArray } from '@/misc/prelude/array.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { MessagingMessagesRepository, MutingsRepository, UserGroupJoiningsRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdService } from './IdService.js';
|
||||
import { GlobalEventService } from './GlobalEventService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
export class MessagingService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.messagingMessagesRepository)
|
||||
private messagingMessagesRepository: MessagingMessagesRepository,
|
||||
|
||||
@Inject(DI.userGroupJoiningsRepository)
|
||||
private userGroupJoiningsRepository: UserGroupJoiningsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private messagingMessageEntityService: MessagingMessageEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private queueService: QueueService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: CacheableUser | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) {
|
||||
const message = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
fileId: file ? file.id : null,
|
||||
recipientId: recipientUser ? recipientUser.id : null,
|
||||
groupId: recipientGroup ? recipientGroup.id : null,
|
||||
text: text ? text.trim() : null,
|
||||
userId: user.id,
|
||||
isRead: false,
|
||||
reads: [] as any[],
|
||||
uri,
|
||||
} as MessagingMessage;
|
||||
|
||||
await this.messagingMessagesRepository.insert(message);
|
||||
|
||||
const messageObj = await this.messagingMessageEntityService.pack(message);
|
||||
|
||||
if (recipientUser) {
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 自分のストリーム
|
||||
this.globalEventService.publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj);
|
||||
this.globalEventService.publishMessagingIndexStream(message.userId, 'message', messageObj);
|
||||
this.globalEventService.publishMainStream(message.userId, 'messagingMessage', messageObj);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(recipientUser)) {
|
||||
// 相手のストリーム
|
||||
this.globalEventService.publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj);
|
||||
this.globalEventService.publishMessagingIndexStream(recipientUser.id, 'message', messageObj);
|
||||
this.globalEventService.publishMainStream(recipientUser.id, 'messagingMessage', messageObj);
|
||||
}
|
||||
} else if (recipientGroup) {
|
||||
// グループのストリーム
|
||||
this.globalEventService.publishGroupMessagingStream(recipientGroup.id, 'message', messageObj);
|
||||
|
||||
// メンバーのストリーム
|
||||
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id });
|
||||
for (const joining of joinings) {
|
||||
this.globalEventService.publishMessagingIndexStream(joining.userId, 'message', messageObj);
|
||||
this.globalEventService.publishMainStream(joining.userId, 'messagingMessage', messageObj);
|
||||
}
|
||||
}
|
||||
|
||||
// 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const freshMessage = await this.messagingMessagesRepository.findOneBy({ id: message.id });
|
||||
if (freshMessage == null) return; // メッセージが削除されている場合もある
|
||||
|
||||
if (recipientUser && this.userEntityService.isLocalUser(recipientUser)) {
|
||||
if (freshMessage.isRead) return; // 既読
|
||||
|
||||
//#region ただしミュートされているなら発行しない
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: recipientUser.id,
|
||||
});
|
||||
if (mute.map(m => m.muteeId).includes(user.id)) return;
|
||||
//#endregion
|
||||
|
||||
this.globalEventService.publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj);
|
||||
this.pushNotificationService.pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj);
|
||||
} else if (recipientGroup) {
|
||||
const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) });
|
||||
for (const joining of joinings) {
|
||||
if (freshMessage.reads.includes(joining.userId)) return; // 既読
|
||||
this.globalEventService.publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj);
|
||||
this.pushNotificationService.pushNotification(joining.userId, 'unreadMessagingMessage', messageObj);
|
||||
}
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
if (recipientUser && this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipientUser)) {
|
||||
const note = {
|
||||
id: message.id,
|
||||
createdAt: message.createdAt,
|
||||
fileIds: message.fileId ? [message.fileId] : [],
|
||||
text: message.text,
|
||||
userId: message.userId,
|
||||
visibility: 'specified',
|
||||
mentions: [recipientUser].map(u => u.id),
|
||||
mentionedRemoteUsers: JSON.stringify([recipientUser].map(u => ({
|
||||
uri: u.uri,
|
||||
username: u.username,
|
||||
host: u.host,
|
||||
}))),
|
||||
} as Note;
|
||||
|
||||
const activity = this.apRendererService.renderActivity(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note));
|
||||
|
||||
this.queueService.deliver(user, activity, recipientUser.inbox);
|
||||
}
|
||||
return messageObj;
|
||||
}
|
||||
|
||||
public async deleteMessage(message: MessagingMessage) {
|
||||
await this.messagingMessagesRepository.delete(message.id);
|
||||
this.#postDeleteMessage(message);
|
||||
}
|
||||
|
||||
async #postDeleteMessage(message: MessagingMessage) {
|
||||
if (message.recipientId) {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: message.userId });
|
||||
const recipient = await this.usersRepository.findOneByOrFail({ id: message.recipientId });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) this.globalEventService.publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id);
|
||||
if (this.userEntityService.isLocalUser(recipient)) this.globalEventService.publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id);
|
||||
|
||||
if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) {
|
||||
const activity = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user));
|
||||
this.queueService.deliver(user, activity, recipient.inbox);
|
||||
}
|
||||
} else if (message.groupId) {
|
||||
this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read
|
||||
*/
|
||||
public async readUserMessagingMessage(
|
||||
userId: User['id'],
|
||||
otherpartyId: User['id'],
|
||||
messageIds: MessagingMessage['id'][],
|
||||
) {
|
||||
if (messageIds.length === 0) return;
|
||||
|
||||
const messages = await this.messagingMessagesRepository.findBy({
|
||||
id: In(messageIds),
|
||||
});
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.recipientId !== userId) {
|
||||
throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).');
|
||||
}
|
||||
}
|
||||
|
||||
// Update documents
|
||||
await this.messagingMessagesRepository.update({
|
||||
id: In(messageIds),
|
||||
userId: otherpartyId,
|
||||
recipientId: userId,
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
// Publish event
|
||||
this.globalEventService.publishMessagingStream(otherpartyId, userId, 'read', messageIds);
|
||||
this.globalEventService.publishMessagingIndexStream(userId, 'read', messageIds);
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) {
|
||||
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages');
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined);
|
||||
} else {
|
||||
// そのユーザーとのメッセージで未読がなければイベント発行
|
||||
const count = await this.messagingMessagesRepository.count({
|
||||
where: {
|
||||
userId: otherpartyId,
|
||||
recipientId: userId,
|
||||
isRead: false,
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (!count) {
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark messages as read
|
||||
*/
|
||||
public async readGroupMessagingMessage(
|
||||
userId: User['id'],
|
||||
groupId: UserGroup['id'],
|
||||
messageIds: MessagingMessage['id'][],
|
||||
) {
|
||||
if (messageIds.length === 0) return;
|
||||
|
||||
// check joined
|
||||
const joining = await this.userGroupJoiningsRepository.findOneBy({
|
||||
userId: userId,
|
||||
userGroupId: groupId,
|
||||
});
|
||||
|
||||
if (joining == null) {
|
||||
throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).');
|
||||
}
|
||||
|
||||
const messages = await this.messagingMessagesRepository.findBy({
|
||||
id: In(messageIds),
|
||||
});
|
||||
|
||||
const reads: MessagingMessage['id'][] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.userId === userId) continue;
|
||||
if (message.reads.includes(userId)) continue;
|
||||
|
||||
// Update document
|
||||
await this.messagingMessagesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reads: (() => `array_append("reads", '${joining.userId}')`) as any,
|
||||
})
|
||||
.where('id = :id', { id: message.id })
|
||||
.execute();
|
||||
|
||||
reads.push(message.id);
|
||||
}
|
||||
|
||||
// Publish event
|
||||
this.globalEventService.publishGroupMessagingStream(groupId, 'read', {
|
||||
ids: reads,
|
||||
userId: userId,
|
||||
});
|
||||
this.globalEventService.publishMessagingIndexStream(userId, 'read', reads);
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) {
|
||||
// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
|
||||
this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages');
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined);
|
||||
} else {
|
||||
// そのグループにおいて未読がなければイベント発行
|
||||
const unreadExist = await this.messagingMessagesRepository.createQueryBuilder('message')
|
||||
.where('message.groupId = :groupId', { groupId: groupId })
|
||||
.andWhere('message.userId != :userId', { userId: userId })
|
||||
.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
|
||||
.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
|
||||
.getOne().then(x => x != null);
|
||||
|
||||
if (!unreadExist) {
|
||||
this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deliverReadActivity(user: { id: User['id']; host: null; }, recipient: IRemoteUser, messages: MessagingMessage | MessagingMessage[]) {
|
||||
messages = toArray(messages).filter(x => x.uri);
|
||||
const contents = messages.map(x => this.apRendererService.renderRead(user, x));
|
||||
|
||||
if (contents.length > 1) {
|
||||
const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents);
|
||||
this.queueService.deliver(user, this.apRendererService.renderActivity(collection), recipient.inbox);
|
||||
} else {
|
||||
for (const content of contents) {
|
||||
this.queueService.deliver(user, this.apRendererService.renderActivity(content), recipient.inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
63
packages/backend/src/core/MetaService.ts
Normal file
63
packages/backend/src/core/MetaService.ts
Normal file
@ -0,0 +1,63 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Meta } from '@/models/entities/Meta.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class MetaService implements OnApplicationShutdown {
|
||||
#cache: Meta | undefined;
|
||||
#intervalId: NodeJS.Timer;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
) {
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
this.#intervalId = setInterval(() => {
|
||||
this.fetch(true).then(meta => {
|
||||
this.#cache = meta;
|
||||
});
|
||||
}, 1000 * 10);
|
||||
}
|
||||
}
|
||||
|
||||
async fetch(noCache = false): Promise<Meta> {
|
||||
if (!noCache && this.#cache) return this.#cache;
|
||||
|
||||
return await this.db.transaction(async transactionalEntityManager => {
|
||||
// 過去のバグでレコードが複数出来てしまっている可能性があるので新しいIDを優先する
|
||||
const metas = await transactionalEntityManager.find(Meta, {
|
||||
order: {
|
||||
id: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
const meta = metas[0];
|
||||
|
||||
if (meta) {
|
||||
this.#cache = meta;
|
||||
return meta;
|
||||
} else {
|
||||
// metaが空のときfetchMetaが同時に呼ばれるとここが同時に呼ばれてしまうことがあるのでフェイルセーフなupsertを使う
|
||||
const saved = await transactionalEntityManager
|
||||
.upsert(
|
||||
Meta,
|
||||
{
|
||||
id: 'x',
|
||||
},
|
||||
['id'],
|
||||
)
|
||||
.then((x) => transactionalEntityManager.findOneByOrFail(Meta, x.identifiers[0]));
|
||||
|
||||
this.#cache = saved;
|
||||
return saved;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
clearInterval(this.#intervalId);
|
||||
}
|
||||
}
|
384
packages/backend/src/core/MfmService.ts
Normal file
384
packages/backend/src/core/MfmService.ts
Normal file
@ -0,0 +1,384 @@
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as parse5 from 'parse5';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { intersperse } from '@/misc/prelude/array.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||
import * as TreeAdapter from '../../node_modules/parse5/dist/tree-adapters/default.js';
|
||||
import type * as mfm from 'mfm-js';
|
||||
|
||||
const treeAdapter = TreeAdapter.defaultTreeAdapter;
|
||||
|
||||
const urlRegex = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+/;
|
||||
const urlRegexFull = /^https?:\/\/[\w\/:%#@$&?!()\[\]~.,=+\-]+$/;
|
||||
|
||||
@Injectable()
|
||||
export class MfmService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public fromHtml(html: string, hashtagNames?: string[]): string {
|
||||
// some AP servers like Pixelfed use br tags as well as newlines
|
||||
html = html.replace(/<br\s?\/?>\r?\n/gi, '\n');
|
||||
|
||||
const dom = parse5.parseFragment(html);
|
||||
|
||||
let text = '';
|
||||
|
||||
for (const n of dom.childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
|
||||
return text.trim();
|
||||
|
||||
function getText(node: TreeAdapter.Node): string {
|
||||
if (treeAdapter.isTextNode(node)) return node.value;
|
||||
if (!treeAdapter.isElementNode(node)) return '';
|
||||
if (node.nodeName === 'br') return '\n';
|
||||
|
||||
if (node.childNodes) {
|
||||
return node.childNodes.map(n => getText(n)).join('');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function appendChildren(childNodes: TreeAdapter.ChildNode[]): void {
|
||||
if (childNodes) {
|
||||
for (const n of childNodes) {
|
||||
analyze(n);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function analyze(node: TreeAdapter.Node) {
|
||||
if (treeAdapter.isTextNode(node)) {
|
||||
text += node.value;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip comment or document type node
|
||||
if (!treeAdapter.isElementNode(node)) return;
|
||||
|
||||
switch (node.nodeName) {
|
||||
case 'br': {
|
||||
text += '\n';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'a':
|
||||
{
|
||||
const txt = getText(node);
|
||||
const rel = node.attrs.find(x => x.name === 'rel');
|
||||
const href = node.attrs.find(x => x.name === 'href');
|
||||
|
||||
// ハッシュタグ
|
||||
if (hashtagNames && href && hashtagNames.map(x => x.toLowerCase()).includes(txt.toLowerCase())) {
|
||||
text += txt;
|
||||
// メンション
|
||||
} else if (txt.startsWith('@') && !(rel && rel.value.match(/^me /))) {
|
||||
const part = txt.split('@');
|
||||
|
||||
if (part.length === 2 && href) {
|
||||
//#region ホスト名部分が省略されているので復元する
|
||||
const acct = `${txt}@${(new URL(href.value)).hostname}`;
|
||||
text += acct;
|
||||
//#endregion
|
||||
} else if (part.length === 3) {
|
||||
text += txt;
|
||||
}
|
||||
// その他
|
||||
} else {
|
||||
const generateLink = () => {
|
||||
if (!href && !txt) {
|
||||
return '';
|
||||
}
|
||||
if (!href) {
|
||||
return txt;
|
||||
}
|
||||
if (!txt || txt === href.value) { // #6383: Missing text node
|
||||
if (href.value.match(urlRegexFull)) {
|
||||
return href.value;
|
||||
} else {
|
||||
return `<${href.value}>`;
|
||||
}
|
||||
}
|
||||
if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
|
||||
return `[${txt}](<${href.value}>)`; // #6846
|
||||
} else {
|
||||
return `[${txt}](${href.value})`;
|
||||
}
|
||||
};
|
||||
|
||||
text += generateLink();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'h1':
|
||||
{
|
||||
text += '【';
|
||||
appendChildren(node.childNodes);
|
||||
text += '】\n';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'b':
|
||||
case 'strong':
|
||||
{
|
||||
text += '**';
|
||||
appendChildren(node.childNodes);
|
||||
text += '**';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'small':
|
||||
{
|
||||
text += '<small>';
|
||||
appendChildren(node.childNodes);
|
||||
text += '</small>';
|
||||
break;
|
||||
}
|
||||
|
||||
case 's':
|
||||
case 'del':
|
||||
{
|
||||
text += '~~';
|
||||
appendChildren(node.childNodes);
|
||||
text += '~~';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'i':
|
||||
case 'em':
|
||||
{
|
||||
text += '<i>';
|
||||
appendChildren(node.childNodes);
|
||||
text += '</i>';
|
||||
break;
|
||||
}
|
||||
|
||||
// block code (<pre><code>)
|
||||
case 'pre': {
|
||||
if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
|
||||
text += '\n```\n';
|
||||
text += getText(node.childNodes[0]);
|
||||
text += '\n```\n';
|
||||
} else {
|
||||
appendChildren(node.childNodes);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// inline code (<code>)
|
||||
case 'code': {
|
||||
text += '`';
|
||||
appendChildren(node.childNodes);
|
||||
text += '`';
|
||||
break;
|
||||
}
|
||||
|
||||
case 'blockquote': {
|
||||
const t = getText(node);
|
||||
if (t) {
|
||||
text += '\n> ';
|
||||
text += t.split('\n').join('\n> ');
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'p':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
case 'h4':
|
||||
case 'h5':
|
||||
case 'h6':
|
||||
{
|
||||
text += '\n\n';
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
// other block elements
|
||||
case 'div':
|
||||
case 'header':
|
||||
case 'footer':
|
||||
case 'article':
|
||||
case 'li':
|
||||
case 'dt':
|
||||
case 'dd':
|
||||
{
|
||||
text += '\n';
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
|
||||
default: // includes inline elements
|
||||
{
|
||||
appendChildren(node.childNodes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public toHtml(nodes: mfm.MfmNode[] | null, mentionedRemoteUsers: IMentionedRemoteUsers = []) {
|
||||
if (nodes == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { window } = new JSDOM('');
|
||||
|
||||
const doc = window.document;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType<K>) => any } = {
|
||||
bold: (node) => {
|
||||
const el = doc.createElement('b');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
small: (node) => {
|
||||
const el = doc.createElement('small');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
strike: (node) => {
|
||||
const el = doc.createElement('del');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
italic: (node) => {
|
||||
const el = doc.createElement('i');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
fn: (node) => {
|
||||
const el = doc.createElement('i');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
blockCode: (node) => {
|
||||
const pre = doc.createElement('pre');
|
||||
const inner = doc.createElement('code');
|
||||
inner.textContent = node.props.code;
|
||||
pre.appendChild(inner);
|
||||
return pre;
|
||||
},
|
||||
|
||||
center: (node) => {
|
||||
const el = doc.createElement('div');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
emojiCode: (node) => {
|
||||
return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
|
||||
},
|
||||
|
||||
unicodeEmoji: (node) => {
|
||||
return doc.createTextNode(node.props.emoji);
|
||||
},
|
||||
|
||||
hashtag: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `${this.config.url}/tags/${node.props.hashtag}`;
|
||||
a.textContent = `#${node.props.hashtag}`;
|
||||
a.setAttribute('rel', 'tag');
|
||||
return a;
|
||||
},
|
||||
|
||||
inlineCode: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.code;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathInline: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
mathBlock: (node) => {
|
||||
const el = doc.createElement('code');
|
||||
el.textContent = node.props.formula;
|
||||
return el;
|
||||
},
|
||||
|
||||
link: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
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 === username && remoteUser.host === host);
|
||||
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
|
||||
a.className = 'u-url mention';
|
||||
a.textContent = acct;
|
||||
return a;
|
||||
},
|
||||
|
||||
quote: (node) => {
|
||||
const el = doc.createElement('blockquote');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
|
||||
text: (node) => {
|
||||
const el = doc.createElement('span');
|
||||
const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
|
||||
|
||||
for (const x of intersperse<FIXME | 'br'>('br', nodes)) {
|
||||
el.appendChild(x === 'br' ? doc.createElement('br') : x);
|
||||
}
|
||||
|
||||
return el;
|
||||
},
|
||||
|
||||
url: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = node.props.url;
|
||||
a.textContent = node.props.url;
|
||||
return a;
|
||||
},
|
||||
|
||||
search: (node) => {
|
||||
const a = doc.createElement('a');
|
||||
a.href = `https://www.google.com/search?q=${node.props.query}`;
|
||||
a.textContent = node.props.content;
|
||||
return a;
|
||||
},
|
||||
|
||||
plain: (node) => {
|
||||
const el = doc.createElement('span');
|
||||
appendChildren(node.children, el);
|
||||
return el;
|
||||
},
|
||||
};
|
||||
|
||||
appendChildren(nodes, doc.body);
|
||||
|
||||
return `<p>${doc.body.innerHTML}</p>`;
|
||||
}
|
||||
}
|
26
packages/backend/src/core/ModerationLogService.ts
Normal file
26
packages/backend/src/core/ModerationLogService.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ModerationLogsRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ModerationLogService {
|
||||
constructor(
|
||||
@Inject(DI.moderationLogsRepository)
|
||||
private moderationLogsRepository: ModerationLogsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async insertModerationLog(moderator: { id: User['id'] }, type: string, info?: Record<string, any>) {
|
||||
await this.moderationLogsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: moderator.id,
|
||||
type: type,
|
||||
info: info ?? {},
|
||||
});
|
||||
}
|
||||
}
|
742
packages/backend/src/core/NoteCreateService.ts
Normal file
742
packages/backend/src/core/NoteCreateService.ts
Normal file
@ -0,0 +1,742 @@
|
||||
import * as mfm from 'mfm-js';
|
||||
import { Not, In, DataSource } from 'typeorm';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { extractMentions } from '@/misc/extract-mentions.js';
|
||||
import { extractCustomEmojisFromMfm } from '@/misc/extract-custom-emojis-from-mfm.js';
|
||||
import { extractHashtags } from '@/misc/extract-hashtags.js';
|
||||
import type { IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||
import { Note } from '@/models/entities/Note.js';
|
||||
import { ChannelFollowingsRepository, ChannelsRepository, InstancesRepository, MutedNotesRepository, MutingsRepository, NotesRepository, NoteThreadMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { App } from '@/models/entities/App.js';
|
||||
import { concat } from '@/misc/prelude/array.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import type { IPoll } from '@/models/entities/Poll.js';
|
||||
import { Poll } from '@/models/entities/Poll.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { checkWordMute } from '@/misc/check-word-mute.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import NotesChart from '@/core/chart/charts/notes.js';
|
||||
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import { WebhookService } from '@/core/WebhookService.js';
|
||||
import { HashtagService } from '@/core/HashtagService.js';
|
||||
import { AntennaService } from '@/core/AntennaService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { NoteReadService } from './NoteReadService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { ResolveUserService } from './remote/ResolveUserService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
|
||||
const mutedWordsCache = new Cache<{ userId: UserProfile['userId']; mutedWords: UserProfile['mutedWords']; }[]>(1000 * 60 * 5);
|
||||
|
||||
type NotificationType = 'reply' | 'renote' | 'quote' | 'mention';
|
||||
|
||||
class NotificationManager {
|
||||
private notifier: { id: User['id']; };
|
||||
private note: Note;
|
||||
private queue: {
|
||||
target: ILocalUser['id'];
|
||||
reason: NotificationType;
|
||||
}[];
|
||||
|
||||
constructor(
|
||||
private mutingsRepository: MutingsRepository,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
notifier: { id: User['id']; },
|
||||
note: Note,
|
||||
) {
|
||||
this.notifier = notifier;
|
||||
this.note = note;
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
public push(notifiee: ILocalUser['id'], reason: NotificationType) {
|
||||
// 自分自身へは通知しない
|
||||
if (this.notifier.id === notifiee) return;
|
||||
|
||||
const exist = this.queue.find(x => x.target === notifiee);
|
||||
|
||||
if (exist) {
|
||||
// 「メンションされているかつ返信されている」場合は、メンションとしての通知ではなく返信としての通知にする
|
||||
if (reason !== 'mention') {
|
||||
exist.reason = reason;
|
||||
}
|
||||
} else {
|
||||
this.queue.push({
|
||||
reason: reason,
|
||||
target: notifiee,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async deliver() {
|
||||
for (const x of this.queue) {
|
||||
// ミュート情報を取得
|
||||
const mentioneeMutes = await this.mutingsRepository.findBy({
|
||||
muterId: x.target,
|
||||
});
|
||||
|
||||
const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId);
|
||||
|
||||
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
|
||||
if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
|
||||
this.createNotificationService.createNotification(x.target, x.reason, {
|
||||
notifierId: this.notifier.id,
|
||||
noteId: this.note.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type MinimumUser = {
|
||||
id: User['id'];
|
||||
host: User['host'];
|
||||
username: User['username'];
|
||||
uri: User['uri'];
|
||||
};
|
||||
|
||||
type Option = {
|
||||
createdAt?: Date | null;
|
||||
name?: string | null;
|
||||
text?: string | null;
|
||||
reply?: Note | null;
|
||||
renote?: Note | null;
|
||||
files?: DriveFile[] | null;
|
||||
poll?: IPoll | null;
|
||||
localOnly?: boolean | null;
|
||||
cw?: string | null;
|
||||
visibility?: string;
|
||||
visibleUsers?: MinimumUser[] | null;
|
||||
channel?: Channel | null;
|
||||
apMentions?: MinimumUser[] | null;
|
||||
apHashtags?: string[] | null;
|
||||
apEmojis?: string[] | null;
|
||||
uri?: string | null;
|
||||
url?: string | null;
|
||||
app?: App | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class NoteCreateService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private queueService: QueueService,
|
||||
private noteReadService: NoteReadService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private hashtagService: HashtagService,
|
||||
private antennaService: AntennaService,
|
||||
private webhookService: WebhookService,
|
||||
private resolveUserService: ResolveUserService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {}
|
||||
|
||||
public async create(user: {
|
||||
id: User['id'];
|
||||
username: User['username'];
|
||||
host: User['host'];
|
||||
isSilenced: User['isSilenced'];
|
||||
createdAt: User['createdAt'];
|
||||
}, data: Option, silent = false): Promise<Note> {
|
||||
// チャンネル外にリプライしたら対象のスコープに合わせる
|
||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||
if (data.reply && data.channel && data.reply.channelId !== data.channel.id) {
|
||||
if (data.reply.channelId) {
|
||||
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
|
||||
} else {
|
||||
data.channel = null;
|
||||
}
|
||||
}
|
||||
|
||||
// チャンネル内にリプライしたら対象のスコープに合わせる
|
||||
// (クライアントサイドでやっても良い処理だと思うけどとりあえずサーバーサイドで)
|
||||
if (data.reply && (data.channel == null) && data.reply.channelId) {
|
||||
data.channel = await this.channelsRepository.findOneBy({ id: data.reply.channelId });
|
||||
}
|
||||
|
||||
if (data.createdAt == null) data.createdAt = new Date();
|
||||
if (data.visibility == null) data.visibility = 'public';
|
||||
if (data.localOnly == null) data.localOnly = false;
|
||||
if (data.channel != null) data.visibility = 'public';
|
||||
if (data.channel != null) data.visibleUsers = [];
|
||||
if (data.channel != null) data.localOnly = true;
|
||||
|
||||
// サイレンス
|
||||
if (user.isSilenced && data.visibility === 'public' && data.channel == null) {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象が「ホームまたは全体」以外の公開範囲ならreject
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.renote.visibility !== 'home' && data.renote.userId !== user.id) {
|
||||
throw new Error('Renote target is not public or home');
|
||||
}
|
||||
|
||||
// Renote対象がpublicではないならhomeにする
|
||||
if (data.renote && data.renote.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// Renote対象がfollowersならfollowersにする
|
||||
if (data.renote && data.renote.visibility === 'followers') {
|
||||
data.visibility = 'followers';
|
||||
}
|
||||
|
||||
// 返信対象がpublicではないならhomeにする
|
||||
if (data.reply && data.reply.visibility !== 'public' && data.visibility === 'public') {
|
||||
data.visibility = 'home';
|
||||
}
|
||||
|
||||
// ローカルのみをRenoteしたらローカルのみにする
|
||||
if (data.renote && data.renote.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
|
||||
// ローカルのみにリプライしたらローカルのみにする
|
||||
if (data.reply && data.reply.localOnly && data.channel == null) {
|
||||
data.localOnly = true;
|
||||
}
|
||||
|
||||
if (data.text) {
|
||||
data.text = data.text.trim();
|
||||
} else {
|
||||
data.text = null;
|
||||
}
|
||||
|
||||
let tags = data.apHashtags;
|
||||
let emojis = data.apEmojis;
|
||||
let mentionedUsers = data.apMentions;
|
||||
|
||||
// Parse MFM if needed
|
||||
if (!tags || !emojis || !mentionedUsers) {
|
||||
const tokens = data.text ? mfm.parse(data.text)! : [];
|
||||
const cwTokens = data.cw ? mfm.parse(data.cw)! : [];
|
||||
const choiceTokens = data.poll && data.poll.choices
|
||||
? concat(data.poll.choices.map(choice => mfm.parse(choice)!))
|
||||
: [];
|
||||
|
||||
const combinedTokens = tokens.concat(cwTokens).concat(choiceTokens);
|
||||
|
||||
tags = data.apHashtags ?? extractHashtags(combinedTokens);
|
||||
|
||||
emojis = data.apEmojis ?? extractCustomEmojisFromMfm(combinedTokens);
|
||||
|
||||
mentionedUsers = data.apMentions ?? await this.#extractMentionedUsers(user, combinedTokens);
|
||||
}
|
||||
|
||||
tags = tags.filter(tag => Array.from(tag ?? '').length <= 128).splice(0, 32);
|
||||
|
||||
if (data.reply && (user.id !== data.reply.userId) && !mentionedUsers.some(u => u.id === data.reply!.userId)) {
|
||||
mentionedUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
|
||||
}
|
||||
|
||||
if (data.visibility === 'specified') {
|
||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
if (!mentionedUsers.some(x => x.id === u.id)) {
|
||||
mentionedUsers.push(u);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.reply && !data.visibleUsers.some(x => x.id === data.reply!.userId)) {
|
||||
data.visibleUsers.push(await this.usersRepository.findOneByOrFail({ id: data.reply!.userId }));
|
||||
}
|
||||
}
|
||||
|
||||
const note = await this.#insertNote(user, data, tags, emojis, mentionedUsers);
|
||||
|
||||
setImmediate(() => this.#postNoteCreated(note, user, data, silent, tags!, mentionedUsers!));
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
async #insertNote(user: { id: User['id']; host: User['host']; }, data: Option, tags: string[], emojis: string[], mentionedUsers: MinimumUser[]) {
|
||||
const insert = new Note({
|
||||
id: this.idService.genId(data.createdAt!),
|
||||
createdAt: data.createdAt!,
|
||||
fileIds: data.files ? data.files.map(file => file.id) : [],
|
||||
replyId: data.reply ? data.reply.id : null,
|
||||
renoteId: data.renote ? data.renote.id : null,
|
||||
channelId: data.channel ? data.channel.id : null,
|
||||
threadId: data.reply
|
||||
? data.reply.threadId
|
||||
? data.reply.threadId
|
||||
: data.reply.id
|
||||
: null,
|
||||
name: data.name,
|
||||
text: data.text,
|
||||
hasPoll: data.poll != null,
|
||||
cw: data.cw == null ? null : data.cw,
|
||||
tags: tags.map(tag => normalizeForSearch(tag)),
|
||||
emojis,
|
||||
userId: user.id,
|
||||
localOnly: data.localOnly!,
|
||||
visibility: data.visibility as any,
|
||||
visibleUserIds: data.visibility === 'specified'
|
||||
? data.visibleUsers
|
||||
? data.visibleUsers.map(u => u.id)
|
||||
: []
|
||||
: [],
|
||||
|
||||
attachedFileTypes: data.files ? data.files.map(file => file.type) : [],
|
||||
|
||||
// 以下非正規化データ
|
||||
replyUserId: data.reply ? data.reply.userId : null,
|
||||
replyUserHost: data.reply ? data.reply.userHost : null,
|
||||
renoteUserId: data.renote ? data.renote.userId : null,
|
||||
renoteUserHost: data.renote ? data.renote.userHost : null,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
if (data.uri != null) insert.uri = data.uri;
|
||||
if (data.url != null) insert.url = data.url;
|
||||
|
||||
// Append mentions data
|
||||
if (mentionedUsers.length > 0) {
|
||||
insert.mentions = mentionedUsers.map(u => u.id);
|
||||
const profiles = await this.userProfilesRepository.findBy({ userId: In(insert.mentions) });
|
||||
insert.mentionedRemoteUsers = JSON.stringify(mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u)).map(u => {
|
||||
const profile = profiles.find(p => p.userId === u.id);
|
||||
const url = profile != null ? profile.url : null;
|
||||
return {
|
||||
uri: u.uri,
|
||||
url: url == null ? undefined : url,
|
||||
username: u.username,
|
||||
host: u.host,
|
||||
} as IMentionedRemoteUsers[0];
|
||||
}));
|
||||
}
|
||||
|
||||
// 投稿を作成
|
||||
try {
|
||||
if (insert.hasPoll) {
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
await transactionalEntityManager.insert(Note, insert);
|
||||
|
||||
const poll = new Poll({
|
||||
noteId: insert.id,
|
||||
choices: data.poll!.choices,
|
||||
expiresAt: data.poll!.expiresAt,
|
||||
multiple: data.poll!.multiple,
|
||||
votes: new Array(data.poll!.choices.length).fill(0),
|
||||
noteVisibility: insert.visibility,
|
||||
userId: user.id,
|
||||
userHost: user.host,
|
||||
});
|
||||
|
||||
await transactionalEntityManager.insert(Poll, poll);
|
||||
});
|
||||
} else {
|
||||
await this.notesRepository.insert(insert);
|
||||
}
|
||||
|
||||
return insert;
|
||||
} catch (e) {
|
||||
// duplicate key error
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const err = new Error('Duplicated note');
|
||||
err.name = 'duplicated';
|
||||
throw err;
|
||||
}
|
||||
|
||||
console.error(e);
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async #postNoteCreated(note: Note, user: {
|
||||
id: User['id'];
|
||||
username: User['username'];
|
||||
host: User['host'];
|
||||
isSilenced: User['isSilenced'];
|
||||
createdAt: User['createdAt'];
|
||||
}, data: Option, silent: boolean, tags: string[], mentionedUsers: MinimumUser[]) {
|
||||
// 統計を更新
|
||||
this.notesChart.update(note, true);
|
||||
this.perUserNotesChart.update(user, note, true);
|
||||
|
||||
// Register host
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'notesCount', 1);
|
||||
this.instanceChart.updateNote(i.host, note, true);
|
||||
});
|
||||
}
|
||||
|
||||
// ハッシュタグ更新
|
||||
if (data.visibility === 'public' || data.visibility === 'home') {
|
||||
this.hashtagService.updateHashtags(user, tags);
|
||||
}
|
||||
|
||||
// Increment notes count (user)
|
||||
this.#incNotesCountOfUser(user);
|
||||
|
||||
// Word mute
|
||||
mutedWordsCache.fetch(null, () => this.userProfilesRepository.find({
|
||||
where: {
|
||||
enableWordMute: true,
|
||||
},
|
||||
select: ['userId', 'mutedWords'],
|
||||
})).then(us => {
|
||||
for (const u of us) {
|
||||
checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => {
|
||||
if (shouldMute) {
|
||||
this.mutedNotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
userId: u.userId,
|
||||
noteId: note.id,
|
||||
reason: 'word',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Antenna
|
||||
for (const antenna of (await this.antennaService.getAntennas())) {
|
||||
this.antennaService.checkHitAntenna(antenna, note, user).then(hit => {
|
||||
if (hit) {
|
||||
this.antennaService.addNoteToAntenna(antenna, note, user);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Channel
|
||||
if (note.channelId) {
|
||||
this.channelFollowingsRepository.findBy({ followeeId: note.channelId }).then(followings => {
|
||||
for (const following of followings) {
|
||||
this.noteReadService.insertNoteUnread(following.followerId, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (data.reply) {
|
||||
this.#saveReply(data.reply, note);
|
||||
}
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (data.renote && (await this.noteEntityService.countSameRenotes(user.id, data.renote.id, note.id) === 0)) {
|
||||
this.#incRenoteCount(data.renote);
|
||||
}
|
||||
|
||||
if (data.poll && data.poll.expiresAt) {
|
||||
const delay = data.poll.expiresAt.getTime() - Date.now();
|
||||
this.queueService.endedPollNotificationQueue.add({
|
||||
noteId: note.id,
|
||||
}, {
|
||||
delay,
|
||||
removeOnComplete: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
|
||||
|
||||
// 未読通知を作成
|
||||
if (data.visibility === 'specified') {
|
||||
if (data.visibleUsers == null) throw new Error('invalid param');
|
||||
|
||||
for (const u of data.visibleUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: true,
|
||||
isMentioned: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const u of mentionedUsers) {
|
||||
// ローカルユーザーのみ
|
||||
if (!this.userEntityService.isLocalUser(u)) continue;
|
||||
|
||||
this.noteReadService.insertNoteUnread(u.id, note, {
|
||||
isSpecified: false,
|
||||
isMentioned: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Pack the note
|
||||
const noteObj = await this.noteEntityService.pack(note);
|
||||
|
||||
this.globalEventServie.publishNotesStream(noteObj);
|
||||
|
||||
this.webhookService.getActiveWebhooks().then(webhooks => {
|
||||
webhooks = webhooks.filter(x => x.userId === user.id && x.on.includes('note'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'note', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const nm = new NotificationManager(this.mutingsRepository, this.createNotificationService, user, note);
|
||||
const nmRelatedPromises = [];
|
||||
|
||||
await this.#createMentionedEvents(mentionedUsers, note, nm);
|
||||
|
||||
// If has in reply to note
|
||||
if (data.reply) {
|
||||
// 通知
|
||||
if (data.reply.userHost === null) {
|
||||
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
|
||||
userId: data.reply.userId,
|
||||
threadId: data.reply.threadId ?? data.reply.id,
|
||||
});
|
||||
|
||||
if (!threadMuted) {
|
||||
nm.push(data.reply.userId, 'reply');
|
||||
this.globalEventServie.publishMainStream(data.reply.userId, 'reply', noteObj);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.reply!.userId && x.on.includes('reply'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'reply', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If it is renote
|
||||
if (data.renote) {
|
||||
const type = data.text ? 'quote' : 'renote';
|
||||
|
||||
// Notify
|
||||
if (data.renote.userHost === null) {
|
||||
nm.push(data.renote.userId, type);
|
||||
}
|
||||
|
||||
// Publish event
|
||||
if ((user.id !== data.renote.userId) && data.renote.userHost === null) {
|
||||
this.globalEventServie.publishMainStream(data.renote.userId, 'renote', noteObj);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === data.renote!.userId && x.on.includes('renote'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'renote', {
|
||||
note: noteObj,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all(nmRelatedPromises).then(() => {
|
||||
nm.deliver();
|
||||
});
|
||||
|
||||
//#region AP deliver
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
(async () => {
|
||||
const noteActivity = await this.#renderNoteOrRenoteActivity(data, note);
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
|
||||
|
||||
// メンションされたリモートユーザーに配送
|
||||
for (const u of mentionedUsers.filter(u => this.userEntityService.isRemoteUser(u))) {
|
||||
dm.addDirectRecipe(u as IRemoteUser);
|
||||
}
|
||||
|
||||
// 投稿がリプライかつ投稿者がローカルユーザーかつリプライ先の投稿の投稿者がリモートユーザーなら配送
|
||||
if (data.reply && data.reply.userHost !== null) {
|
||||
const u = await this.usersRepository.findOneBy({ id: data.reply.userId });
|
||||
if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
|
||||
// 投稿がRenoteかつ投稿者がローカルユーザーかつRenote元の投稿の投稿者がリモートユーザーなら配送
|
||||
if (data.renote && data.renote.userHost !== null) {
|
||||
const u = await this.usersRepository.findOneBy({ id: data.renote.userId });
|
||||
if (u && this.userEntityService.isRemoteUser(u)) dm.addDirectRecipe(u);
|
||||
}
|
||||
|
||||
// フォロワーに配送
|
||||
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
}
|
||||
|
||||
if (['public'].includes(note.visibility)) {
|
||||
this.relayService.deliverToRelays(user, noteActivity);
|
||||
}
|
||||
|
||||
dm.execute();
|
||||
})();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
if (data.channel) {
|
||||
this.channelsRepository.increment({ id: data.channel.id }, 'notesCount', 1);
|
||||
this.channelsRepository.update(data.channel.id, {
|
||||
lastNotedAt: new Date(),
|
||||
});
|
||||
|
||||
this.notesRepository.countBy({
|
||||
userId: user.id,
|
||||
channelId: data.channel.id,
|
||||
}).then(count => {
|
||||
// この処理が行われるのはノート作成後なので、ノートが一つしかなかったら最初の投稿だと判断できる
|
||||
// TODO: とはいえノートを削除して何回も投稿すればその分だけインクリメントされる雑さもあるのでどうにかしたい
|
||||
if (count === 1) {
|
||||
this.channelsRepository.increment({ id: data.channel!.id }, 'usersCount', 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Register to search database
|
||||
this.#index(note);
|
||||
}
|
||||
|
||||
#incRenoteCount(renote: Note) {
|
||||
this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
renoteCount: () => '"renoteCount" + 1',
|
||||
score: () => '"score" + 1',
|
||||
})
|
||||
.where('id = :id', { id: renote.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
async #createMentionedEvents(mentionedUsers: MinimumUser[], note: Note, nm: NotificationManager) {
|
||||
for (const u of mentionedUsers.filter(u => this.userEntityService.isLocalUser(u))) {
|
||||
const threadMuted = await this.noteThreadMutingsRepository.findOneBy({
|
||||
userId: u.id,
|
||||
threadId: note.threadId ?? note.id,
|
||||
});
|
||||
|
||||
if (threadMuted) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const detailPackedNote = await this.noteEntityService.pack(note, u, {
|
||||
detail: true,
|
||||
});
|
||||
|
||||
this.globalEventServie.publishMainStream(u.id, 'mention', detailPackedNote);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === u.id && x.on.includes('mention'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'mention', {
|
||||
note: detailPackedNote,
|
||||
});
|
||||
}
|
||||
|
||||
// Create notification
|
||||
nm.push(u.id, 'mention');
|
||||
}
|
||||
}
|
||||
|
||||
#saveReply(reply: Note, note: Note) {
|
||||
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
|
||||
}
|
||||
|
||||
async #renderNoteOrRenoteActivity(data: Option, note: Note) {
|
||||
if (data.localOnly) return null;
|
||||
|
||||
const content = data.renote && data.text == null && data.poll == null && (data.files == null || data.files.length === 0)
|
||||
? 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, false), note);
|
||||
|
||||
return this.apRendererService.renderActivity(content);
|
||||
}
|
||||
|
||||
#index(note: Note) {
|
||||
if (note.text == null || this.config.elasticsearch == null) return;
|
||||
/*
|
||||
es!.index({
|
||||
index: this.config.elasticsearch.index ?? 'misskey_note',
|
||||
id: note.id.toString(),
|
||||
body: {
|
||||
text: normalizeForSearch(note.text),
|
||||
userId: note.userId,
|
||||
userHost: note.userHost,
|
||||
},
|
||||
});*/
|
||||
}
|
||||
|
||||
#incNotesCountOfUser(user: { id: User['id']; }) {
|
||||
this.usersRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
updatedAt: new Date(),
|
||||
notesCount: () => '"notesCount" + 1',
|
||||
})
|
||||
.where('id = :id', { id: user.id })
|
||||
.execute();
|
||||
}
|
||||
|
||||
async #extractMentionedUsers(user: { host: User['host']; }, tokens: mfm.MfmNode[]): Promise<User[]> {
|
||||
if (tokens == null) return [];
|
||||
|
||||
const mentions = extractMentions(tokens);
|
||||
let mentionedUsers = (await Promise.all(mentions.map(m =>
|
||||
this.resolveUserService.resolveUser(m.username, m.host ?? user.host).catch(() => null),
|
||||
))).filter(x => x != null) as User[];
|
||||
|
||||
// Drop duplicate users
|
||||
mentionedUsers = mentionedUsers.filter((u, i, self) =>
|
||||
i === self.findIndex(u2 => u.id === u2.id),
|
||||
);
|
||||
|
||||
return mentionedUsers;
|
||||
}
|
||||
}
|
168
packages/backend/src/core/NoteDeleteService.ts
Normal file
168
packages/backend/src/core/NoteDeleteService.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import type { User, ILocalUser, IRemoteUser } from '@/models/entities/User.js';
|
||||
import type { Note, IMentionedRemoteUsers } from '@/models/entities/Note.js';
|
||||
import { InstancesRepository, NotesRepository, UsersRepository } from '@/models/index.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import NotesChart from '@/core/chart/charts/notes.js';
|
||||
import PerUserNotesChart from '@/core/chart/charts/per-user-notes.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteDeleteService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private relayService: RelayService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private notesChart: NotesChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 投稿を削除します。
|
||||
* @param user 投稿者
|
||||
* @param note 投稿
|
||||
*/
|
||||
async delete(user: { id: User['id']; uri: User['uri']; host: User['host']; }, note: Note, quiet = false) {
|
||||
const deletedAt = new Date();
|
||||
|
||||
// この投稿を除く指定したユーザーによる指定したノートのリノートが存在しないとき
|
||||
if (note.renoteId && (await this.noteEntityService.countSameRenotes(user.id, note.renoteId, note.id)) === 0) {
|
||||
this.notesRepository.decrement({ id: note.renoteId }, 'renoteCount', 1);
|
||||
this.notesRepository.decrement({ id: note.renoteId }, 'score', 1);
|
||||
}
|
||||
|
||||
if (note.replyId) {
|
||||
await this.notesRepository.decrement({ id: note.replyId }, 'repliesCount', 1);
|
||||
}
|
||||
|
||||
if (!quiet) {
|
||||
this.globalEventServie.publishNoteStream(note.id, 'deleted', {
|
||||
deletedAt: deletedAt,
|
||||
});
|
||||
|
||||
//#region ローカルの投稿なら削除アクティビティを配送
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
|
||||
let renote: Note | null = null;
|
||||
|
||||
// if deletd note is renote
|
||||
if (note.renoteId && note.text == null && !note.hasPoll && (note.fileIds == null || note.fileIds.length === 0)) {
|
||||
renote = await this.notesRepository.findOneBy({
|
||||
id: note.renoteId,
|
||||
});
|
||||
}
|
||||
|
||||
const content = this.apRendererService.renderActivity(renote
|
||||
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
|
||||
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
|
||||
|
||||
this.#deliverToConcerned(user, note, content);
|
||||
}
|
||||
|
||||
// also deliever delete activity to cascaded notes
|
||||
const cascadingNotes = (await this.#findCascadingNotes(note)).filter(note => !note.localOnly); // filter out local-only notes
|
||||
for (const cascadingNote of cascadingNotes) {
|
||||
if (!cascadingNote.user) continue;
|
||||
if (!this.userEntityService.isLocalUser(cascadingNote.user)) continue;
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${cascadingNote.id}`), cascadingNote.user));
|
||||
this.#deliverToConcerned(cascadingNote.user, cascadingNote, content);
|
||||
}
|
||||
//#endregion
|
||||
|
||||
// 統計を更新
|
||||
this.notesChart.update(note, false);
|
||||
this.perUserNotesChart.update(user, note, false);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(user)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(user.host).then(i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'notesCount', 1);
|
||||
this.instanceChart.updateNote(i.host, note, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.notesRepository.delete({
|
||||
id: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
async #findCascadingNotes(note: Note) {
|
||||
const cascadingNotes: Note[] = [];
|
||||
|
||||
const recursive = async (noteId: string) => {
|
||||
const query = this.notesRepository.createQueryBuilder('note')
|
||||
.where('note.replyId = :noteId', { noteId })
|
||||
.orWhere(new Brackets(q => {
|
||||
q.where('note.renoteId = :noteId', { noteId })
|
||||
.andWhere('note.text IS NOT NULL');
|
||||
}))
|
||||
.leftJoinAndSelect('note.user', 'user');
|
||||
const replies = await query.getMany();
|
||||
for (const reply of replies) {
|
||||
cascadingNotes.push(reply);
|
||||
await recursive(reply.id);
|
||||
}
|
||||
};
|
||||
await recursive(note.id);
|
||||
|
||||
return cascadingNotes.filter(note => note.userHost === null); // filter out non-local users
|
||||
}
|
||||
|
||||
async #getMentionedRemoteUsers(note: Note) {
|
||||
const where = [] as any[];
|
||||
|
||||
// mention / reply / dm
|
||||
const uris = (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri);
|
||||
if (uris.length > 0) {
|
||||
where.push(
|
||||
{ uri: In(uris) },
|
||||
);
|
||||
}
|
||||
|
||||
// renote / quote
|
||||
if (note.renoteUserId) {
|
||||
where.push({
|
||||
id: note.renoteUserId,
|
||||
});
|
||||
}
|
||||
|
||||
if (where.length === 0) return [];
|
||||
|
||||
return await this.usersRepository.find({
|
||||
where,
|
||||
}) as IRemoteUser[];
|
||||
}
|
||||
|
||||
async #deliverToConcerned(user: { id: ILocalUser['id']; host: null; }, note: Note, content: any) {
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
const remoteUsers = await this.#getMentionedRemoteUsers(note);
|
||||
for (const remoteUser of remoteUsers) {
|
||||
this.apDeliverManagerService.deliverToUser(user, content, remoteUser);
|
||||
}
|
||||
}
|
||||
}
|
117
packages/backend/src/core/NotePiningService.ts
Normal file
117
packages/backend/src/core/NotePiningService.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { UserNotePining } from '@/models/entities/UserNotePining.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotePiningService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private relayService: RelayService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した投稿をピン留めします
|
||||
* @param user
|
||||
* @param noteId
|
||||
*/
|
||||
public async addPinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
|
||||
// Fetch pinee
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: noteId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('70c4e51f-5bea-449c-a030-53bee3cce202', 'No such note.');
|
||||
}
|
||||
|
||||
const pinings = await this.userNotePiningsRepository.findBy({ userId: user.id });
|
||||
|
||||
if (pinings.length >= 5) {
|
||||
throw new IdentifiableError('15a018eb-58e5-4da1-93be-330fcc5e4e1a', 'You can not pin notes any more.');
|
||||
}
|
||||
|
||||
if (pinings.some(pining => pining.noteId === note.id)) {
|
||||
throw new IdentifiableError('23f0cf4e-59a3-4276-a91d-61a5891c1514', 'That note has already been pinned.');
|
||||
}
|
||||
|
||||
await this.userNotePiningsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
} as UserNotePining);
|
||||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.deliverPinnedChange(user.id, note.id, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定した投稿のピン留めを解除します
|
||||
* @param user
|
||||
* @param noteId
|
||||
*/
|
||||
public async removePinned(user: { id: User['id']; host: User['host']; }, noteId: Note['id']) {
|
||||
// Fetch unpinee
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
id: noteId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (note == null) {
|
||||
throw new IdentifiableError('b302d4cf-c050-400a-bbb3-be208681f40c', 'No such note.');
|
||||
}
|
||||
|
||||
this.userNotePiningsRepository.delete({
|
||||
userId: user.id,
|
||||
noteId: note.id,
|
||||
});
|
||||
|
||||
// Deliver to remote followers
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.deliverPinnedChange(user.id, noteId, false);
|
||||
}
|
||||
}
|
||||
|
||||
public async deliverPinnedChange(userId: User['id'], noteId: Note['id'], isAddition: boolean) {
|
||||
const user = await this.usersRepository.findOneBy({ id: userId });
|
||||
if (user == null) throw new Error('user not found');
|
||||
|
||||
if (!this.userEntityService.isLocalUser(user)) return;
|
||||
|
||||
const target = `${this.config.url}/users/${user.id}/collections/featured`;
|
||||
const item = `${this.config.url}/notes/${noteId}`;
|
||||
const content = this.apRendererService.renderActivity(isAddition ? this.apRendererService.renderAdd(user, target, item) : this.apRendererService.renderRemove(user, target, item));
|
||||
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
214
packages/backend/src/core/NoteReadService.ts
Normal file
214
packages/backend/src/core/NoteReadService.ts
Normal file
@ -0,0 +1,214 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, IsNull, Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Channel } from '@/models/entities/Channel.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { NotificationService } from './NotificationService.js';
|
||||
import { AntennaService } from './AntennaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NoteReadService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.noteUnreadsRepository)
|
||||
private noteUnreadsRepository: NoteUnreadsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.antennaNotesRepository)
|
||||
private antennaNotesRepository: AntennaNotesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private notificationService: NotificationService,
|
||||
private antennaService: AntennaService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async insertNoteUnread(userId: User['id'], note: Note, params: {
|
||||
// NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
|
||||
isSpecified: boolean;
|
||||
isMentioned: boolean;
|
||||
}): Promise<void> {
|
||||
//#region ミュートしているなら無視
|
||||
// TODO: 現在の仕様ではChannelにミュートは適用されないのでよしなにケアする
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: userId,
|
||||
});
|
||||
if (mute.map(m => m.muteeId).includes(note.userId)) return;
|
||||
//#endregion
|
||||
|
||||
// スレッドミュート
|
||||
const threadMute = await this.noteThreadMutingsRepository.findOneBy({
|
||||
userId: userId,
|
||||
threadId: note.threadId ?? note.id,
|
||||
});
|
||||
if (threadMute) return;
|
||||
|
||||
const unread = {
|
||||
id: this.idService.genId(),
|
||||
noteId: note.id,
|
||||
userId: userId,
|
||||
isSpecified: params.isSpecified,
|
||||
isMentioned: params.isMentioned,
|
||||
noteChannelId: note.channelId,
|
||||
noteUserId: note.userId,
|
||||
};
|
||||
|
||||
await this.noteUnreadsRepository.insert(unread);
|
||||
|
||||
// 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
|
||||
setTimeout(async () => {
|
||||
const exist = await this.noteUnreadsRepository.findOneBy({ id: unread.id });
|
||||
|
||||
if (exist == null) return;
|
||||
|
||||
if (params.isMentioned) {
|
||||
this.globalEventServie.publishMainStream(userId, 'unreadMention', note.id);
|
||||
}
|
||||
if (params.isSpecified) {
|
||||
this.globalEventServie.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
|
||||
}
|
||||
if (note.channelId) {
|
||||
this.globalEventServie.publishMainStream(userId, 'unreadChannel', note.id);
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
public async read(
|
||||
userId: User['id'],
|
||||
notes: (Note | Packed<'Note'>)[],
|
||||
info?: {
|
||||
following: Set<User['id']>;
|
||||
followingChannels: Set<Channel['id']>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const following = info?.following ? info.following : new Set<string>((await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: userId,
|
||||
},
|
||||
select: ['followeeId'],
|
||||
})).map(x => x.followeeId));
|
||||
const followingChannels = info?.followingChannels ? info.followingChannels : new Set<string>((await this.channelFollowingsRepository.find({
|
||||
where: {
|
||||
followerId: userId,
|
||||
},
|
||||
select: ['followeeId'],
|
||||
})).map(x => x.followeeId));
|
||||
|
||||
const myAntennas = (await this.antennaService.getAntennas()).filter(a => a.userId === userId);
|
||||
const readMentions: (Note | Packed<'Note'>)[] = [];
|
||||
const readSpecifiedNotes: (Note | Packed<'Note'>)[] = [];
|
||||
const readChannelNotes: (Note | Packed<'Note'>)[] = [];
|
||||
const readAntennaNotes: (Note | Packed<'Note'>)[] = [];
|
||||
|
||||
for (const note of notes) {
|
||||
if (note.mentions && note.mentions.includes(userId)) {
|
||||
readMentions.push(note);
|
||||
} else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
|
||||
readSpecifiedNotes.push(note);
|
||||
}
|
||||
|
||||
if (note.channelId && followingChannels.has(note.channelId)) {
|
||||
readChannelNotes.push(note);
|
||||
}
|
||||
|
||||
if (note.user != null) { // たぶんnullになることは無いはずだけど一応
|
||||
for (const antenna of myAntennas) {
|
||||
if (await this.antennaService.checkHitAntenna(antenna, note, note.user, undefined, Array.from(following))) {
|
||||
readAntennaNotes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((readMentions.length > 0) || (readSpecifiedNotes.length > 0) || (readChannelNotes.length > 0)) {
|
||||
// Remove the record
|
||||
await this.noteUnreadsRepository.delete({
|
||||
userId: userId,
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id), ...readChannelNotes.map(n => n.id)]),
|
||||
});
|
||||
|
||||
// TODO: ↓まとめてクエリしたい
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isMentioned: true,
|
||||
}).then(mentionsCount => {
|
||||
if (mentionsCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllUnreadMentions');
|
||||
}
|
||||
});
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
isSpecified: true,
|
||||
}).then(specifiedCount => {
|
||||
if (specifiedCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
|
||||
}
|
||||
});
|
||||
|
||||
this.noteUnreadsRepository.countBy({
|
||||
userId: userId,
|
||||
noteChannelId: Not(IsNull()),
|
||||
}).then(channelNoteCount => {
|
||||
if (channelNoteCount === 0) {
|
||||
// 全て既読になったイベントを発行
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllChannels');
|
||||
}
|
||||
});
|
||||
|
||||
this.notificationService.readNotificationByQuery(userId, {
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
||||
});
|
||||
}
|
||||
|
||||
if (readAntennaNotes.length > 0) {
|
||||
await this.antennaNotesRepository.update({
|
||||
antennaId: In(myAntennas.map(a => a.id)),
|
||||
noteId: In(readAntennaNotes.map(n => n.id)),
|
||||
}, {
|
||||
read: true,
|
||||
});
|
||||
|
||||
// TODO: まとめてクエリしたい
|
||||
for (const antenna of myAntennas) {
|
||||
const count = await this.antennaNotesRepository.countBy({
|
||||
antennaId: antenna.id,
|
||||
read: false,
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
this.globalEventServie.publishMainStream(userId, 'readAntenna', antenna);
|
||||
}
|
||||
}
|
||||
|
||||
this.userEntityService.getHasUnreadAntenna(userId).then(unread => {
|
||||
if (!unread) {
|
||||
this.globalEventServie.publishMainStream(userId, 'readAllAntennas');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
67
packages/backend/src/core/NotificationService.ts
Normal file
67
packages/backend/src/core/NotificationService.ts
Normal file
@ -0,0 +1,67 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotificationsRepository } from '@/models/index.js';
|
||||
import type { UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Notification } from '@/models/entities/Notification.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { GlobalEventService } from './GlobalEventService.js';
|
||||
import { PushNotificationService } from './PushNotificationService.js';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async readNotification(
|
||||
userId: User['id'],
|
||||
notificationIds: Notification['id'][],
|
||||
) {
|
||||
if (notificationIds.length === 0) return;
|
||||
|
||||
// Update documents
|
||||
const result = await this.notificationsRepository.update({
|
||||
notifieeId: userId,
|
||||
id: In(notificationIds),
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
if (result.affected === 0) return;
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.#postReadAllNotifications(userId);
|
||||
else return this.#postReadNotifications(userId, notificationIds);
|
||||
}
|
||||
|
||||
public async readNotificationByQuery(
|
||||
userId: User['id'],
|
||||
query: Record<string, any>,
|
||||
) {
|
||||
const notificationIds = await this.notificationsRepository.findBy({
|
||||
...query,
|
||||
notifieeId: userId,
|
||||
isRead: false,
|
||||
}).then(notifications => notifications.map(notification => notification.id));
|
||||
|
||||
return this.readNotification(userId, notificationIds);
|
||||
}
|
||||
|
||||
#postReadAllNotifications(userId: User['id']) {
|
||||
this.globalEventService.publishMainStream(userId, 'readAllNotifications');
|
||||
return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
|
||||
}
|
||||
|
||||
#postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
|
||||
this.globalEventService.publishMainStream(userId, 'readNotifications', notificationIds);
|
||||
return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds });
|
||||
}
|
||||
}
|
115
packages/backend/src/core/PollService.ts
Normal file
115
packages/backend/src/core/PollService.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotesRepository, UsersRepository, BlockingsRepository } from '@/models/index.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { RelayService } from '@/core/RelayService.js';
|
||||
import type { CacheableUser } from '@/models/entities/User.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
|
||||
@Injectable()
|
||||
export class PollService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.pollsRepository)
|
||||
private pollsRepository: PollsRepository,
|
||||
|
||||
@Inject(DI.pollVotesRepository)
|
||||
private pollVotesRepository: PollVotesRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private relayService: RelayService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async vote(user: CacheableUser, note: Note, choice: number) {
|
||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||
|
||||
if (poll == null) throw new Error('poll not found');
|
||||
|
||||
// Check whether is valid choice
|
||||
if (poll.choices[choice] == null) throw new Error('invalid choice param');
|
||||
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const block = await this.blockingsRepository.findOneBy({
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new Error('blocked');
|
||||
}
|
||||
}
|
||||
|
||||
// if already voted
|
||||
const exist = await this.pollVotesRepository.findBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (poll.multiple) {
|
||||
if (exist.some(x => x.choice === choice)) {
|
||||
throw new Error('already voted');
|
||||
}
|
||||
} else if (exist.length !== 0) {
|
||||
throw new Error('already voted');
|
||||
}
|
||||
|
||||
// Create vote
|
||||
await this.pollVotesRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
choice: choice,
|
||||
});
|
||||
|
||||
// Increment votes count
|
||||
const index = choice + 1; // In SQL, array index is 1 based
|
||||
await this.pollsRepository.query(`UPDATE poll SET votes[${index}] = votes[${index}] + 1 WHERE "noteId" = '${poll.noteId}'`);
|
||||
|
||||
this.globalEventServie.publishNoteStream(note.id, 'pollVoted', {
|
||||
choice: choice,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Notify
|
||||
this.createNotificationService.createNotification(note.userId, 'pollVote', {
|
||||
notifierId: user.id,
|
||||
noteId: note.id,
|
||||
choice: choice,
|
||||
});
|
||||
}
|
||||
|
||||
public async deliverQuestionUpdate(noteId: Note['id']) {
|
||||
const note = await this.notesRepository.findOneBy({ id: noteId });
|
||||
if (note == null) throw new Error('note not found');
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
if (user == null) throw new Error('note not found');
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUpdate(await this.apRendererService.renderNote(note, false), user));
|
||||
this.apDeliverManagerService.deliverToFollowers(user, content);
|
||||
this.relayService.deliverToRelays(user, content);
|
||||
}
|
||||
}
|
||||
}
|
22
packages/backend/src/core/ProxyAccountService.ts
Normal file
22
packages/backend/src/core/ProxyAccountService.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import type { ILocalUser, User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ProxyAccountService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async fetch(): Promise<ILocalUser | null> {
|
||||
const meta = await this.metaService.fetch();
|
||||
if (meta.proxyAccountId == null) return null;
|
||||
return await this.usersRepository.findOneByOrFail({ id: meta.proxyAccountId }) as ILocalUser;
|
||||
}
|
||||
}
|
101
packages/backend/src/core/PushNotificationService.ts
Normal file
101
packages/backend/src/core/PushNotificationService.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import push from 'web-push';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { Packed } from '@/misc/schema';
|
||||
import { getNoteSummary } from '@/misc/get-note-summary.js';
|
||||
import { SwSubscriptionsRepository } from '@/models/index.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
|
||||
// Defined also packages/sw/types.ts#L14-L21
|
||||
type pushNotificationsTypes = {
|
||||
'notification': Packed<'Notification'>;
|
||||
'unreadMessagingMessage': Packed<'MessagingMessage'>;
|
||||
'readNotifications': { notificationIds: string[] };
|
||||
'readAllNotifications': undefined;
|
||||
'readAllMessagingMessages': undefined;
|
||||
'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
|
||||
};
|
||||
|
||||
// プッシュメッセージサーバーには文字数制限があるため、内容を削減します
|
||||
function truncateNotification(notification: Packed<'Notification'>): any {
|
||||
if (notification.note) {
|
||||
return {
|
||||
...notification,
|
||||
note: {
|
||||
...notification.note,
|
||||
// textをgetNoteSummaryしたものに置き換える
|
||||
text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
|
||||
|
||||
cw: undefined,
|
||||
reply: undefined,
|
||||
renote: undefined,
|
||||
user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PushNotificationService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.swSubscriptionsRepository)
|
||||
private swSubscriptionsRepository: SwSubscriptionsRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
|
||||
|
||||
// アプリケーションの連絡先と、サーバーサイドの鍵ペアの情報を登録
|
||||
push.setVapidDetails(this.config.url,
|
||||
meta.swPublicKey,
|
||||
meta.swPrivateKey);
|
||||
|
||||
// Fetch
|
||||
const subscriptions = await this.swSubscriptionsRepository.findBy({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
auth: subscription.auth,
|
||||
p256dh: subscription.publickey,
|
||||
},
|
||||
};
|
||||
|
||||
push.sendNotification(pushSubscription, JSON.stringify({
|
||||
type,
|
||||
body: type === 'notification' ? truncateNotification(body as Packed<'Notification'>) : body,
|
||||
userId,
|
||||
dateTime: (new Date()).getTime(),
|
||||
}), {
|
||||
proxy: this.config.proxy,
|
||||
}).catch((err: any) => {
|
||||
//swLogger.info(err.statusCode);
|
||||
//swLogger.info(err.headers);
|
||||
//swLogger.info(err.body);
|
||||
|
||||
if (err.statusCode === 410) {
|
||||
this.swSubscriptionsRepository.delete({
|
||||
userId: userId,
|
||||
endpoint: subscription.endpoint,
|
||||
auth: subscription.auth,
|
||||
publickey: subscription.publickey,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
262
packages/backend/src/core/QueryService.ts
Normal file
262
packages/backend/src/core/QueryService.ts
Normal file
@ -0,0 +1,262 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Brackets } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
@Injectable()
|
||||
export class QueryService {
|
||||
constructor(
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.noteThreadMutingsRepository)
|
||||
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public makePaginationQuery<T>(q: SelectQueryBuilder<T>, sinceId?: string, untilId?: string, sinceDate?: number, untilDate?: number): 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');
|
||||
} else if (sinceId) {
|
||||
q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
|
||||
q.orderBy(`${q.alias}.id`, 'ASC');
|
||||
} else if (untilId) {
|
||||
q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
} else if (sinceDate && untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
} else if (sinceDate) {
|
||||
q.andWhere(`${q.alias}.createdAt > :sinceDate`, { sinceDate: new Date(sinceDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'ASC');
|
||||
} else if (untilDate) {
|
||||
q.andWhere(`${q.alias}.createdAt < :untilDate`, { untilDate: new Date(untilDate) });
|
||||
q.orderBy(`${q.alias}.createdAt`, 'DESC');
|
||||
} else {
|
||||
q.orderBy(`${q.alias}.id`, 'DESC');
|
||||
}
|
||||
return q;
|
||||
}
|
||||
|
||||
// ここでいうBlockedは被Blockedの意
|
||||
public generateBlockedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
// 投稿の作者にブロックされていない かつ
|
||||
// 投稿の返信先の作者にブロックされていない かつ
|
||||
// 投稿の引用元の作者にブロックされていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ blockingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ blockingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateBlockQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const blockingQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockeeId')
|
||||
.where('blocking.blockerId = :blockerId', { blockerId: me.id });
|
||||
|
||||
const blockedQuery = this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select('blocking.blockerId')
|
||||
.where('blocking.blockeeId = :blockeeId', { blockeeId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ blockingQuery.getQuery() })`);
|
||||
q.setParameters(blockingQuery.getParameters());
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ blockedQuery.getQuery() })`);
|
||||
q.setParameters(blockedQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateChannelQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere('note.channelId IS NULL');
|
||||
} else {
|
||||
q.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
const channelFollowingQuery = this.channelFollowingsRepository.createQueryBuilder('channelFollowing')
|
||||
.select('channelFollowing.followeeId')
|
||||
.where('channelFollowing.followerId = :followerId', { followerId: me.id });
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
// チャンネルのノートではない
|
||||
.where('note.channelId IS NULL')
|
||||
// または自分がフォローしているチャンネルのノート
|
||||
.orWhere(`note.channelId IN (${ channelFollowingQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(channelFollowingQuery.getParameters());
|
||||
}
|
||||
}
|
||||
|
||||
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const mutedQuery = this.mutedNotesRepository.createQueryBuilder('muted')
|
||||
.select('muted.noteId')
|
||||
.where('muted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')
|
||||
.select('threadMuted.threadId')
|
||||
.where('threadMuted.userId = :userId', { userId: me.id });
|
||||
|
||||
q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`);
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.threadId IS NULL')
|
||||
.orWhere(`note.threadId NOT IN (${ mutedQuery.getQuery() })`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutedQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateMutedUserQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }, exclude?: User): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
if (exclude) {
|
||||
mutingQuery.andWhere('muting.muteeId != :excludeId', { excludeId: exclude.id });
|
||||
}
|
||||
|
||||
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
|
||||
.select('user_profile.mutedInstances')
|
||||
.where('user_profile.userId = :muterId', { muterId: me.id });
|
||||
|
||||
// 投稿の作者をミュートしていない かつ
|
||||
// 投稿の返信先の作者をミュートしていない かつ
|
||||
// 投稿の引用元の作者をミュートしていない
|
||||
q
|
||||
.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`)
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserId IS NULL')
|
||||
.orWhere(`note.replyUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserId IS NULL')
|
||||
.orWhere(`note.renoteUserId NOT IN (${ mutingQuery.getQuery() })`);
|
||||
}))
|
||||
// mute instances
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.andWhere('note.userHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.userHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.replyUserHost)`);
|
||||
}))
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
.where('note.renoteUserHost IS NULL')
|
||||
.orWhere(`NOT ((${ mutingInstanceQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
|
||||
}));
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
q.setParameters(mutingInstanceQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateMutedUserQueryForUsers(q: SelectQueryBuilder<any>, me: { id: User['id'] }): void {
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.where('muting.muterId = :muterId', { muterId: me.id });
|
||||
|
||||
q.andWhere(`user.id NOT IN (${ mutingQuery.getQuery() })`);
|
||||
|
||||
q.setParameters(mutingQuery.getParameters());
|
||||
}
|
||||
|
||||
public generateRepliesQuery(q: SelectQueryBuilder<any>, me?: Pick<User, 'id' | 'showTimelineReplies'> | null): void {
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
} else if (!me.showTimelineReplies) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.replyId IS NULL') // 返信ではない
|
||||
.orWhere('note.replyUserId = :meId', { meId: me.id }) // 返信だけど自分のノートへの返信
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど自分の行った返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.userId = :meId', { meId: me.id });
|
||||
}))
|
||||
.orWhere(new Brackets(qb => { qb // 返信だけど投稿者自身への返信
|
||||
.where('note.replyId IS NOT NULL')
|
||||
.andWhere('note.replyUserId = note.userId');
|
||||
}));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
public generateVisibilityQuery(q: SelectQueryBuilder<any>, me?: { id: User['id'] } | null): void {
|
||||
// This code must always be synchronized with the checks in Notes.isVisibleForMe.
|
||||
if (me == null) {
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}));
|
||||
} else {
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :meId');
|
||||
|
||||
q.andWhere(new Brackets(qb => { qb
|
||||
// 公開投稿である
|
||||
.where(new Brackets(qb => { qb
|
||||
.where('note.visibility = \'public\'')
|
||||
.orWhere('note.visibility = \'home\'');
|
||||
}))
|
||||
// または 自分自身
|
||||
.orWhere('note.userId = :meId')
|
||||
// または 自分宛て
|
||||
.orWhere(':meId = ANY(note.visibleUserIds)')
|
||||
.orWhere(':meId = ANY(note.mentions)')
|
||||
.orWhere(new Brackets(qb => { qb
|
||||
// または フォロワー宛ての投稿であり、
|
||||
.where('note.visibility = \'followers\'')
|
||||
.andWhere(new Brackets(qb => { qb
|
||||
// 自分がフォロワーである
|
||||
.where(`note.userId IN (${ followingQuery.getQuery() })`)
|
||||
// または 自分の投稿へのリプライ
|
||||
.orWhere('note.replyUserId = :meId');
|
||||
}));
|
||||
}));
|
||||
}));
|
||||
|
||||
q.setParameters({ meId: me.id });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
242
packages/backend/src/core/QueueService.ts
Normal file
242
packages/backend/src/core/QueueService.ts
Normal file
@ -0,0 +1,242 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { IActivity } from '@/core/remote/activitypub/type.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Webhook, webhookEventTypes } from '@/models/entities/Webhook.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DbQueue, DeliverQueue, EndedPollNotificationQueue, InboxQueue, ObjectStorageQueue, SystemQueue, WebhookDeliverQueue } from './queue/QueueModule.js';
|
||||
import type { ThinUser } from '../queue/types.js';
|
||||
import type httpSignature from '@peertube/http-signature';
|
||||
|
||||
@Injectable()
|
||||
export class QueueService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject('queue:system') public systemQueue: SystemQueue,
|
||||
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
|
||||
@Inject('queue:deliver') public deliverQueue: DeliverQueue,
|
||||
@Inject('queue:inbox') public inboxQueue: InboxQueue,
|
||||
@Inject('queue:db') public dbQueue: DbQueue,
|
||||
@Inject('queue:objectStorage') public objectStorageQueue: ObjectStorageQueue,
|
||||
@Inject('queue:webhookDeliver') public webhookDeliverQueue: WebhookDeliverQueue,
|
||||
) {}
|
||||
|
||||
public deliver(user: ThinUser, content: IActivity, to: string | null) {
|
||||
if (content == null) return null;
|
||||
if (to == null) return null;
|
||||
|
||||
const data = {
|
||||
user: {
|
||||
id: user.id,
|
||||
},
|
||||
content,
|
||||
to,
|
||||
};
|
||||
|
||||
return this.deliverQueue.add(data, {
|
||||
attempts: this.config.deliverJobMaxAttempts ?? 12,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public inbox(activity: IActivity, signature: httpSignature.IParsedSignature) {
|
||||
const data = {
|
||||
activity: activity,
|
||||
signature,
|
||||
};
|
||||
|
||||
return this.inboxQueue.add(data, {
|
||||
attempts: this.config.inboxJobMaxAttempts ?? 8,
|
||||
timeout: 5 * 60 * 1000, // 5min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createDeleteDriveFilesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('deleteDriveFiles', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportCustomEmojisJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportCustomEmojis', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportNotesJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportNotes', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportFollowingJob(user: ThinUser, excludeMuting = false, excludeInactive = false) {
|
||||
return this.dbQueue.add('exportFollowing', {
|
||||
user: user,
|
||||
excludeMuting,
|
||||
excludeInactive,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportMuteJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportMuting', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportBlockingJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportBlocking', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createExportUserListsJob(user: ThinUser) {
|
||||
return this.dbQueue.add('exportUserLists', {
|
||||
user: user,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportFollowingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importFollowing', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportMutingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importMuting', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportBlockingJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importBlocking', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportUserListsJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importUserLists', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createImportCustomEmojisJob(user: ThinUser, fileId: DriveFile['id']) {
|
||||
return this.dbQueue.add('importCustomEmojis', {
|
||||
user: user,
|
||||
fileId: fileId,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createDeleteAccountJob(user: ThinUser, opts: { soft?: boolean; } = {}) {
|
||||
return this.dbQueue.add('deleteAccount', {
|
||||
user: user,
|
||||
soft: opts.soft,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createDeleteObjectStorageFileJob(key: string) {
|
||||
return this.objectStorageQueue.add('deleteFile', {
|
||||
key: key,
|
||||
}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public createCleanRemoteFilesJob() {
|
||||
return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public webhookDeliver(webhook: Webhook, type: typeof webhookEventTypes[number], content: unknown) {
|
||||
const data = {
|
||||
type,
|
||||
content,
|
||||
webhookId: webhook.id,
|
||||
userId: webhook.userId,
|
||||
to: webhook.url,
|
||||
secret: webhook.secret,
|
||||
createdAt: Date.now(),
|
||||
eventId: uuid(),
|
||||
};
|
||||
|
||||
return this.webhookDeliverQueue.add(data, {
|
||||
attempts: 4,
|
||||
timeout: 1 * 60 * 1000, // 1min
|
||||
backoff: {
|
||||
type: 'apBackoff',
|
||||
},
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
});
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.deliverQueue.once('cleaned', (jobs, status) => {
|
||||
//deliverLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.deliverQueue.clean(0, 'delayed');
|
||||
|
||||
this.inboxQueue.once('cleaned', (jobs, status) => {
|
||||
//inboxLogger.succ(`Cleaned ${jobs.length} ${status} jobs`);
|
||||
});
|
||||
this.inboxQueue.clean(0, 'delayed');
|
||||
}
|
||||
}
|
340
packages/backend/src/core/ReactionService.ts
Normal file
340
packages/backend/src/core/ReactionService.ts
Normal file
@ -0,0 +1,340 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { EmojisRepository, BlockingsRepository, NoteReactionsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import type { IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import PerUserReactionsChart from '@/core/chart/charts/per-user-reactions.js';
|
||||
import { emojiRegex } from '@/misc/emoji-regex.js';
|
||||
import { ApDeliverManagerService } from './remote/activitypub/ApDeliverManagerService.js';
|
||||
import { NoteEntityService } from './entities/NoteEntityService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { MetaService } from './MetaService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
|
||||
const legacies: Record<string, string> = {
|
||||
'like': '👍',
|
||||
'love': '❤', // ここに記述する場合は異体字セレクタを入れない
|
||||
'laugh': '😆',
|
||||
'hmm': '🤔',
|
||||
'surprise': '😮',
|
||||
'congrats': '🎉',
|
||||
'angry': '💢',
|
||||
'confused': '😥',
|
||||
'rip': '😇',
|
||||
'pudding': '🍮',
|
||||
'star': '⭐',
|
||||
};
|
||||
|
||||
type DecodedReaction = {
|
||||
/**
|
||||
* リアクション名 (Unicode Emoji or ':name@hostname' or ':name@.')
|
||||
*/
|
||||
reaction: string;
|
||||
|
||||
/**
|
||||
* name (カスタム絵文字の場合name, Emojiクエリに使う)
|
||||
*/
|
||||
name?: string;
|
||||
|
||||
/**
|
||||
* host (カスタム絵文字の場合host, Emojiクエリに使う)
|
||||
*/
|
||||
host?: string | null;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class ReactionService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
||||
@Inject(DI.emojisRepository)
|
||||
private emojisRepository: EmojisRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private metaService: MetaService,
|
||||
private userEntityService: UserEntityService,
|
||||
private noteEntityService: NoteEntityService,
|
||||
private idService: IdService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDeliverManagerService: ApDeliverManagerService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async create(user: { id: User['id']; host: User['host']; }, note: Note, reaction?: string) {
|
||||
// Check blocking
|
||||
if (note.userId !== user.id) {
|
||||
const block = await this.blockingsRepository.findOneBy({
|
||||
blockerId: note.userId,
|
||||
blockeeId: user.id,
|
||||
});
|
||||
if (block) {
|
||||
throw new IdentifiableError('e70412a4-7197-4726-8e74-f3e0deb92aa7');
|
||||
}
|
||||
}
|
||||
|
||||
// check visibility
|
||||
if (!await this.noteEntityService.isVisibleForMe(note, user.id)) {
|
||||
throw new IdentifiableError('68e9d2d1-48bf-42c2-b90a-b20e09fd3d48', 'Note not accessible for you.');
|
||||
}
|
||||
|
||||
// TODO: cache
|
||||
reaction = await this.toDbReaction(reaction, user.host);
|
||||
|
||||
const record: NoteReaction = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
reaction,
|
||||
};
|
||||
|
||||
// Create reaction
|
||||
try {
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} catch (e) {
|
||||
if (isDuplicateKeyValueError(e)) {
|
||||
const exists = await this.noteReactionsRepository.findOneByOrFail({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (exists.reaction !== reaction) {
|
||||
// 別のリアクションがすでにされていたら置き換える
|
||||
await this.delete(user, note);
|
||||
await this.noteReactionsRepository.insert(record);
|
||||
} else {
|
||||
// 同じリアクションがすでにされていたらエラー
|
||||
throw new IdentifiableError('51c42bb4-931a-456b-bff7-e5a8a70dd298');
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Increment reactions count
|
||||
const sql = `jsonb_set("reactions", '{${reaction}}', (COALESCE("reactions"->>'${reaction}', '0')::int + 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
score: () => '"score" + 1',
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
this.perUserReactionsChart.update(user, note);
|
||||
|
||||
// カスタム絵文字リアクションだったら絵文字情報も送る
|
||||
const decodedReaction = this.decodeReaction(reaction);
|
||||
|
||||
const emoji = await this.emojisRepository.findOne({
|
||||
where: {
|
||||
name: decodedReaction.name,
|
||||
host: decodedReaction.host ?? IsNull(),
|
||||
},
|
||||
select: ['name', 'host', 'originalUrl', 'publicUrl'],
|
||||
});
|
||||
|
||||
this.globalEventServie.publishNoteStream(note.id, 'reacted', {
|
||||
reaction: decodedReaction.reaction,
|
||||
emoji: emoji != null ? {
|
||||
name: emoji.host ? `${emoji.name}@${emoji.host}` : `${emoji.name}@.`,
|
||||
url: emoji.publicUrl ?? emoji.originalUrl, // || emoji.originalUrl してるのは後方互換性のため
|
||||
} : null,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// リアクションされたユーザーがローカルユーザーなら通知を作成
|
||||
if (note.userHost === null) {
|
||||
this.createNotificationService.createNotification(note.userId, 'reaction', {
|
||||
notifierId: user.id,
|
||||
noteId: note.id,
|
||||
reaction: reaction,
|
||||
});
|
||||
}
|
||||
|
||||
//#region 配信
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
|
||||
const content = this.apRendererService.renderActivity(await this.apRendererService.renderLike(record, note));
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
if (note.userHost !== null) {
|
||||
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
dm.addDirectRecipe(reactee as IRemoteUser);
|
||||
}
|
||||
|
||||
if (['public', 'home', 'followers'].includes(note.visibility)) {
|
||||
dm.addFollowersRecipe();
|
||||
} else if (note.visibility === 'specified') {
|
||||
const visibleUsers = await Promise.all(note.visibleUserIds.map(id => this.usersRepository.findOneBy({ id })));
|
||||
for (const u of visibleUsers.filter(u => u && this.userEntityService.isRemoteUser(u))) {
|
||||
dm.addDirectRecipe(u as IRemoteUser);
|
||||
}
|
||||
}
|
||||
|
||||
dm.execute();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
public async delete(user: { id: User['id']; host: User['host']; }, note: Note) {
|
||||
// if already unreacted
|
||||
const exist = await this.noteReactionsRepository.findOneBy({
|
||||
noteId: note.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
if (exist == null) {
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
||||
}
|
||||
|
||||
// Delete reaction
|
||||
const result = await this.noteReactionsRepository.delete(exist.id);
|
||||
|
||||
if (result.affected !== 1) {
|
||||
throw new IdentifiableError('60527ec9-b4cb-4a88-a6bd-32d3ad26817d', 'not reacted');
|
||||
}
|
||||
|
||||
// Decrement reactions count
|
||||
const sql = `jsonb_set("reactions", '{${exist.reaction}}', (COALESCE("reactions"->>'${exist.reaction}', '0')::int - 1)::text::jsonb)`;
|
||||
await this.notesRepository.createQueryBuilder().update()
|
||||
.set({
|
||||
reactions: () => sql,
|
||||
})
|
||||
.where('id = :id', { id: note.id })
|
||||
.execute();
|
||||
|
||||
this.notesRepository.decrement({ id: note.id }, 'score', 1);
|
||||
|
||||
this.globalEventServie.publishNoteStream(note.id, 'unreacted', {
|
||||
reaction: this.decodeReaction(exist.reaction).reaction,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
//#region 配信
|
||||
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
|
||||
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
|
||||
if (note.userHost !== null) {
|
||||
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
|
||||
dm.addDirectRecipe(reactee as IRemoteUser);
|
||||
}
|
||||
dm.addFollowersRecipe();
|
||||
dm.execute();
|
||||
}
|
||||
//#endregion
|
||||
}
|
||||
|
||||
public async getFallbackReaction(): Promise<string> {
|
||||
const meta = await this.metaService.fetch();
|
||||
return meta.useStarForReactionFallback ? '⭐' : '👍';
|
||||
}
|
||||
|
||||
public convertLegacyReactions(reactions: Record<string, number>) {
|
||||
const _reactions = {} as Record<string, number>;
|
||||
|
||||
for (const reaction of Object.keys(reactions)) {
|
||||
if (reactions[reaction] <= 0) continue;
|
||||
|
||||
if (Object.keys(legacies).includes(reaction)) {
|
||||
if (_reactions[legacies[reaction]]) {
|
||||
_reactions[legacies[reaction]] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[legacies[reaction]] = reactions[reaction];
|
||||
}
|
||||
} else {
|
||||
if (_reactions[reaction]) {
|
||||
_reactions[reaction] += reactions[reaction];
|
||||
} else {
|
||||
_reactions[reaction] = reactions[reaction];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _reactions2 = {} as Record<string, number>;
|
||||
|
||||
for (const reaction of Object.keys(_reactions)) {
|
||||
_reactions2[this.decodeReaction(reaction).reaction] = _reactions[reaction];
|
||||
}
|
||||
|
||||
return _reactions2;
|
||||
}
|
||||
|
||||
public async toDbReaction(reaction?: string | null, reacterHost?: string | null): Promise<string> {
|
||||
if (reaction == null) return await this.getFallbackReaction();
|
||||
|
||||
reacterHost = this.utilityService.toPunyNullable(reacterHost);
|
||||
|
||||
// 文字列タイプのリアクションを絵文字に変換
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
|
||||
// Unicode絵文字
|
||||
const match = emojiRegex.exec(reaction);
|
||||
if (match) {
|
||||
// 合字を含む1つの絵文字
|
||||
const unicode = match[0];
|
||||
|
||||
// 異体字セレクタ除去
|
||||
return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
|
||||
}
|
||||
|
||||
const custom = reaction.match(/^:([\w+-]+)(?:@\.)?:$/);
|
||||
if (custom) {
|
||||
const name = custom[1];
|
||||
const emoji = await this.emojisRepository.findOneBy({
|
||||
host: reacterHost ?? IsNull(),
|
||||
name,
|
||||
});
|
||||
|
||||
if (emoji) return reacterHost ? `:${name}@${reacterHost}:` : `:${name}:`;
|
||||
}
|
||||
|
||||
return await this.getFallbackReaction();
|
||||
}
|
||||
|
||||
public decodeReaction(str: string): DecodedReaction {
|
||||
const custom = str.match(/^:([\w+-]+)(?:@([\w.-]+))?:$/);
|
||||
|
||||
if (custom) {
|
||||
const name = custom[1];
|
||||
const host = custom[2] ?? null;
|
||||
|
||||
return {
|
||||
reaction: `:${name}@${host ?? '.'}:`, // ローカル分は@以降を省略するのではなく.にする
|
||||
name,
|
||||
host,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
reaction: str,
|
||||
name: undefined,
|
||||
host: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public convertLegacyReaction(reaction: string): string {
|
||||
reaction = this.decodeReaction(reaction).reaction;
|
||||
if (Object.keys(legacies).includes(reaction)) return legacies[reaction];
|
||||
return reaction;
|
||||
}
|
||||
}
|
119
packages/backend/src/core/RelayService.ts
Normal file
119
packages/backend/src/core/RelayService.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { ILocalUser, User } from '@/models/entities/User.js';
|
||||
import { RelaysRepository, UsersRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { Relay } from '@/models/entities/Relay.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { CreateSystemUserService } from '@/core/CreateSystemUserService.js';
|
||||
import { ApRendererService } from '@/core/remote/activitypub/ApRendererService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
const ACTOR_USERNAME = 'relay.actor' as const;
|
||||
|
||||
@Injectable()
|
||||
export class RelayService {
|
||||
#relaysCache: Cache<Relay[]>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.relaysRepository)
|
||||
private relaysRepository: RelaysRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private createSystemUserService: CreateSystemUserService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
this.#relaysCache = new Cache<Relay[]>(1000 * 60 * 10);
|
||||
}
|
||||
|
||||
async #getRelayActor(): Promise<ILocalUser> {
|
||||
const user = await this.usersRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
username: ACTOR_USERNAME,
|
||||
});
|
||||
|
||||
if (user) return user as ILocalUser;
|
||||
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME);
|
||||
return created as ILocalUser;
|
||||
}
|
||||
|
||||
public async addRelay(inbox: string): Promise<Relay> {
|
||||
const relay = await this.relaysRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
inbox,
|
||||
status: 'requesting',
|
||||
}).then(x => this.relaysRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
const relayActor = await this.#getRelayActor();
|
||||
const follow = await this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const activity = this.apRendererService.renderActivity(follow);
|
||||
this.queueService.deliver(relayActor, activity, relay.inbox);
|
||||
|
||||
return relay;
|
||||
}
|
||||
|
||||
public async removeRelay(inbox: string): Promise<void> {
|
||||
const relay = await this.relaysRepository.findOneBy({
|
||||
inbox,
|
||||
});
|
||||
|
||||
if (relay == null) {
|
||||
throw new Error('relay not found');
|
||||
}
|
||||
|
||||
const relayActor = await this.#getRelayActor();
|
||||
const follow = this.apRendererService.renderFollowRelay(relay, relayActor);
|
||||
const undo = this.apRendererService.renderUndo(follow, relayActor);
|
||||
const activity = this.apRendererService.renderActivity(undo);
|
||||
this.queueService.deliver(relayActor, activity, relay.inbox);
|
||||
|
||||
await this.relaysRepository.delete(relay.id);
|
||||
}
|
||||
|
||||
public async listRelay(): Promise<Relay[]> {
|
||||
const relays = await this.relaysRepository.find();
|
||||
return relays;
|
||||
}
|
||||
|
||||
public async relayAccepted(id: string): Promise<string> {
|
||||
const result = await this.relaysRepository.update(id, {
|
||||
status: 'accepted',
|
||||
});
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
public async relayRejected(id: string): Promise<string> {
|
||||
const result = await this.relaysRepository.update(id, {
|
||||
status: 'rejected',
|
||||
});
|
||||
|
||||
return JSON.stringify(result);
|
||||
}
|
||||
|
||||
public async deliverToRelays(user: { id: User['id']; host: null; }, activity: any): Promise<void> {
|
||||
if (activity == null) return;
|
||||
|
||||
const relays = await this.#relaysCache.fetch(null, () => this.relaysRepository.findBy({
|
||||
status: 'accepted',
|
||||
}));
|
||||
if (relays.length === 0) return;
|
||||
|
||||
// TODO
|
||||
//const copy = structuredClone(activity);
|
||||
const copy = JSON.parse(JSON.stringify(activity));
|
||||
if (!copy.to) copy.to = ['https://www.w3.org/ns/activitystreams#Public'];
|
||||
|
||||
const signed = await this.apRendererService.attachLdSignature(copy, user);
|
||||
|
||||
for (const relay of relays) {
|
||||
this.queueService.deliver(user, signed, relay.inbox);
|
||||
}
|
||||
}
|
||||
}
|
38
packages/backend/src/core/S3Service.ts
Normal file
38
packages/backend/src/core/S3Service.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import { URL } from 'node:url';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import S3 from 'aws-sdk/clients/s3.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import type { Meta } from '@/models/entities/Meta.js';
|
||||
import { HttpRequestService } from './HttpRequestService.js';
|
||||
|
||||
@Injectable()
|
||||
export class S3Service {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private httpRequestService: HttpRequestService,
|
||||
) {
|
||||
}
|
||||
|
||||
public getS3(meta: Meta) {
|
||||
const u = meta.objectStorageEndpoint != null
|
||||
? `${meta.objectStorageUseSSL ? 'https://' : 'http://'}${meta.objectStorageEndpoint}`
|
||||
: `${meta.objectStorageUseSSL ? 'https://' : 'http://'}example.net`;
|
||||
|
||||
return new S3({
|
||||
endpoint: meta.objectStorageEndpoint ?? undefined,
|
||||
accessKeyId: meta.objectStorageAccessKey!,
|
||||
secretAccessKey: meta.objectStorageSecretKey!,
|
||||
region: meta.objectStorageRegion ?? undefined,
|
||||
sslEnabled: meta.objectStorageUseSSL,
|
||||
s3ForcePathStyle: !meta.objectStorageEndpoint // AWS with endPoint omitted
|
||||
? false
|
||||
: meta.objectStorageS3ForcePathStyle,
|
||||
httpOptions: {
|
||||
agent: this.httpRequestService.getAgentByUrl(new URL(u), !meta.objectStorageUseProxy),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
141
packages/backend/src/core/SignupService.ts
Normal file
141
packages/backend/src/core/SignupService.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { generateKeyPair } from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsedUsernamesRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { User } from '@/models/entities/User.js';
|
||||
import { UserProfile } from '@/models/entities/UserProfile.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { UsedUsername } from '@/models/entities/UsedUsername.js';
|
||||
import generateUserToken from '@/misc/generate-native-user-token.js';
|
||||
import UsersChart from './chart/charts/users.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { UtilityService } from './UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class SignupService {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.usedUsernamesRepository)
|
||||
private usedUsernamesRepository: UsedUsernamesRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private usersChart: UsersChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async signup(opts: {
|
||||
username: User['username'];
|
||||
password?: string | null;
|
||||
passwordHash?: UserProfile['password'] | null;
|
||||
host?: string | null;
|
||||
}) {
|
||||
const { username, password, passwordHash, host } = opts;
|
||||
let hash = passwordHash;
|
||||
|
||||
// Validate username
|
||||
if (!this.userEntityService.validateLocalUsername(username)) {
|
||||
throw new Error('INVALID_USERNAME');
|
||||
}
|
||||
|
||||
if (password != null && passwordHash == null) {
|
||||
// Validate password
|
||||
if (!this.userEntityService.validatePassword(password)) {
|
||||
throw new Error('INVALID_PASSWORD');
|
||||
}
|
||||
|
||||
// Generate hash of password
|
||||
const salt = await bcrypt.genSalt(8);
|
||||
hash = await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = generateUserToken();
|
||||
|
||||
// Check username duplication
|
||||
if (await this.usersRepository.findOneBy({ usernameLower: username.toLowerCase(), host: IsNull() })) {
|
||||
throw new Error('DUPLICATED_USERNAME');
|
||||
}
|
||||
|
||||
// Check deleted username duplication
|
||||
if (await this.usedUsernamesRepository.findOneBy({ username: username.toLowerCase() })) {
|
||||
throw new Error('USED_USERNAME');
|
||||
}
|
||||
|
||||
const keyPair = await new Promise<string[]>((res, rej) =>
|
||||
generateKeyPair('rsa', {
|
||||
modulusLength: 4096,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem',
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem',
|
||||
cipher: undefined,
|
||||
passphrase: undefined,
|
||||
},
|
||||
} as any, (err, publicKey, privateKey) =>
|
||||
err ? rej(err) : res([publicKey, privateKey]),
|
||||
));
|
||||
|
||||
let account!: User;
|
||||
|
||||
// Start transaction
|
||||
await this.db.transaction(async transactionalEntityManager => {
|
||||
const exist = await transactionalEntityManager.findOneBy(User, {
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: IsNull(),
|
||||
});
|
||||
|
||||
if (exist) throw new Error(' the username is already used');
|
||||
|
||||
account = await transactionalEntityManager.save(new User({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
username: username,
|
||||
usernameLower: username.toLowerCase(),
|
||||
host: this.utilityService.toPunyNullable(host),
|
||||
token: secret,
|
||||
isAdmin: (await this.usersRepository.countBy({
|
||||
host: IsNull(),
|
||||
})) === 0,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new UserKeypair({
|
||||
publicKey: keyPair[0],
|
||||
privateKey: keyPair[1],
|
||||
userId: account.id,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new UserProfile({
|
||||
userId: account.id,
|
||||
autoAcceptFollowed: true,
|
||||
password: hash,
|
||||
}));
|
||||
|
||||
await transactionalEntityManager.save(new UsedUsername({
|
||||
createdAt: new Date(),
|
||||
username: username.toLowerCase(),
|
||||
}));
|
||||
});
|
||||
|
||||
this.usersChart.update(account, true);
|
||||
|
||||
return { account, secret };
|
||||
}
|
||||
}
|
||||
|
439
packages/backend/src/core/TwoFactorAuthenticationService.ts
Normal file
439
packages/backend/src/core/TwoFactorAuthenticationService.ts
Normal file
@ -0,0 +1,439 @@
|
||||
import * as crypto from 'node:crypto';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as jsrsasign from 'jsrsasign';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
const ECC_PRELUDE = Buffer.from([0x04]);
|
||||
const NULL_BYTE = Buffer.from([0]);
|
||||
const PEM_PRELUDE = Buffer.from(
|
||||
'3059301306072a8648ce3d020106082a8648ce3d030107034200',
|
||||
'hex',
|
||||
);
|
||||
|
||||
// Android Safetynet attestations are signed with this cert:
|
||||
const GSR2 = `-----BEGIN CERTIFICATE-----
|
||||
MIIDujCCAqKgAwIBAgILBAAAAAABD4Ym5g0wDQYJKoZIhvcNAQEFBQAwTDEgMB4G
|
||||
A1UECxMXR2xvYmFsU2lnbiBSb290IENBIC0gUjIxEzARBgNVBAoTCkdsb2JhbFNp
|
||||
Z24xEzARBgNVBAMTCkdsb2JhbFNpZ24wHhcNMDYxMjE1MDgwMDAwWhcNMjExMjE1
|
||||
MDgwMDAwWjBMMSAwHgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEG
|
||||
A1UEChMKR2xvYmFsU2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjCCASIwDQYJKoZI
|
||||
hvcNAQEBBQADggEPADCCAQoCggEBAKbPJA6+Lm8omUVCxKs+IVSbC9N/hHD6ErPL
|
||||
v4dfxn+G07IwXNb9rfF73OX4YJYJkhD10FPe+3t+c4isUoh7SqbKSaZeqKeMWhG8
|
||||
eoLrvozps6yWJQeXSpkqBy+0Hne/ig+1AnwblrjFuTosvNYSuetZfeLQBoZfXklq
|
||||
tTleiDTsvHgMCJiEbKjNS7SgfQx5TfC4LcshytVsW33hoCmEofnTlEnLJGKRILzd
|
||||
C9XZzPnqJworc5HGnRusyMvo4KD0L5CLTfuwNhv2GXqF4G3yYROIXJ/gkwpRl4pa
|
||||
zq+r1feqCapgvdzZX99yqWATXgAByUr6P6TqBwMhAo6CygPCm48CAwEAAaOBnDCB
|
||||
mTAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUm+IH
|
||||
V2ccHsBqBt5ZtJot39wZhi4wNgYDVR0fBC8wLTAroCmgJ4YlaHR0cDovL2NybC5n
|
||||
bG9iYWxzaWduLm5ldC9yb290LXIyLmNybDAfBgNVHSMEGDAWgBSb4gdXZxwewGoG
|
||||
3lm0mi3f3BmGLjANBgkqhkiG9w0BAQUFAAOCAQEAmYFThxxol4aR7OBKuEQLq4Gs
|
||||
J0/WwbgcQ3izDJr86iw8bmEbTUsp9Z8FHSbBuOmDAGJFtqkIk7mpM0sYmsL4h4hO
|
||||
291xNBrBVNpGP+DTKqttVCL1OmLNIG+6KYnX3ZHu01yiPqFbQfXf5WRDLenVOavS
|
||||
ot+3i9DAgBkcRcAtjOj4LaR0VknFBbVPFd5uRHg5h6h+u/N5GJG79G+dwfCMNYxd
|
||||
AfvDbbnvRG15RjF+Cv6pgsH/76tuIMRQyV+dTZsXjAzlAcmgQWpzU/qlULRuJQ/7
|
||||
TBj0/VLZjmmx6BEP3ojY+x1J96relc8geMJgEtslQIxq/H5COEBkEveegeGTLg==
|
||||
-----END CERTIFICATE-----\n`;
|
||||
|
||||
function base64URLDecode(source: string) {
|
||||
return Buffer.from(source.replace(/\-/g, '+').replace(/_/g, '/'), 'base64');
|
||||
}
|
||||
|
||||
function getCertSubject(certificate: string) {
|
||||
const subjectCert = new jsrsasign.X509();
|
||||
subjectCert.readCertPEM(certificate);
|
||||
|
||||
const subjectString = subjectCert.getSubjectString();
|
||||
const subjectFields = subjectString.slice(1).split('/');
|
||||
|
||||
const fields = {} as Record<string, string>;
|
||||
for (const field of subjectFields) {
|
||||
const eqIndex = field.indexOf('=');
|
||||
fields[field.substring(0, eqIndex)] = field.substring(eqIndex + 1);
|
||||
}
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
function verifyCertificateChain(certificates: string[]) {
|
||||
let valid = true;
|
||||
|
||||
for (let i = 0; i < certificates.length; i++) {
|
||||
const Cert = certificates[i];
|
||||
const certificate = new jsrsasign.X509();
|
||||
certificate.readCertPEM(Cert);
|
||||
|
||||
const CACert = i + 1 >= certificates.length ? Cert : certificates[i + 1];
|
||||
|
||||
const certStruct = jsrsasign.ASN1HEX.getTLVbyList(certificate.hex!, 0, [0]);
|
||||
const algorithm = certificate.getSignatureAlgorithmField();
|
||||
const signatureHex = certificate.getSignatureValueHex();
|
||||
|
||||
// Verify against CA
|
||||
const Signature = new jsrsasign.KJUR.crypto.Signature({ alg: algorithm });
|
||||
Signature.init(CACert);
|
||||
Signature.updateHex(certStruct);
|
||||
valid = valid && !!Signature.verify(signatureHex); // true if CA signed the certificate
|
||||
}
|
||||
|
||||
return valid;
|
||||
}
|
||||
|
||||
function PEMString(pemBuffer: Buffer, type = 'CERTIFICATE') {
|
||||
if (pemBuffer.length === 65 && pemBuffer[0] === 0x04) {
|
||||
pemBuffer = Buffer.concat([PEM_PRELUDE, pemBuffer], 91);
|
||||
type = 'PUBLIC KEY';
|
||||
}
|
||||
const cert = pemBuffer.toString('base64');
|
||||
|
||||
const keyParts = [];
|
||||
const max = Math.ceil(cert.length / 64);
|
||||
let start = 0;
|
||||
for (let i = 0; i < max; i++) {
|
||||
keyParts.push(cert.substring(start, start + 64));
|
||||
start += 64;
|
||||
}
|
||||
|
||||
return (
|
||||
`-----BEGIN ${type}-----\n` +
|
||||
keyParts.join('\n') +
|
||||
`\n-----END ${type}-----\n`
|
||||
);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TwoFactorAuthenticationService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
) {
|
||||
}
|
||||
|
||||
public hash(data: Buffer) {
|
||||
return crypto
|
||||
.createHash('sha256')
|
||||
.update(data)
|
||||
.digest();
|
||||
}
|
||||
|
||||
public verifySignin({
|
||||
publicKey,
|
||||
authenticatorData,
|
||||
clientDataJSON,
|
||||
clientData,
|
||||
signature,
|
||||
challenge,
|
||||
}: {
|
||||
publicKey: Buffer,
|
||||
authenticatorData: Buffer,
|
||||
clientDataJSON: Buffer,
|
||||
clientData: any,
|
||||
signature: Buffer,
|
||||
challenge: string
|
||||
}) {
|
||||
if (clientData.type !== 'webauthn.get') {
|
||||
throw new Error('type is not webauthn.get');
|
||||
}
|
||||
|
||||
if (this.hash(clientData.challenge).toString('hex') !== challenge) {
|
||||
throw new Error('challenge mismatch');
|
||||
}
|
||||
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
|
||||
throw new Error('origin mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat(
|
||||
[authenticatorData, this.hash(clientDataJSON)],
|
||||
32 + authenticatorData.length,
|
||||
);
|
||||
|
||||
return crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(publicKey), signature);
|
||||
}
|
||||
|
||||
public getProcedures() {
|
||||
return {
|
||||
none: {
|
||||
verify({ publicKey }: { publicKey: Map<number, Buffer> }) {
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
return {
|
||||
publicKey: publicKeyU2F,
|
||||
valid: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
'android-key': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
if (attStmt.alg !== -7) {
|
||||
throw new Error('alg mismatch');
|
||||
}
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
]);
|
||||
|
||||
const attCert: Buffer = attStmt.x5c[0];
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
if (!attCert.equals(publicKeyData)) {
|
||||
throw new Error('public key mismatch');
|
||||
}
|
||||
|
||||
const isValid = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
// TODO: Check 'attestationChallenge' field in extension of cert matches hash(clientDataJSON)
|
||||
|
||||
return {
|
||||
valid: isValid,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
},
|
||||
},
|
||||
// what a stupid attestation
|
||||
'android-safetynet': {
|
||||
verify: ({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) => {
|
||||
const verificationData = this.hash(
|
||||
Buffer.concat([authenticatorData, clientDataHash]),
|
||||
);
|
||||
|
||||
const jwsParts = attStmt.response.toString('utf-8').split('.');
|
||||
|
||||
const header = JSON.parse(base64URLDecode(jwsParts[0]).toString('utf-8'));
|
||||
const response = JSON.parse(
|
||||
base64URLDecode(jwsParts[1]).toString('utf-8'),
|
||||
);
|
||||
const signature = jwsParts[2];
|
||||
|
||||
if (!verificationData.equals(Buffer.from(response.nonce, 'base64'))) {
|
||||
throw new Error('invalid nonce');
|
||||
}
|
||||
|
||||
const certificateChain = header.x5c
|
||||
.map((key: any) => PEMString(key))
|
||||
.concat([GSR2]);
|
||||
|
||||
if (getCertSubject(certificateChain[0]).CN !== 'attest.android.com') {
|
||||
throw new Error('invalid common name');
|
||||
}
|
||||
|
||||
if (!verifyCertificateChain(certificateChain)) {
|
||||
throw new Error('Invalid certificate chain!');
|
||||
}
|
||||
|
||||
const signatureBase = Buffer.from(
|
||||
jwsParts[0] + '.' + jwsParts[1],
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const valid = crypto
|
||||
.createVerify('sha256')
|
||||
.update(signatureBase)
|
||||
.verify(certificateChain[0], base64URLDecode(signature));
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
return {
|
||||
valid,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
},
|
||||
},
|
||||
packed: {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>;
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer,
|
||||
}) {
|
||||
const verificationData = Buffer.concat([
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
]);
|
||||
|
||||
if (attStmt.x5c) {
|
||||
const attCert = attStmt.x5c[0];
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
const negTwo = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyData = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyData,
|
||||
};
|
||||
} else if (attStmt.ecdaaKeyId) {
|
||||
// https://fidoalliance.org/specs/fido-v2.0-id-20180227/fido-ecdaa-algorithm-v2.0-id-20180227.html#ecdaa-verify-operation
|
||||
throw new Error('ECDAA-Verify is not supported');
|
||||
} else {
|
||||
if (attStmt.alg !== -7) throw new Error('alg mismatch');
|
||||
|
||||
throw new Error('self attestation is not supported');
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'fido-u2f': {
|
||||
verify({
|
||||
attStmt,
|
||||
authenticatorData,
|
||||
clientDataHash,
|
||||
publicKey,
|
||||
rpIdHash,
|
||||
credentialId,
|
||||
}: {
|
||||
attStmt: any,
|
||||
authenticatorData: Buffer,
|
||||
clientDataHash: Buffer,
|
||||
publicKey: Map<number, any>,
|
||||
rpIdHash: Buffer,
|
||||
credentialId: Buffer
|
||||
}) {
|
||||
const x5c: Buffer[] = attStmt.x5c;
|
||||
if (x5c.length !== 1) {
|
||||
throw new Error('x5c length does not match expectation');
|
||||
}
|
||||
|
||||
const attCert = x5c[0];
|
||||
|
||||
// TODO: make sure attCert is an Elliptic Curve (EC) public key over the P-256 curve
|
||||
|
||||
const negTwo: Buffer = publicKey.get(-2);
|
||||
|
||||
if (!negTwo || negTwo.length !== 32) {
|
||||
throw new Error('invalid or no -2 key given');
|
||||
}
|
||||
const negThree: Buffer = publicKey.get(-3);
|
||||
if (!negThree || negThree.length !== 32) {
|
||||
throw new Error('invalid or no -3 key given');
|
||||
}
|
||||
|
||||
const publicKeyU2F = Buffer.concat(
|
||||
[ECC_PRELUDE, negTwo, negThree],
|
||||
1 + 32 + 32,
|
||||
);
|
||||
|
||||
const verificationData = Buffer.concat([
|
||||
NULL_BYTE,
|
||||
rpIdHash,
|
||||
clientDataHash,
|
||||
credentialId,
|
||||
publicKeyU2F,
|
||||
]);
|
||||
|
||||
const validSignature = crypto
|
||||
.createVerify('SHA256')
|
||||
.update(verificationData)
|
||||
.verify(PEMString(attCert), attStmt.sig);
|
||||
|
||||
return {
|
||||
valid: validSignature,
|
||||
publicKey: publicKeyU2F,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
199
packages/backend/src/core/UserBlockingService.ts
Normal file
199
packages/backend/src/core/UserBlockingService.ts
Normal file
@ -0,0 +1,199 @@
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import type { CacheableUser, User } from '@/models/entities/User.js';
|
||||
import type { Blocking } from '@/models/entities/Blocking.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { WebhookService } from './WebhookService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserBlockingService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.userListsRepository)
|
||||
private userListsRepository: UserListsRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private webhookService: WebhookService,
|
||||
private apRendererService: ApRendererService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async block(blocker: User, blockee: User) {
|
||||
await Promise.all([
|
||||
this.#cancelRequest(blocker, blockee),
|
||||
this.#cancelRequest(blockee, blocker),
|
||||
this.#unFollow(blocker, blockee),
|
||||
this.#unFollow(blockee, blocker),
|
||||
this.#removeFromList(blockee, blocker),
|
||||
]);
|
||||
|
||||
const blocking = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
blocker,
|
||||
blockerId: blocker.id,
|
||||
blockee,
|
||||
blockeeId: blockee.id,
|
||||
} as Blocking;
|
||||
|
||||
await this.blockingsRepository.insert(blocking);
|
||||
|
||||
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderBlock(blocking));
|
||||
this.queueService.deliver(blocker, content, blockee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
async #cancelRequest(follower: User, followee: User) {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (request == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(followee, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// リモートにフォローリクエストをしていたらUndoFollow送信
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
|
||||
// リモートからフォローリクエストを受けていたらReject送信
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
async #unFollow(follower: User, followee: User) {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
|
||||
if (following == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
this.followingsRepository.delete(following.id),
|
||||
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
|
||||
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
|
||||
this.perUserFollowingChart.update(follower, followee, false),
|
||||
]);
|
||||
|
||||
// Publish unfollow event
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// リモートにフォローをしていたらUndoFollow送信
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
async #removeFromList(listOwner: User, user: User) {
|
||||
const userLists = await this.userListsRepository.findBy({
|
||||
userId: listOwner.id,
|
||||
});
|
||||
|
||||
for (const userList of userLists) {
|
||||
await this.userListJoiningsRepository.delete({
|
||||
userListId: userList.id,
|
||||
userId: user.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async unblock(blocker: CacheableUser, blockee: CacheableUser) {
|
||||
const blocking = await this.blockingsRepository.findOneBy({
|
||||
blockerId: blocker.id,
|
||||
blockeeId: blockee.id,
|
||||
});
|
||||
|
||||
if (blocking == null) {
|
||||
logger.warn('ブロック解除がリクエストされましたがブロックしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
// Since we already have the blocker and blockee, we do not need to fetch
|
||||
// them in the query above and can just manually insert them here.
|
||||
blocking.blocker = blocker;
|
||||
blocking.blockee = blockee;
|
||||
|
||||
await this.blockingsRepository.delete(blocking.id);
|
||||
|
||||
// deliver if remote bloking
|
||||
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker));
|
||||
this.queueService.deliver(blocker, content, blockee.inbox);
|
||||
}
|
||||
}
|
||||
}
|
74
packages/backend/src/core/UserCacheService.ts
Normal file
74
packages/backend/src/core/UserCacheService.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { UsersRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { CacheableLocalUser, CacheableUser, ILocalUser } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class UserCacheService implements OnApplicationShutdown {
|
||||
public userByIdCache: Cache<CacheableUser>;
|
||||
public localUserByNativeTokenCache: Cache<CacheableLocalUser | null>;
|
||||
public localUserByIdCache: Cache<CacheableLocalUser>;
|
||||
public uriPersonCache: Cache<CacheableUser | null>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
|
||||
this.userByIdCache = new Cache<CacheableUser>(Infinity);
|
||||
this.localUserByNativeTokenCache = new Cache<CacheableLocalUser | null>(Infinity);
|
||||
this.localUserByIdCache = new Cache<CacheableLocalUser>(Infinity);
|
||||
this.uriPersonCache = new Cache<CacheableUser | null>(Infinity);
|
||||
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
private async onMessage(_, data) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message;
|
||||
switch (type) {
|
||||
case 'userChangeSuspendedState':
|
||||
case 'userChangeSilencedState':
|
||||
case 'userChangeModeratorState':
|
||||
case 'remoteUserUpdated': {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: body.id });
|
||||
this.userByIdCache.set(user.id, user);
|
||||
for (const [k, v] of this.uriPersonCache.cache.entries()) {
|
||||
if (v.value?.id === user.id) {
|
||||
this.uriPersonCache.set(k, user);
|
||||
}
|
||||
}
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
this.localUserByNativeTokenCache.set(user.token, user);
|
||||
this.localUserByIdCache.set(user.id, user);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'userTokenRegenerated': {
|
||||
const user = await this.usersRepository.findOneByOrFail({ id: body.id }) as ILocalUser;
|
||||
this.localUserByNativeTokenCache.delete(body.oldToken);
|
||||
this.localUserByNativeTokenCache.set(body.newToken, user);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
}
|
||||
}
|
574
packages/backend/src/core/UserFollowingService.ts
Normal file
574
packages/backend/src/core/UserFollowingService.ts
Normal file
@ -0,0 +1,574 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { CacheableUser, ILocalUser, IRemoteUser, User } from '@/models/entities/User.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { isDuplicateKeyValueError } from '@/misc/is-duplicate-key-value-error.js';
|
||||
import type { Packed } from '@/misc/schema.js';
|
||||
import InstanceChart from '@/core/chart/charts/instance.js';
|
||||
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
|
||||
import { WebhookService } from '@/core/WebhookService.js';
|
||||
import { CreateNotificationService } from '@/core/CreateNotificationService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Logger from '../logger.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
|
||||
const logger = new Logger('following/create');
|
||||
|
||||
type Local = ILocalUser | {
|
||||
id: ILocalUser['id'];
|
||||
host: ILocalUser['host'];
|
||||
uri: ILocalUser['uri']
|
||||
};
|
||||
type Remote = IRemoteUser | {
|
||||
id: IRemoteUser['id'];
|
||||
host: IRemoteUser['host'];
|
||||
uri: IRemoteUser['uri'];
|
||||
inbox: IRemoteUser['inbox'];
|
||||
};
|
||||
type Both = Local | Remote;
|
||||
|
||||
@Injectable()
|
||||
export class UserFollowingService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.followRequestsRepository)
|
||||
private followRequestsRepository: FollowRequestsRepository,
|
||||
|
||||
@Inject(DI.blockingsRepository)
|
||||
private blockingsRepository: BlockingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private createNotificationService: CreateNotificationService,
|
||||
private federatedInstanceService: FederatedInstanceService,
|
||||
private webhookService: WebhookService,
|
||||
private apRendererService: ApRendererService,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private instanceChart: InstanceChart,
|
||||
) {
|
||||
}
|
||||
|
||||
public async follow(_follower: { id: User['id'] }, _followee: { id: User['id'] }, requestId?: string): Promise<void> {
|
||||
const [follower, followee] = await Promise.all([
|
||||
this.usersRepository.findOneByOrFail({ id: _follower.id }),
|
||||
this.usersRepository.findOneByOrFail({ id: _followee.id }),
|
||||
]);
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: follower.id,
|
||||
blockeeId: followee.id,
|
||||
}),
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: followee.id,
|
||||
blockeeId: follower.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocked) {
|
||||
// リモートフォローを受けてブロックしていた場合は、エラーにするのではなくRejectを送り返しておしまい。
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
return;
|
||||
} else if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee) && blocking) {
|
||||
// リモートフォローを受けてブロックされているはずの場合だったら、ブロック解除しておく。
|
||||
await this.blockingsRepository.delete(blocking.id);
|
||||
} else {
|
||||
// それ以外は単純に例外
|
||||
if (blocking != null) throw new IdentifiableError('710e8fb0-b8c3-4922-be49-d5d93d8e6a6e', 'blocking');
|
||||
if (blocked != null) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||
}
|
||||
|
||||
const followeeProfile = await this.userProfilesRepository.findOneByOrFail({ userId: followee.id });
|
||||
|
||||
// フォロー対象が鍵アカウントである or
|
||||
// フォロワーがBotであり、フォロー対象がBotからのフォローに慎重である or
|
||||
// フォロワーがローカルユーザーであり、フォロー対象がリモートユーザーである
|
||||
// 上記のいずれかに当てはまる場合はすぐフォローせずにフォローリクエストを発行しておく
|
||||
if (followee.isLocked || (followeeProfile.carefulBot && follower.isBot) || (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee))) {
|
||||
let autoAccept = false;
|
||||
|
||||
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
if (following) {
|
||||
autoAccept = true;
|
||||
}
|
||||
|
||||
// フォローしているユーザーは自動承認オプション
|
||||
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
|
||||
const followed = await this.followingsRepository.findOneBy({
|
||||
followerId: followee.id,
|
||||
followeeId: follower.id,
|
||||
});
|
||||
|
||||
if (followed) autoAccept = true;
|
||||
}
|
||||
|
||||
if (!autoAccept) {
|
||||
await this.createFollowRequest(follower, followee, requestId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await this.#insertFollowingDoc(followee, follower);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, requestId), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
async #insertFollowingDoc(
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
|
||||
},
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox']
|
||||
},
|
||||
): Promise<void> {
|
||||
if (follower.id === followee.id) return;
|
||||
|
||||
let alreadyFollowed = false as boolean;
|
||||
|
||||
await this.followingsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
|
||||
// 非正規化
|
||||
followerHost: follower.host,
|
||||
followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : null,
|
||||
followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : null,
|
||||
followeeHost: followee.host,
|
||||
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : null,
|
||||
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : null,
|
||||
}).catch(err => {
|
||||
if (isDuplicateKeyValueError(err) && this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
logger.info(`Insert duplicated ignore. ${follower.id} => ${followee.id}`);
|
||||
alreadyFollowed = true;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
const req = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (req) {
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
// 通知を作成
|
||||
this.createNotificationService.createNotification(follower.id, 'followRequestAccepted', {
|
||||
notifierId: followee.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (alreadyFollowed) return;
|
||||
|
||||
//#region Increment counts
|
||||
await Promise.all([
|
||||
this.usersRepository.increment({ id: follower.id }, 'followingCount', 1),
|
||||
this.usersRepository.increment({ id: followee.id }, 'followersCount', 1),
|
||||
]);
|
||||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followingCount', 1);
|
||||
this.instanceChart.updateFollowing(i.host, true);
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
|
||||
this.instancesRepository.increment({ id: i.id }, 'followersCount', 1);
|
||||
this.instanceChart.updateFollowers(i.host, true);
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, true);
|
||||
|
||||
// Publish follow event
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'follow', packed as Packed<'UserDetailedNotMe'>);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('follow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'follow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Publish followed event
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(follower.id, followee).then(async packed => {
|
||||
this.globalEventServie.publishMainStream(followee.id, 'followed', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === followee.id && x.on.includes('followed'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'followed', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 通知を作成
|
||||
this.createNotificationService.createNotification(followee.id, 'follow', {
|
||||
notifierId: follower.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public async unfollow(
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
silent = false,
|
||||
): Promise<void> {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
|
||||
if (following == null) {
|
||||
logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
|
||||
this.#decrementFollowing(follower, followee);
|
||||
|
||||
// Publish unfollow event
|
||||
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
}).then(async packed => {
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packed);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packed,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(followee) && this.userEntityService.isRemoteUser(follower)) {
|
||||
// local user has null host
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
async #decrementFollowing(
|
||||
follower: {id: User['id']; host: User['host']; },
|
||||
followee: { id: User['id']; host: User['host']; },
|
||||
): Promise<void> {
|
||||
//#region Decrement following / followers counts
|
||||
await Promise.all([
|
||||
this.usersRepository.decrement({ id: follower.id }, 'followingCount', 1),
|
||||
this.usersRepository.decrement({ id: followee.id }, 'followersCount', 1),
|
||||
]);
|
||||
//#endregion
|
||||
|
||||
//#region Update instance stats
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(follower.host).then(i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followingCount', 1);
|
||||
this.instanceChart.updateFollowing(i.host, false);
|
||||
});
|
||||
} else if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
this.federatedInstanceService.registerOrFetchInstanceDoc(followee.host).then(i => {
|
||||
this.instancesRepository.decrement({ id: i.id }, 'followersCount', 1);
|
||||
this.instanceChart.updateFollowers(i.host, false);
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||
|
||||
this.perUserFollowingChart.update(follower, followee, false);
|
||||
}
|
||||
|
||||
public async createFollowRequest(
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
requestId?: string,
|
||||
): Promise<void> {
|
||||
if (follower.id === followee.id) return;
|
||||
|
||||
// check blocking
|
||||
const [blocking, blocked] = await Promise.all([
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: follower.id,
|
||||
blockeeId: followee.id,
|
||||
}),
|
||||
this.blockingsRepository.findOneBy({
|
||||
blockerId: followee.id,
|
||||
blockeeId: follower.id,
|
||||
}),
|
||||
]);
|
||||
|
||||
if (blocking != null) throw new Error('blocking');
|
||||
if (blocked != null) throw new Error('blocked');
|
||||
|
||||
const followRequest = await this.followRequestsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
requestId,
|
||||
|
||||
// 非正規化
|
||||
followerHost: follower.host,
|
||||
followerInbox: this.userEntityService.isRemoteUser(follower) ? follower.inbox : undefined,
|
||||
followerSharedInbox: this.userEntityService.isRemoteUser(follower) ? follower.sharedInbox : undefined,
|
||||
followeeHost: followee.host,
|
||||
followeeInbox: this.userEntityService.isRemoteUser(followee) ? followee.inbox : undefined,
|
||||
followeeSharedInbox: this.userEntityService.isRemoteUser(followee) ? followee.sharedInbox : undefined,
|
||||
}).then(x => this.followRequestsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
|
||||
// Publish receiveRequest event
|
||||
if (this.userEntityService.isLocalUser(followee)) {
|
||||
this.userEntityService.pack(follower.id, followee).then(packed => this.globalEventServie.publishMainStream(followee.id, 'receiveFollowRequest', packed));
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
|
||||
// 通知を作成
|
||||
this.createNotificationService.createNotification(followee.id, 'receiveFollowRequest', {
|
||||
notifierId: follower.id,
|
||||
followRequestId: followRequest.id,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderFollow(follower, followee));
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
public async cancelFollowRequest(
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']
|
||||
},
|
||||
follower: {
|
||||
id: User['id']; host: User['host']; uri: User['host']
|
||||
},
|
||||
): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderFollow(follower, followee), follower));
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) { // 本来このチェックは不要だけどTSに怒られるので
|
||||
this.queueService.deliver(follower, content, followee.inbox);
|
||||
}
|
||||
}
|
||||
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (request == null) {
|
||||
throw new IdentifiableError('17447091-ce07-46dd-b331-c1fd4f15b1e7', 'request not found');
|
||||
}
|
||||
|
||||
await this.followRequestsRepository.delete({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
public async acceptFollowRequest(
|
||||
followee: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
follower: CacheableUser,
|
||||
): Promise<void> {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (request == null) {
|
||||
throw new IdentifiableError('8884c2dd-5795-4ac9-b27e-6a01d38190f9', 'No follow request.');
|
||||
}
|
||||
|
||||
await this.#insertFollowingDoc(followee, follower);
|
||||
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderAccept(this.apRendererService.renderFollow(follower, followee, request.requestId!), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
|
||||
this.userEntityService.pack(followee.id, followee, {
|
||||
detail: true,
|
||||
}).then(packed => this.globalEventServie.publishMainStream(followee.id, 'meUpdated', packed));
|
||||
}
|
||||
|
||||
public async acceptAllFollowRequests(
|
||||
user: {
|
||||
id: User['id']; host: User['host']; uri: User['host']; inbox: User['inbox']; sharedInbox: User['sharedInbox'];
|
||||
},
|
||||
): Promise<void> {
|
||||
const requests = await this.followRequestsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
});
|
||||
|
||||
for (const request of requests) {
|
||||
const follower = await this.usersRepository.findOneByOrFail({ id: request.followerId });
|
||||
this.acceptFollowRequest(user, follower);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API following/request/reject
|
||||
*/
|
||||
public async rejectFollowRequest(user: Local, follower: Both): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(follower)) {
|
||||
this.#deliverReject(user, follower);
|
||||
}
|
||||
|
||||
await this.#removeFollowRequest(user, follower);
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.#publishUnfollow(user, follower);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* API following/reject
|
||||
*/
|
||||
public async rejectFollow(user: Local, follower: Both): Promise<void> {
|
||||
if (this.userEntityService.isRemoteUser(follower)) {
|
||||
this.#deliverReject(user, follower);
|
||||
}
|
||||
|
||||
await this.#removeFollow(user, follower);
|
||||
|
||||
if (this.userEntityService.isLocalUser(follower)) {
|
||||
this.#publishUnfollow(user, follower);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AP Reject/Follow
|
||||
*/
|
||||
public async remoteReject(actor: Remote, follower: Local): Promise<void> {
|
||||
await this.#removeFollowRequest(actor, follower);
|
||||
await this.#removeFollow(actor, follower);
|
||||
this.#publishUnfollow(actor, follower);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove follow request record
|
||||
*/
|
||||
async #removeFollowRequest(followee: Both, follower: Both): Promise<void> {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (!request) return;
|
||||
|
||||
await this.followRequestsRepository.delete(request.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove follow record
|
||||
*/
|
||||
async #removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||
const following = await this.followingsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
if (!following) return;
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
this.#decrementFollowing(follower, followee);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver Reject to remote
|
||||
*/
|
||||
async #deliverReject(followee: Local, follower: Remote): Promise<void> {
|
||||
const request = await this.followRequestsRepository.findOneBy({
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
});
|
||||
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderReject(this.apRendererService.renderFollow(follower, followee, request?.requestId ?? undefined), followee));
|
||||
this.queueService.deliver(followee, content, follower.inbox);
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish unfollow to local
|
||||
*/
|
||||
async #publishUnfollow(followee: Both, follower: Local): Promise<void> {
|
||||
const packedFollowee = await this.userEntityService.pack(followee.id, follower, {
|
||||
detail: true,
|
||||
});
|
||||
|
||||
this.globalEventServie.publishUserEvent(follower.id, 'unfollow', packedFollowee);
|
||||
this.globalEventServie.publishMainStream(follower.id, 'unfollow', packedFollowee);
|
||||
|
||||
const webhooks = (await this.webhookService.getActiveWebhooks()).filter(x => x.userId === follower.id && x.on.includes('unfollow'));
|
||||
for (const webhook of webhooks) {
|
||||
this.queueService.webhookDeliver(webhook, 'unfollow', {
|
||||
user: packedFollowee,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
22
packages/backend/src/core/UserKeypairStoreService.ts
Normal file
22
packages/backend/src/core/UserKeypairStoreService.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { UserKeypairsRepository } from '@/models/index.js';
|
||||
import { Cache } from '@/misc/cache.js';
|
||||
import type { UserKeypair } from '@/models/entities/UserKeypair.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserKeypairStoreService {
|
||||
#cache: Cache<UserKeypair>;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.userKeypairsRepository)
|
||||
private userKeypairsRepository: UserKeypairsRepository,
|
||||
) {
|
||||
this.#cache = new Cache<UserKeypair>(Infinity);
|
||||
}
|
||||
|
||||
public async getUserKeypair(userId: User['id']): Promise<UserKeypair> {
|
||||
return await this.#cache.fetch(userId, () => this.userKeypairsRepository.findOneByOrFail({ userId: userId }));
|
||||
}
|
||||
}
|
48
packages/backend/src/core/UserListService.ts
Normal file
48
packages/backend/src/core/UserListService.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UserListJoiningsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { UserList } from '@/models/entities/UserList.js';
|
||||
import type { UserListJoining } from '@/models/entities/UserListJoining.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
import { ProxyAccountService } from './ProxyAccountService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserListService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userListJoiningsRepository)
|
||||
private userListJoiningsRepository: UserListJoiningsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private idService: IdService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
private proxyAccountService: ProxyAccountService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async push(target: User, list: UserList) {
|
||||
await this.userListJoiningsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
userId: target.id,
|
||||
userListId: list.id,
|
||||
} as UserListJoining);
|
||||
|
||||
this.globalEventServie.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
|
||||
|
||||
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
|
||||
if (this.userEntityService.isRemoteUser(target)) {
|
||||
const proxy = await this.proxyAccountService.fetch();
|
||||
if (proxy) {
|
||||
this.userFollowingService.follow(proxy, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
32
packages/backend/src/core/UserMutingService.ts
Normal file
32
packages/backend/src/core/UserMutingService.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { UsersRepository, MutingsRepository } from '@/models/index.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserMutingService {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private queueService: QueueService,
|
||||
private globalEventServie: GlobalEventService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async mute(user: User, target: User): Promise<void> {
|
||||
await this.mutingsRepository.insert({
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
muterId: user.id,
|
||||
muteeId: target.id,
|
||||
});
|
||||
}
|
||||
}
|
88
packages/backend/src/core/UserSuspendService.ts
Normal file
88
packages/backend/src/core/UserSuspendService.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { FollowingsRepository, UsersRepository } from '@/models/index.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { ApRendererService } from './remote/activitypub/ApRendererService.js';
|
||||
import { UserEntityService } from './entities/UserEntityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async doPostSuspend(user: { id: User['id']; host: User['host'] }): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにDelete配信
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user, content, inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async doPostUnsuspend(user: User): Promise<void> {
|
||||
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: false });
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
// 知り得る全SharedInboxにUndo Delete配信
|
||||
const content = this.apRendererService.renderActivity(this.apRendererService.renderUndo(this.apRendererService.renderDelete(`${this.config.url}/users/${user.id}`, user), user));
|
||||
|
||||
const queue: string[] = [];
|
||||
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: [
|
||||
{ followerSharedInbox: Not(IsNull()) },
|
||||
{ followeeSharedInbox: Not(IsNull()) },
|
||||
],
|
||||
select: ['followerSharedInbox', 'followeeSharedInbox'],
|
||||
});
|
||||
|
||||
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
|
||||
|
||||
for (const inbox of inboxes) {
|
||||
if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
|
||||
}
|
||||
|
||||
for (const inbox of queue) {
|
||||
this.queueService.deliver(user as any, content, inbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
37
packages/backend/src/core/UtilityService.ts
Normal file
37
packages/backend/src/core/UtilityService.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { URL } from 'node:url';
|
||||
import { toASCII } from 'punycode';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
) {
|
||||
}
|
||||
|
||||
public getFullApAccount(username: string, host: string | null): string {
|
||||
return host ? `${username}@${this.toPuny(host)}` : `${username}@${this.toPuny(this.config.host)}`;
|
||||
}
|
||||
|
||||
public isSelfHost(host: string | null): boolean {
|
||||
if (host == null) return true;
|
||||
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||
}
|
||||
|
||||
public extractDbHost(uri: string): string {
|
||||
const url = new URL(uri);
|
||||
return this.toPuny(url.hostname);
|
||||
}
|
||||
|
||||
public toPuny(host: string): string {
|
||||
return toASCII(host.toLowerCase());
|
||||
}
|
||||
|
||||
public toPunyNullable(host: string | null | undefined): string | null {
|
||||
if (host == null) return null;
|
||||
return toASCII(host.toLowerCase());
|
||||
}
|
||||
}
|
44
packages/backend/src/core/VideoProcessingService.ts
Normal file
44
packages/backend/src/core/VideoProcessingService.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import FFmpeg from 'fluent-ffmpeg';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { Config } from '@/config.js';
|
||||
import { ImageProcessingService } from '@/core/ImageProcessingService.js';
|
||||
import type { IImage } from '@/core/ImageProcessingService.js';
|
||||
import { createTempDir } from '@/misc/create-temp.js';
|
||||
|
||||
@Injectable()
|
||||
export class VideoProcessingService {
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
private imageProcessingService: ImageProcessingService,
|
||||
) {
|
||||
}
|
||||
|
||||
public async generateVideoThumbnail(source: string): Promise<IImage> {
|
||||
const [dir, cleanup] = await createTempDir();
|
||||
|
||||
try {
|
||||
await new Promise((res, rej) => {
|
||||
FFmpeg({
|
||||
source,
|
||||
})
|
||||
.on('end', res)
|
||||
.on('error', rej)
|
||||
.screenshot({
|
||||
folder: dir,
|
||||
filename: 'out.png', // must have .png extension
|
||||
count: 1,
|
||||
timestamps: ['5%'],
|
||||
});
|
||||
});
|
||||
|
||||
// JPEGに変換 (Webpでもいいが、MastodonはWebpをサポートせず表示できなくなる)
|
||||
return await this.imageProcessingService.convertToJpeg(`${dir}/out.png`, 498, 280);
|
||||
} finally {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
70
packages/backend/src/core/WebhookService.ts
Normal file
70
packages/backend/src/core/WebhookService.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { WebhooksRepository } from '@/models/index.js';
|
||||
import type { Webhook } from '@/models/entities/Webhook.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { OnApplicationShutdown } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class WebhookService implements OnApplicationShutdown {
|
||||
#webhooksFetched = false;
|
||||
#webhooks: Webhook[] = [];
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redisSubscriber)
|
||||
private redisSubscriber: Redis.Redis,
|
||||
|
||||
@Inject(DI.webhooksRepository)
|
||||
private webhooksRepository: WebhooksRepository,
|
||||
) {
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.redisSubscriber.on('message', this.onMessage);
|
||||
}
|
||||
|
||||
public async getActiveWebhooks() {
|
||||
if (!this.#webhooksFetched) {
|
||||
this.#webhooks = await this.webhooksRepository.findBy({
|
||||
active: true,
|
||||
});
|
||||
this.#webhooksFetched = true;
|
||||
}
|
||||
|
||||
return this.#webhooks;
|
||||
}
|
||||
|
||||
private async onMessage(_, data) {
|
||||
const obj = JSON.parse(data);
|
||||
|
||||
if (obj.channel === 'internal') {
|
||||
const { type, body } = obj.message;
|
||||
switch (type) {
|
||||
case 'webhookCreated':
|
||||
if (body.active) {
|
||||
this.#webhooks.push(body);
|
||||
}
|
||||
break;
|
||||
case 'webhookUpdated':
|
||||
if (body.active) {
|
||||
const i = this.#webhooks.findIndex(a => a.id === body.id);
|
||||
if (i > -1) {
|
||||
this.#webhooks[i] = body;
|
||||
} else {
|
||||
this.#webhooks.push(body);
|
||||
}
|
||||
} else {
|
||||
this.#webhooks = this.#webhooks.filter(a => a.id !== body.id);
|
||||
}
|
||||
break;
|
||||
case 'webhookDeleted':
|
||||
this.#webhooks = this.#webhooks.filter(a => a.id !== body.id);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public onApplicationShutdown(signal?: string | undefined) {
|
||||
this.redisSubscriber.off('message', this.onMessage);
|
||||
}
|
||||
}
|
59
packages/backend/src/core/chart/ChartManagementService.ts
Normal file
59
packages/backend/src/core/chart/ChartManagementService.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { beforeShutdown } from '@/misc/before-shutdown.js';
|
||||
|
||||
import FederationChart from './charts/federation.js';
|
||||
import NotesChart from './charts/notes.js';
|
||||
import UsersChart from './charts/users.js';
|
||||
import ActiveUsersChart from './charts/active-users.js';
|
||||
import InstanceChart from './charts/instance.js';
|
||||
import PerUserNotesChart from './charts/per-user-notes.js';
|
||||
import DriveChart from './charts/drive.js';
|
||||
import PerUserReactionsChart from './charts/per-user-reactions.js';
|
||||
import HashtagChart from './charts/hashtag.js';
|
||||
import PerUserFollowingChart from './charts/per-user-following.js';
|
||||
import PerUserDriveChart from './charts/per-user-drive.js';
|
||||
import ApRequestChart from './charts/ap-request.js';
|
||||
|
||||
@Injectable()
|
||||
export class ChartManagementService {
|
||||
constructor(
|
||||
private federationChart: FederationChart,
|
||||
private notesChart: NotesChart,
|
||||
private usersChart: UsersChart,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private instanceChart: InstanceChart,
|
||||
private perUserNotesChart: PerUserNotesChart,
|
||||
private driveChart: DriveChart,
|
||||
private perUserReactionsChart: PerUserReactionsChart,
|
||||
private hashtagChart: HashtagChart,
|
||||
private perUserFollowingChart: PerUserFollowingChart,
|
||||
private perUserDriveChart: PerUserDriveChart,
|
||||
private apRequestChart: ApRequestChart,
|
||||
) {}
|
||||
|
||||
public async run() {
|
||||
const charts = [
|
||||
this.federationChart,
|
||||
this.notesChart,
|
||||
this.usersChart,
|
||||
this.activeUsersChart,
|
||||
this.instanceChart,
|
||||
this.perUserNotesChart,
|
||||
this.driveChart,
|
||||
this.perUserReactionsChart,
|
||||
this.hashtagChart,
|
||||
this.perUserFollowingChart,
|
||||
this.perUserDriveChart,
|
||||
this.apRequestChart,
|
||||
];
|
||||
|
||||
// 20分おきにメモリ情報をDBに書き込み
|
||||
setInterval(() => {
|
||||
for (const chart of charts) {
|
||||
chart.save();
|
||||
}
|
||||
}, 1000 * 60 * 20);
|
||||
|
||||
beforeShutdown(() => Promise.all(charts.map(chart => chart.save())));
|
||||
}
|
||||
}
|
@ -1,7 +1,11 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Users } from '@/models/index.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/active-users.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
const week = 1000 * 60 * 60 * 24 * 7;
|
||||
const month = 1000 * 60 * 60 * 24 * 30;
|
||||
@ -11,9 +15,15 @@ const year = 1000 * 60 * 60 * 24 * 365;
|
||||
* アクティブユーザーに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class ActiveUsersChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
@ -1,13 +1,24 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/ap-request.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* Chart about ActivityPub requests
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class ApRequestChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
@ -1,16 +1,25 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { DriveFiles } from '@/models/index.js';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Not, IsNull, DataSource } from 'typeorm';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/drive.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ドライブに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class DriveChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
@ -1,15 +1,33 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { Followings, Instances } from '@/models/index.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { FollowingsRepository, InstancesRepository } from '@/models/index.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/federation.js';
|
||||
import { fetchMeta } from '@/misc/fetch-meta.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* フェデレーションに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class FederationChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.instancesRepository)
|
||||
private instancesRepository: InstancesRepository,
|
||||
|
||||
private metaService: MetaService,
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
@ -18,62 +36,62 @@ export default class FederationChart extends Chart<typeof schema> {
|
||||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
const meta = await fetchMeta();
|
||||
const meta = await this.metaService.fetch();
|
||||
|
||||
const suspendedInstancesQuery = Instances.createQueryBuilder('instance')
|
||||
const suspendedInstancesQuery = this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('instance.host')
|
||||
.where('instance.isSuspended = true');
|
||||
|
||||
const pubsubSubQuery = Followings.createQueryBuilder('f')
|
||||
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
|
||||
.select('f.followerHost')
|
||||
.where('f.followerHost IS NOT NULL');
|
||||
|
||||
const subInstancesQuery = Followings.createQueryBuilder('f')
|
||||
const subInstancesQuery = this.followingsRepository.createQueryBuilder('f')
|
||||
.select('f.followeeHost')
|
||||
.where('f.followeeHost IS NOT NULL');
|
||||
|
||||
const pubInstancesQuery = Followings.createQueryBuilder('f')
|
||||
const pubInstancesQuery = this.followingsRepository.createQueryBuilder('f')
|
||||
.select('f.followerHost')
|
||||
.where('f.followerHost IS NOT NULL');
|
||||
|
||||
const [sub, pub, pubsub, subActive, pubActive] = await Promise.all([
|
||||
Followings.createQueryBuilder('following')
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followeeHost)')
|
||||
.where('following.followeeHost IS NOT NULL')
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followeeHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts })
|
||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
Followings.createQueryBuilder('following')
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followerHost)')
|
||||
.where('following.followerHost IS NOT NULL')
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followerHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followerHost NOT IN (:...blocked)', { blocked: meta.blockedHosts })
|
||||
.andWhere(`following.followerHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
Followings.createQueryBuilder('following')
|
||||
this.followingsRepository.createQueryBuilder('following')
|
||||
.select('COUNT(DISTINCT following.followeeHost)')
|
||||
.where('following.followeeHost IS NOT NULL')
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : `following.followeeHost NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'following.followeeHost NOT IN (:...blocked)', { blocked: meta.blockedHosts })
|
||||
.andWhere(`following.followeeHost NOT IN (${ suspendedInstancesQuery.getQuery() })`)
|
||||
.andWhere(`following.followeeHost IN (${ pubsubSubQuery.getQuery() })`)
|
||||
.setParameters(pubsubSubQuery.getParameters())
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
Instances.createQueryBuilder('instance')
|
||||
this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('COUNT(instance.id)')
|
||||
.where(`instance.host IN (${ subInstancesQuery.getQuery() })`)
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
|
||||
.andWhere(`instance.isSuspended = false`)
|
||||
.andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts })
|
||||
.andWhere('instance.isSuspended = false')
|
||||
.andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
Instances.createQueryBuilder('instance')
|
||||
this.instancesRepository.createQueryBuilder('instance')
|
||||
.select('COUNT(instance.id)')
|
||||
.where(`instance.host IN (${ pubInstancesQuery.getQuery() })`)
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : `instance.host NOT IN (:...blocked)`, { blocked: meta.blockedHosts })
|
||||
.andWhere(`instance.isSuspended = false`)
|
||||
.andWhere(`instance.lastCommunicatedAt > :gt`, { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
|
||||
.andWhere(meta.blockedHosts.length === 0 ? '1=1' : 'instance.host NOT IN (:...blocked)', { blocked: meta.blockedHosts })
|
||||
.andWhere('instance.isSuspended = false')
|
||||
.andWhere('instance.lastCommunicatedAt > :gt', { gt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)) })
|
||||
.getRawOne()
|
||||
.then(x => parseInt(x.count, 10)),
|
||||
]);
|
41
packages/backend/src/core/chart/charts/hashtag.ts
Normal file
41
packages/backend/src/core/chart/charts/hashtag.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/hashtag.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ハッシュタグに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class HashtagChart extends Chart<typeof schema> {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async update(hashtag: string, user: { id: User['id'], host: User['host'] }): Promise<void> {
|
||||
await this.commit({
|
||||
'local.users': this.userEntityService.isLocalUser(user) ? [user.id] : [],
|
||||
'remote.users': this.userEntityService.isLocalUser(user) ? [] : [user.id],
|
||||
}, hashtag);
|
||||
}
|
||||
}
|
@ -1,17 +1,41 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { DriveFiles, Followings, Users, Notes } from '@/models/index.js';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { Note } from '@/models/entities/note.js';
|
||||
import { toPuny } from '@/misc/convert-host.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DriveFilesRepository, FollowingsRepository, UsersRepository, NotesRepository } from '@/models/index.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/instance.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* インスタンスごとのチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class InstanceChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema, true);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private utilityService: UtilityService,
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||
@ -22,11 +46,11 @@ export default class InstanceChart extends Chart<typeof schema> {
|
||||
followersCount,
|
||||
driveFiles,
|
||||
] = await Promise.all([
|
||||
Notes.countBy({ userHost: group }),
|
||||
Users.countBy({ host: group }),
|
||||
Followings.countBy({ followerHost: group }),
|
||||
Followings.countBy({ followeeHost: group }),
|
||||
DriveFiles.countBy({ userHost: group }),
|
||||
this.notesRepository.countBy({ userHost: group }),
|
||||
this.usersRepository.countBy({ host: group }),
|
||||
this.followingsRepository.countBy({ followerHost: group }),
|
||||
this.followingsRepository.countBy({ followeeHost: group }),
|
||||
this.driveFilesRepository.countBy({ userHost: group }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -45,21 +69,21 @@ export default class InstanceChart extends Chart<typeof schema> {
|
||||
public async requestReceived(host: string): Promise<void> {
|
||||
await this.commit({
|
||||
'requests.received': 1,
|
||||
}, toPuny(host));
|
||||
}, this.utilityService.toPuny(host));
|
||||
}
|
||||
|
||||
public async requestSent(host: string, isSucceeded: boolean): Promise<void> {
|
||||
await this.commit({
|
||||
'requests.succeeded': isSucceeded ? 1 : 0,
|
||||
'requests.failed': isSucceeded ? 0 : 1,
|
||||
}, toPuny(host));
|
||||
}, this.utilityService.toPuny(host));
|
||||
}
|
||||
|
||||
public async newUser(host: string): Promise<void> {
|
||||
await this.commit({
|
||||
'users.total': 1,
|
||||
'users.inc': 1,
|
||||
}, toPuny(host));
|
||||
}, this.utilityService.toPuny(host));
|
||||
}
|
||||
|
||||
public async updateNote(host: string, note: Note, isAdditional: boolean): Promise<void> {
|
||||
@ -71,7 +95,7 @@ export default class InstanceChart extends Chart<typeof schema> {
|
||||
'notes.diffs.renote': note.renoteId != null ? (isAdditional ? 1 : -1) : 0,
|
||||
'notes.diffs.reply': note.replyId != null ? (isAdditional ? 1 : -1) : 0,
|
||||
'notes.diffs.withFile': note.fileIds.length > 0 ? (isAdditional ? 1 : -1) : 0,
|
||||
}, toPuny(host));
|
||||
}, this.utilityService.toPuny(host));
|
||||
}
|
||||
|
||||
public async updateFollowing(host: string, isAdditional: boolean): Promise<void> {
|
||||
@ -79,7 +103,7 @@ export default class InstanceChart extends Chart<typeof schema> {
|
||||
'following.total': isAdditional ? 1 : -1,
|
||||
'following.inc': isAdditional ? 1 : 0,
|
||||
'following.dec': isAdditional ? 0 : 1,
|
||||
}, toPuny(host));
|
||||
}, this.utilityService.toPuny(host));
|
||||
}
|
||||
|
||||
public async updateFollowers(host: string, isAdditional: boolean): Promise<void> {
|
||||
@ -87,7 +111,7 @@ export default class InstanceChart extends Chart<typeof schema> {
|
||||
'followers.total': isAdditional ? 1 : -1,
|
||||
'followers.inc': isAdditional ? 1 : 0,
|
||||
'followers.dec': isAdditional ? 0 : 1,
|
||||
}, toPuny(host));
|
||||
}, this.utilityService.toPuny(host));
|
||||
}
|
||||
|
||||
public async updateDrive(file: DriveFile, isAdditional: boolean): Promise<void> {
|
@ -1,22 +1,35 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { Notes } from '@/models/index.js';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { Note } from '@/models/entities/note.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Not, IsNull, DataSource } from 'typeorm';
|
||||
import { NotesRepository } from '@/models/index.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/notes.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ノートに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class NotesChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
const [localCount, remoteCount] = await Promise.all([
|
||||
Notes.countBy({ userHost: IsNull() }),
|
||||
Notes.countBy({ userHost: Not(IsNull()) }),
|
||||
this.notesRepository.countBy({ userHost: IsNull() }),
|
||||
this.notesRepository.countBy({ userHost: Not(IsNull()) }),
|
||||
]);
|
||||
|
||||
return {
|
@ -1,21 +1,37 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { DriveFiles } from '@/models/index.js';
|
||||
import { DriveFile } from '@/models/entities/drive-file.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { DriveFilesRepository } from '@/models/index.js';
|
||||
import type { DriveFile } from '@/models/entities/DriveFile.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/per-user-drive.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ユーザーごとのドライブに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class PerUserDriveChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema, true);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.driveFilesRepository)
|
||||
private driveFilesRepository: DriveFilesRepository,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private driveFileEntityService: DriveFileEntityService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||
const [count, size] = await Promise.all([
|
||||
DriveFiles.countBy({ userId: group }),
|
||||
DriveFiles.calcDriveUsageOf(group),
|
||||
this.driveFilesRepository.countBy({ userId: group }),
|
||||
this.driveFileEntityService.calcDriveUsageOf(group),
|
||||
]);
|
||||
|
||||
return {
|
@ -1,16 +1,31 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { Followings, Users } from '@/models/index.js';
|
||||
import { Not, IsNull } from 'typeorm';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { Not, IsNull, DataSource } from 'typeorm';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { FollowingsRepository } from '@/models/index.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/per-user-following.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ユーザーごとのフォローに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class PerUserFollowingChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema, true);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||
@ -20,10 +35,10 @@ export default class PerUserFollowingChart extends Chart<typeof schema> {
|
||||
remoteFollowingsCount,
|
||||
remoteFollowersCount,
|
||||
] = await Promise.all([
|
||||
Followings.countBy({ followerId: group, followeeHost: IsNull() }),
|
||||
Followings.countBy({ followeeId: group, followerHost: IsNull() }),
|
||||
Followings.countBy({ followerId: group, followeeHost: Not(IsNull()) }),
|
||||
Followings.countBy({ followeeId: group, followerHost: Not(IsNull()) }),
|
||||
this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }),
|
||||
this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }),
|
||||
this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }),
|
||||
this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -39,8 +54,8 @@ export default class PerUserFollowingChart extends Chart<typeof schema> {
|
||||
}
|
||||
|
||||
public async update(follower: { id: User['id']; host: User['host']; }, followee: { id: User['id']; host: User['host']; }, isFollow: boolean): Promise<void> {
|
||||
const prefixFollower = Users.isLocalUser(follower) ? 'local' : 'remote';
|
||||
const prefixFollowee = Users.isLocalUser(followee) ? 'local' : 'remote';
|
||||
const prefixFollower = this.userEntityService.isLocalUser(follower) ? 'local' : 'remote';
|
||||
const prefixFollowee = this.userEntityService.isLocalUser(followee) ? 'local' : 'remote';
|
||||
|
||||
this.commit({
|
||||
[`${prefixFollower}.followings.total`]: isFollow ? 1 : -1,
|
@ -1,21 +1,35 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { User } from '@/models/entities/user.js';
|
||||
import { Notes } from '@/models/index.js';
|
||||
import { Note } from '@/models/entities/note.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { NotesRepository } from '@/models/index.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/per-user-notes.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ユーザーごとのノートに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class PerUserNotesChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema, true);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||
const [count] = await Promise.all([
|
||||
Notes.countBy({ userId: group }),
|
||||
this.notesRepository.countBy({ userId: group }),
|
||||
]);
|
||||
|
||||
return {
|
42
packages/backend/src/core/chart/charts/per-user-reactions.ts
Normal file
42
packages/backend/src/core/chart/charts/per-user-reactions.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import type { User } from '@/models/entities/User.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/per-user-reactions.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* ユーザーごとのリアクションに関するチャート
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class PerUserReactionsChart extends Chart<typeof schema> {
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
private userEntityService: UserEntityService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async update(user: { id: User['id'], host: User['host'] }, note: Note): Promise<void> {
|
||||
const prefix = this.userEntityService.isLocalUser(user) ? 'local' : 'remote';
|
||||
this.commit({
|
||||
[`${prefix}.count`]: 1,
|
||||
}, note.userId);
|
||||
}
|
||||
}
|
@ -1,15 +1,26 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/test-grouped.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* For testing
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class TestGroupedChart extends Chart<typeof schema> {
|
||||
private total = {} as Record<string, number>;
|
||||
|
||||
constructor() {
|
||||
super(name, schema, true);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
@ -1,13 +1,24 @@
|
||||
import Chart, { KVs } from '../core.js';
|
||||
import { Injectable, Inject } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { AppLockService } from '@/core/AppLockService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import Chart from '../core.js';
|
||||
import { name, schema } from './entities/test-intersection.js';
|
||||
import type { KVs } from '../core.js';
|
||||
|
||||
/**
|
||||
* For testing
|
||||
*/
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class TestIntersectionChart extends Chart<typeof schema> {
|
||||
constructor() {
|
||||
super(name, schema);
|
||||
constructor(
|
||||
@Inject(DI.db)
|
||||
private db: DataSource,
|
||||
|
||||
private appLockService: AppLockService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), name, schema);
|
||||
}
|
||||
|
||||
protected async tickMajor(): Promise<Partial<KVs<typeof schema>>> {
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user