Merge remote-tracking branch 'misskey-dev/develop' into develop

# Conflicts:
#	package.json
This commit is contained in:
mattyatea 2023-09-11 18:57:08 +09:00
commit 94a7eca882
249 changed files with 6467 additions and 8437 deletions

View file

@ -0,0 +1,49 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class PasskeySupport1691959191872 {
name = 'PasskeySupport1691959191872'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "counter" bigint NOT NULL DEFAULT '0'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialDeviceType" character varying(32)`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "credentialBackedUp" boolean`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ADD "transports" character varying(32) array`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'The public key of the UserSecurityKey, hex-encoded.'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'Timestamp of the last time the UserSecurityKey was used.'`);
await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" SET DEFAULT now()`);
await queryRunner.query(`UPDATE "user_security_key" SET "id" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("id", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', ''), "publicKey" = REPLACE(REPLACE(REPLACE(REPLACE(ENCODE(DECODE("publicKey", 'hex'), 'base64'), E'\\n', ''), '+', '-'), '/', '_'), '=', '')`);
await queryRunner.query(`ALTER TABLE "attestation_challenge" DROP CONSTRAINT "FK_f1a461a618fa1755692d0e0d592"`);
await queryRunner.query(`DROP INDEX "IDX_47efb914aed1f72dd39a306c7b"`);
await queryRunner.query(`DROP INDEX "IDX_f1a461a618fa1755692d0e0d59"`);
await queryRunner.query(`DROP TABLE "attestation_challenge"`);
}
async down(queryRunner) {
await queryRunner.query(`CREATE TABLE "attestation_challenge" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "challenge" character varying(64) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "registrationChallenge" boolean NOT NULL DEFAULT false, CONSTRAINT "PK_d0ba6786e093f1bcb497572a6b5" PRIMARY KEY ("id", "userId"))`);
await queryRunner.query(`CREATE INDEX "IDX_f1a461a618fa1755692d0e0d59" ON "attestation_challenge" ("userId") `);
await queryRunner.query(`CREATE INDEX "IDX_47efb914aed1f72dd39a306c7b" ON "attestation_challenge" ("challenge") `);
await queryRunner.query(`ALTER TABLE "attestation_challenge" ADD CONSTRAINT "FK_f1a461a618fa1755692d0e0d592" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."challenge" IS 'Hex-encoded sha256 hash of the challenge.'`);
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."createdAt" IS 'The date challenge was created for expiry purposes.'`);
await queryRunner.query(`COMMENT ON COLUMN "attestation_challenge"."registrationChallenge" IS 'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.'`);
await queryRunner.query(`UPDATE "user_security_key" SET "id" = ENCODE(DECODE(REPLACE(REPLACE("id" || CASE WHEN LENGTH("id") % 4 = 2 THEN '==' WHEN LENGTH("id") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex'), "publicKey" = ENCODE(DECODE(REPLACE(REPLACE("publicKey" || CASE WHEN LENGTH("publicKey") % 4 = 2 THEN '==' WHEN LENGTH("publicKey") % 4 = 3 THEN '=' ELSE '' END, '-', '+'), '_', '/'), 'base64'), 'hex')`);
await queryRunner.query(`ALTER TABLE "user_security_key" ALTER COLUMN "lastUsed" DROP DEFAULT`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."lastUsed" IS 'The date of the last time the UserSecurityKey was successfully validated.'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."publicKey" IS 'Variable-length public key used to verify attestations (hex-encoded).'`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."transports" IS 'The type of the credential returned by the browser'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "transports"`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialBackedUp" IS 'Whether or not the credential has been backed up'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialBackedUp"`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."credentialDeviceType" IS 'The type of Backup Eligibility in authenticator data'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "credentialDeviceType"`);
await queryRunner.query(`COMMENT ON COLUMN "user_security_key"."counter" IS 'The number of times the UserSecurityKey was validated.'`);
await queryRunner.query(`ALTER TABLE "user_security_key" DROP COLUMN "counter"`);
}
}

View file

@ -56,36 +56,37 @@
"utf-8-validate": "^6.0.3"
},
"dependencies": {
"@aws-sdk/client-s3": "3.367.0",
"@aws-sdk/lib-storage": "3.367.0",
"@aws-sdk/node-http-handler": "3.360.0",
"@bull-board/api": "5.7.1",
"@bull-board/fastify": "5.7.1",
"@bull-board/ui": "5.7.1",
"@aws-sdk/client-s3": "3.400.0",
"@aws-sdk/lib-storage": "3.400.0",
"@aws-sdk/node-http-handler": "3.374.0",
"@bull-board/api": "5.8.1",
"@bull-board/fastify": "5.8.1",
"@bull-board/ui": "5.8.1",
"@discordapp/twemoji": "14.1.2",
"@fastify/accepts": "4.2.0",
"@fastify/cookie": "8.3.0",
"@fastify/cookie": "9.0.4",
"@fastify/cors": "8.3.0",
"@fastify/express": "2.3.0",
"@fastify/http-proxy": "9.2.1",
"@fastify/multipart": "7.7.3",
"@fastify/static": "6.10.2",
"@fastify/static": "6.11.0",
"@fastify/view": "8.0.0",
"@nestjs/common": "10.1.2",
"@nestjs/core": "10.1.2",
"@nestjs/testing": "10.1.2",
"@nestjs/common": "10.2.4",
"@nestjs/core": "10.2.4",
"@nestjs/testing": "10.2.4",
"@peertube/http-signature": "1.7.0",
"@sinonjs/fake-timers": "10.3.0",
"@simplewebauthn/server": "8.1.1",
"@sinonjs/fake-timers": "11.1.0",
"@swc/cli": "0.1.62",
"@swc/core": "1.3.72",
"@swc/core": "1.3.82",
"accepts": "1.3.8",
"ajv": "8.12.0",
"archiver": "5.3.1",
"async-mutex": "^0.4.0",
"archiver": "6.0.1",
"async-mutex": "0.4.0",
"bcryptjs": "2.4.3",
"blurhash": "2.0.5",
"body-parser": "1.20.2",
"bullmq": "4.6.3",
"bullmq": "4.8.0",
"cacheable-lookup": "7.0.0",
"cbor": "9.0.1",
"chalk": "5.3.0",
@ -96,7 +97,7 @@
"content-disposition": "0.5.4",
"date-fns": "2.30.0",
"deep-email-validator": "0.1.21",
"fastify": "4.21.0",
"fastify": "4.22.2",
"feed": "4.2.2",
"file-type": "18.5.0",
"fluent-ffmpeg": "2.1.2",
@ -112,14 +113,15 @@
"js-yaml": "4.1.0",
"jsdom": "22.1.0",
"json5": "2.2.3",
"jsonld": "8.2.0",
"jsonld": "8.2.1",
"jsrsasign": "10.8.6",
"meilisearch": "0.33.0",
"meilisearch": "0.34.1",
"mfm-js": "0.23.3",
"microformats-parser": "1.4.1",
"mime-types": "2.1.35",
"misskey-js": "workspace:*",
"ms": "3.0.0-canary.1",
"nanoid": "4.0.2",
"nested-property": "4.0.0",
"node-fetch": "3.3.2",
"nodemailer": "6.9.4",
@ -130,7 +132,7 @@
"os-utils": "0.0.14",
"otpauth": "9.1.4",
"parse5": "7.1.2",
"pg": "8.11.1",
"pg": "8.11.3",
"pkce-challenge": "4.0.1",
"probe-image-size": "7.2.3",
"promise-limit": "2.7.0",
@ -140,84 +142,85 @@
"qrcode": "1.5.3",
"random-seed": "0.3.0",
"ratelimiter": "3.4.1",
"re2": "1.20.1",
"re2": "1.20.3",
"redis-lock": "0.1.4",
"reflect-metadata": "0.1.13",
"rename": "1.0.4",
"rss-parser": "3.13.0",
"rxjs": "7.8.1",
"sanitize-html": "2.11.0",
"sharp": "0.32.4",
"sharp": "0.32.5",
"sharp-read-bmp": "github:misskey-dev/sharp-read-bmp",
"slacc": "0.0.10",
"strict-event-emitter-types": "2.0.0",
"stringz": "2.1.0",
"summaly": "github:misskey-dev/summaly",
"systeminformation": "5.18.9",
"systeminformation": "5.21.4",
"tinycolor2": "1.6.0",
"tmp": "0.2.1",
"tsc-alias": "1.8.7",
"tsconfig-paths": "4.2.0",
"twemoji-parser": "14.0.0",
"typeorm": "0.3.17",
"typescript": "5.1.6",
"typescript": "5.2.2",
"ulid": "2.3.0",
"vary": "1.1.2",
"web-push": "3.6.4",
"web-push": "3.6.5",
"ws": "8.13.0",
"xev": "3.0.2"
},
"devDependencies": {
"@jest/globals": "29.6.2",
"@swc/jest": "0.2.27",
"@jest/globals": "29.6.4",
"@simplewebauthn/typescript-types": "8.0.0",
"@swc/jest": "0.2.29",
"@types/accepts": "1.3.5",
"@types/archiver": "5.3.2",
"@types/bcryptjs": "2.4.2",
"@types/bcryptjs": "2.4.3",
"@types/body-parser": "1.19.2",
"@types/cbor": "6.0.0",
"@types/color-convert": "2.0.0",
"@types/content-disposition": "0.5.5",
"@types/color-convert": "2.0.1",
"@types/content-disposition": "0.5.6",
"@types/fluent-ffmpeg": "2.1.21",
"@types/http-link-header": "1.0.3",
"@types/jest": "29.5.3",
"@types/jest": "29.5.4",
"@types/js-yaml": "4.0.5",
"@types/jsdom": "21.1.1",
"@types/jsdom": "21.1.2",
"@types/jsonld": "1.5.9",
"@types/jsrsasign": "10.5.8",
"@types/mime-types": "2.1.1",
"@types/ms": "0.7.31",
"@types/node": "20.4.5",
"@types/node": "20.5.9",
"@types/node-fetch": "3.0.3",
"@types/nodemailer": "6.4.9",
"@types/oauth": "0.9.1",
"@types/oauth2orize": "1.11.0",
"@types/oauth": "0.9.2",
"@types/oauth2orize": "1.11.1",
"@types/oauth2orize-pkce": "0.1.0",
"@types/pg": "8.10.2",
"@types/pug": "2.0.6",
"@types/punycode": "2.1.0",
"@types/qrcode": "1.5.1",
"@types/qrcode": "1.5.2",
"@types/random-seed": "0.3.3",
"@types/ratelimiter": "3.4.4",
"@types/rename": "1.0.4",
"@types/sanitize-html": "2.9.0",
"@types/semver": "7.5.0",
"@types/semver": "7.5.1",
"@types/sharp": "0.32.0",
"@types/simple-oauth2": "5.0.4",
"@types/sinonjs__fake-timers": "8.1.2",
"@types/tinycolor2": "1.4.3",
"@types/tmp": "0.2.3",
"@types/vary": "1.1.0",
"@types/web-push": "3.3.2",
"@types/web-push": "3.6.0",
"@types/ws": "8.5.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"@typescript-eslint/eslint-plugin": "6.6.0",
"@typescript-eslint/parser": "6.6.0",
"aws-sdk-client-mock": "3.0.0",
"cross-env": "7.0.3",
"eslint": "8.46.0",
"eslint-plugin-import": "2.28.0",
"execa": "7.2.0",
"jest": "29.6.2",
"jest-mock": "29.6.2",
"eslint": "8.48.0",
"eslint-plugin-import": "2.28.1",
"execa": "8.0.1",
"jest": "29.6.4",
"jest-mock": "29.6.3",
"simple-oauth2": "5.0.0"
}
}

View file

@ -8,7 +8,6 @@ import { ChartManagementService } from '@/core/chart/ChartManagementService.js';
import { QueueProcessorService } from '@/queue/QueueProcessorService.js';
import { NestLogger } from '@/NestLogger.js';
import { QueueProcessorModule } from '@/queue/QueueProcessorModule.js';
import { JanitorService } from '@/daemons/JanitorService.js';
import { QueueStatsService } from '@/daemons/QueueStatsService.js';
import { ServerStatsService } from '@/daemons/ServerStatsService.js';
import { ServerService } from '@/server/ServerService.js';
@ -25,7 +24,6 @@ export async function server() {
if (process.env.NODE_ENV !== 'test') {
app.get(ChartManagementService).start();
app.get(JanitorService).start();
app.get(QueueStatsService).start();
app.get(ServerStatsService).start();
}

View file

@ -3,10 +3,6 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Config loader
*/
import * as fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
@ -23,11 +19,9 @@ type RedisOptionsSource = Partial<RedisOptions> & {
};
/**
*
*
*/
export type Source = {
repository_url?: string;
feedback_url?: string;
type Source = {
url: string;
port?: number;
socket?: string;
@ -70,8 +64,6 @@ export type Source = {
maxFileSize?: number;
accesslog?: string;
clusterLimit?: number;
id: string;
@ -93,12 +85,63 @@ export type Source = {
videoThumbnailGenerator?: string;
signToActivityPubGet?: boolean;
perChannelMaxNoteCacheCount?: number;
perUserNotificationsMaxCount?: number;
deactivateAntennaThreshold?: number;
};
/**
* Misskeyが自動的に()
*/
export type Mixin = {
export type Config = {
url: string;
port: number;
socket: string | undefined;
chmodSocket: string | undefined;
disableHsts: boolean | undefined;
db: {
host: string;
port: number;
db: string;
user: string;
pass: string;
disableCache?: boolean;
extra?: { [x: string]: string };
};
dbReplications: boolean | undefined;
dbSlaves: {
host: string;
port: number;
db: string;
user: string;
pass: string;
}[] | undefined;
meilisearch: {
host: string;
port: string;
apiKey: string;
ssl?: boolean;
index: string;
scope?: 'local' | 'global' | string[];
} | undefined;
proxy: string | undefined;
proxySmtp: string | undefined;
proxyBypassHosts: string[] | undefined;
allowedPrivateNetworks: string[] | undefined;
maxFileSize: number | undefined;
clusterLimit: number | undefined;
id: string;
outgoingAddress: string | undefined;
outgoingAddressFamily: 'ipv4' | 'ipv6' | 'dual' | undefined;
deliverJobConcurrency: number | undefined;
inboxJobConcurrency: number | undefined;
relashionshipJobConcurrency: number | undefined;
deliverJobPerSec: number | undefined;
inboxJobPerSec: number | undefined;
relashionshipJobPerSec: number | undefined;
deliverJobMaxAttempts: number | undefined;
inboxJobMaxAttempts: number | undefined;
proxyRemoteFiles: boolean | undefined;
signToActivityPubGet: boolean | undefined;
version: string;
host: string;
hostname: string;
@ -117,10 +160,11 @@ export type Mixin = {
redis: RedisOptions & RedisOptionsSource;
redisForPubsub: RedisOptions & RedisOptionsSource;
redisForJobQueue: RedisOptions & RedisOptionsSource;
perChannelMaxNoteCacheCount: number;
perUserNotificationsMaxCount: number;
deactivateAntennaThreshold: number;
};
export type Config = Source & Mixin;
const _filename = fileURLToPath(import.meta.url);
const _dirname = dirname(_filename);
@ -138,7 +182,7 @@ const path = process.env.MISSKEY_CONFIG_YML
? resolve(dir, 'test.yml')
: resolve(dir, 'default.yml');
export function loadConfig() {
export function loadConfig(): Config {
const meta = JSON.parse(fs.readFileSync(`${_dirname}/../../../built/meta.json`, 'utf-8'));
const clientManifestExists = fs.existsSync(_dirname + '/../../../built/_vite_/manifest.json');
const clientManifest = clientManifestExists ?
@ -146,43 +190,72 @@ export function loadConfig() {
: { 'src/_boot_.ts': { file: 'src/_boot_.ts' } };
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/_boot_.ts'];
mixin.clientManifestExists = clientManifestExists;
const version = meta.version;
const host = url.host;
const hostname = url.hostname;
const scheme = url.protocol.replace(/:$/, '');
const wsScheme = scheme.replace('http', 'ws');
const externalMediaProxy = config.mediaProxy ?
config.mediaProxy.endsWith('/') ? config.mediaProxy.substring(0, config.mediaProxy.length - 1) : config.mediaProxy
: null;
const internalMediaProxy = `${mixin.scheme}://${mixin.host}/proxy`;
mixin.mediaProxy = externalMediaProxy ?? internalMediaProxy;
mixin.externalMediaProxyEnabled = externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy;
const internalMediaProxy = `${scheme}://${host}/proxy`;
const redis = convertRedisOptions(config.redis, host);
mixin.videoThumbnailGenerator = config.videoThumbnailGenerator ?
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null;
mixin.redis = convertRedisOptions(config.redis, mixin.host);
mixin.redisForPubsub = config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, mixin.host) : mixin.redis;
mixin.redisForJobQueue = config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, mixin.host) : mixin.redis;
return Object.assign(config, mixin);
return {
version,
url: url.origin,
port: config.port ?? parseInt(process.env.PORT ?? '', 10),
socket: config.socket,
chmodSocket: config.chmodSocket,
disableHsts: config.disableHsts,
host,
hostname,
scheme,
wsScheme,
wsUrl: `${wsScheme}://${host}`,
apiUrl: `${scheme}://${host}/api`,
authUrl: `${scheme}://${host}/auth`,
driveUrl: `${scheme}://${host}/files`,
db: config.db,
dbReplications: config.dbReplications,
dbSlaves: config.dbSlaves,
meilisearch: config.meilisearch,
redis,
redisForPubsub: config.redisForPubsub ? convertRedisOptions(config.redisForPubsub, host) : redis,
redisForJobQueue: config.redisForJobQueue ? convertRedisOptions(config.redisForJobQueue, host) : redis,
id: config.id,
proxy: config.proxy,
proxySmtp: config.proxySmtp,
proxyBypassHosts: config.proxyBypassHosts,
allowedPrivateNetworks: config.allowedPrivateNetworks,
maxFileSize: config.maxFileSize,
clusterLimit: config.clusterLimit,
outgoingAddress: config.outgoingAddress,
outgoingAddressFamily: config.outgoingAddressFamily,
deliverJobConcurrency: config.deliverJobConcurrency,
inboxJobConcurrency: config.inboxJobConcurrency,
relashionshipJobConcurrency: config.relashionshipJobConcurrency,
deliverJobPerSec: config.deliverJobPerSec,
inboxJobPerSec: config.inboxJobPerSec,
relashionshipJobPerSec: config.relashionshipJobPerSec,
deliverJobMaxAttempts: config.deliverJobMaxAttempts,
inboxJobMaxAttempts: config.inboxJobMaxAttempts,
proxyRemoteFiles: config.proxyRemoteFiles,
signToActivityPubGet: config.signToActivityPubGet,
mediaProxy: externalMediaProxy ?? internalMediaProxy,
externalMediaProxyEnabled: externalMediaProxy !== null && externalMediaProxy !== internalMediaProxy,
videoThumbnailGenerator: config.videoThumbnailGenerator ?
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
: null,
userAgent: `Misskey/${version} (${config.url})`,
clientEntry: clientManifest['src/_boot_.ts'],
clientManifestExists: clientManifestExists,
perChannelMaxNoteCacheCount: config.perChannelMaxNoteCacheCount ?? 1000,
perUserNotificationsMaxCount: config.perUserNotificationsMaxCount ?? 300,
deactivateAntennaThreshold: config.deactivateAntennaThreshold ?? (1000 * 60 * 60 * 24 * 7),
};
}
function tryCreateUrl(url: string) {

View file

@ -43,7 +43,7 @@ import { RelayService } from './RelayService.js';
import { RoleService } from './RoleService.js';
import { S3Service } from './S3Service.js';
import { SignupService } from './SignupService.js';
import { TwoFactorAuthenticationService } from './TwoFactorAuthenticationService.js';
import { WebAuthnService } from './WebAuthnService.js';
import { UserBlockingService } from './UserBlockingService.js';
import { CacheService } from './CacheService.js';
import { UserFollowingService } from './UserFollowingService.js';
@ -168,7 +168,7 @@ const $RelayService: Provider = { provide: 'RelayService', useExisting: RelaySer
const $RoleService: Provider = { provide: 'RoleService', useExisting: RoleService };
const $S3Service: Provider = { provide: 'S3Service', useExisting: S3Service };
const $SignupService: Provider = { provide: 'SignupService', useExisting: SignupService };
const $TwoFactorAuthenticationService: Provider = { provide: 'TwoFactorAuthenticationService', useExisting: TwoFactorAuthenticationService };
const $WebAuthnService: Provider = { provide: 'WebAuthnService', useExisting: WebAuthnService };
const $UserBlockingService: Provider = { provide: 'UserBlockingService', useExisting: UserBlockingService };
const $CacheService: Provider = { provide: 'CacheService', useExisting: CacheService };
const $UserFollowingService: Provider = { provide: 'UserFollowingService', useExisting: UserFollowingService };
@ -296,7 +296,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
TwoFactorAuthenticationService,
WebAuthnService,
UserBlockingService,
CacheService,
UserFollowingService,
@ -417,7 +417,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
$TwoFactorAuthenticationService,
$WebAuthnService,
$UserBlockingService,
$CacheService,
$UserFollowingService,
@ -539,7 +539,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
RoleService,
S3Service,
SignupService,
TwoFactorAuthenticationService,
WebAuthnService,
UserBlockingService,
CacheService,
UserFollowingService,
@ -659,7 +659,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$RoleService,
$S3Service,
$SignupService,
$TwoFactorAuthenticationService,
$WebAuthnService,
$UserBlockingService,
$CacheService,
$UserFollowingService,

View file

@ -8,6 +8,7 @@ import { ulid } from 'ulid';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { genAid, parseAid } from '@/misc/id/aid.js';
import { genAidx, parseAidx } from '@/misc/id/aidx.js';
import { genMeid, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, parseObjectId } from '@/misc/id/object-id.js';
@ -31,6 +32,7 @@ export class IdService {
switch (this.method) {
case 'aid': return genAid(date);
case 'aidx': return genAidx(date);
case 'meid': return genMeid(date);
case 'meidg': return genMeidg(date);
case 'ulid': return ulid(date.getTime());
@ -43,6 +45,7 @@ export class IdService {
public parse(id: string): { date: Date; } {
switch (this.method) {
case 'aid': return parseAid(id);
case 'aidx': return parseAidx(id);
case 'objectid': return parseObjectId(id);
case 'meid': return parseMeid(id);
case 'meidg': return parseMeidg(id);

View file

@ -16,7 +16,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class MetaService implements OnApplicationShutdown {
private cache: MiMeta | undefined;
private intervalId: NodeJS.Timer;
private intervalId: NodeJS.Timeout;
constructor(
@Inject(DI.redisForSub)

View file

@ -334,7 +334,7 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.channel) {
this.redisClient.xadd(
`channelTimeline:${data.channel.id}`,
'MAXLEN', '~', '1000',
'MAXLEN', '~', this.config.perChannelMaxNoteCacheCount.toString(),
'*',
'note', note.id);
}

View file

@ -17,12 +17,16 @@ import { PushNotificationService } from '@/core/PushNotificationService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js';
@Injectable()
export class NotificationService implements OnApplicationShutdown {
#shutdownController = new AbortController();
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@ -96,7 +100,7 @@ export class NotificationService implements OnApplicationShutdown {
const redisIdPromise = this.redisClient.xadd(
`notificationTimeline:${notifieeId}`,
'MAXLEN', '~', '300',
'MAXLEN', '~', this.config.perUserNotificationsMaxCount.toString(),
'*',
'data', JSON.stringify(notification));

View file

@ -1,446 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import * as crypto from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import * as jsrsasign from 'jsrsasign';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.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]);
if (certStruct == null) throw new Error('certStruct is null');
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,
) {
}
@bindThis
public hash(data: Buffer) {
return crypto
.createHash('sha256')
.update(data)
.digest();
}
@bindThis
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);
}
@bindThis
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,
};
},
},
};
}
}

View file

@ -0,0 +1,252 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import {
generateAuthenticationOptions,
generateRegistrationOptions, verifyAuthenticationResponse,
verifyRegistrationResponse,
} from '@simplewebauthn/server';
import { AttestationFormat, isoCBOR } from '@simplewebauthn/server/helpers';
import { DI } from '@/di-symbols.js';
import type { UserSecurityKeysRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MetaService } from '@/core/MetaService.js';
import { MiUser } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type {
AuthenticationResponseJSON,
AuthenticatorTransportFuture,
CredentialDeviceType,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialDescriptorFuture,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/typescript-types';
@Injectable()
export class WebAuthnService {
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.config)
private config: Config,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
private metaService: MetaService,
) {
}
@bindThis
public async getRelyingParty(): Promise<{ origin: string; rpId: string; rpName: string; rpIcon?: string; }> {
const instance = await this.metaService.fetch();
return {
origin: this.config.url,
rpId: this.config.host,
rpName: instance.name ?? this.config.host,
rpIcon: instance.iconUrl ?? undefined,
};
}
@bindThis
public async initiateRegistration(userId: MiUser['id'], userName: string, userDisplayName?: string): Promise<PublicKeyCredentialCreationOptionsJSON> {
const relyingParty = await this.getRelyingParty();
const keys = await this.userSecurityKeysRepository.findBy({
userId: userId,
});
const registrationOptions = await generateRegistrationOptions({
rpName: relyingParty.rpName,
rpID: relyingParty.rpId,
userID: userId,
userName: userName,
userDisplayName: userDisplayName,
attestationType: 'indirect',
excludeCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
id: Buffer.from(key.id, 'base64url'),
type: 'public-key',
transports: key.transports ?? undefined,
})),
authenticatorSelection: {
residentKey: 'required',
userVerification: 'preferred',
},
});
await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, registrationOptions.challenge);
return registrationOptions;
}
@bindThis
public async verifyRegistration(userId: MiUser['id'], response: RegistrationResponseJSON): Promise<{
credentialID: Uint8Array;
credentialPublicKey: Uint8Array;
attestationObject: Uint8Array;
fmt: AttestationFormat;
counter: number;
userVerified: boolean;
credentialDeviceType: CredentialDeviceType;
credentialBackedUp: boolean;
transports?: AuthenticatorTransportFuture[];
}> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
if (!challenge) {
throw new IdentifiableError('7dbfb66c-9216-4e2b-9c27-cef2ac8efb84', 'challenge not found');
}
await this.redisClient.del(`webauthn:challenge:${userId}`);
const relyingParty = await this.getRelyingParty();
let verification;
try {
verification = await verifyRegistrationResponse({
response: response,
expectedChallenge: challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
requireUserVerification: true,
});
} catch (error) {
console.error(error);
throw new IdentifiableError('5c1446f8-8ca7-4d31-9f39-656afe9c5d87', 'verification failed');
}
const { verified } = verification;
if (!verified || !verification.registrationInfo) {
throw new IdentifiableError('bb333667-3832-4a80-8bb5-c505be7d710d', 'verification failed');
}
const { registrationInfo } = verification;
return {
credentialID: registrationInfo.credentialID,
credentialPublicKey: registrationInfo.credentialPublicKey,
attestationObject: registrationInfo.attestationObject,
fmt: registrationInfo.fmt,
counter: registrationInfo.counter,
userVerified: registrationInfo.userVerified,
credentialDeviceType: registrationInfo.credentialDeviceType,
credentialBackedUp: registrationInfo.credentialBackedUp,
transports: response.response.transports,
};
}
@bindThis
public async initiateAuthentication(userId: MiUser['id']): Promise<PublicKeyCredentialRequestOptionsJSON> {
const keys = await this.userSecurityKeysRepository.findBy({
userId: userId,
});
if (keys.length === 0) {
throw new IdentifiableError('f27fd449-9af4-4841-9249-1f989b9fa4a4', 'no keys found');
}
const authenticationOptions = await generateAuthenticationOptions({
allowCredentials: keys.map(key => (<PublicKeyCredentialDescriptorFuture>{
id: Buffer.from(key.id, 'base64url'),
type: 'public-key',
transports: key.transports ?? undefined,
})),
userVerification: 'preferred',
});
await this.redisClient.setex(`webauthn:challenge:${userId}`, 90, authenticationOptions.challenge);
return authenticationOptions;
}
@bindThis
public async verifyAuthentication(userId: MiUser['id'], response: AuthenticationResponseJSON): Promise<boolean> {
const challenge = await this.redisClient.get(`webauthn:challenge:${userId}`);
if (!challenge) {
throw new IdentifiableError('2d16e51c-007b-4edd-afd2-f7dd02c947f6', 'challenge not found');
}
await this.redisClient.del(`webauthn:challenge:${userId}`);
const key = await this.userSecurityKeysRepository.findOneBy({
id: response.id,
userId: userId,
});
if (!key) {
throw new IdentifiableError('36b96a7d-b547-412d-aeed-2d611cdc8cdc', 'unknown key');
}
// マイグレーション
if (key.counter === 0 && key.publicKey.length === 87) {
const cert = new Uint8Array(Buffer.from(key.publicKey, 'base64url'));
if (cert[0] === 0x04) { // 前の実装ではいつも 0x04 で始まっていた
const halfLength = (cert.length - 1) / 2;
const cborMap = new Map<number, number | ArrayBufferLike>();
cborMap.set(1, 2); // kty, EC2
cborMap.set(3, -7); // alg, ES256
cborMap.set(-1, 1); // crv, P256
cborMap.set(-2, cert.slice(1, halfLength + 1)); // x
cborMap.set(-3, cert.slice(halfLength + 1)); // y
const cborPubKey = Buffer.from(isoCBOR.encode(cborMap)).toString('base64url');
await this.userSecurityKeysRepository.update({
id: response.id,
userId: userId,
}, {
publicKey: cborPubKey,
});
key.publicKey = cborPubKey;
}
}
const relyingParty = await this.getRelyingParty();
let verification;
try {
verification = await verifyAuthenticationResponse({
response: response,
expectedChallenge: challenge,
expectedOrigin: relyingParty.origin,
expectedRPID: relyingParty.rpId,
authenticator: {
credentialID: Buffer.from(key.id, 'base64url'),
credentialPublicKey: Buffer.from(key.publicKey, 'base64url'),
counter: key.counter,
transports: key.transports ? key.transports as AuthenticatorTransportFuture[] : undefined,
},
requireUserVerification: true,
});
} catch (error) {
console.error(error);
throw new IdentifiableError('b18c89a7-5b5e-4cec-bb5b-0419f332d430', 'verification failed');
}
const { verified, authenticationInfo } = verification;
if (!verified) {
return false;
}
await this.userSecurityKeysRepository.update({
id: response.id,
userId: userId,
}, {
lastUsed: new Date(),
counter: authenticationInfo.newCounter,
credentialDeviceType: authenticationInfo.credentialDeviceType,
credentialBackedUp: authenticationInfo.credentialBackedUp,
});
return verified;
}
}

View file

@ -23,7 +23,7 @@ import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable()
export class ChartManagementService implements OnApplicationShutdown {
private charts;
private saveIntervalId: NodeJS.Timer;
private saveIntervalId: NodeJS.Timeout;
constructor(
private federationChart: FederationChart,

View file

@ -6,7 +6,6 @@
import { Module } from '@nestjs/common';
import { CoreModule } from '@/core/CoreModule.js';
import { GlobalModule } from '@/GlobalModule.js';
import { JanitorService } from './JanitorService.js';
import { QueueStatsService } from './QueueStatsService.js';
import { ServerStatsService } from './ServerStatsService.js';
@ -16,12 +15,10 @@ import { ServerStatsService } from './ServerStatsService.js';
CoreModule,
],
providers: [
JanitorService,
QueueStatsService,
ServerStatsService,
],
exports: [
JanitorService,
QueueStatsService,
ServerStatsService,
],

View file

@ -1,50 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { AttestationChallengesRepository } from '@/models/index.js';
import { bindThis } from '@/decorators.js';
import type { OnApplicationShutdown } from '@nestjs/common';
const interval = 30 * 60 * 1000;
@Injectable()
export class JanitorService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer;
constructor(
@Inject(DI.attestationChallengesRepository)
private attestationChallengesRepository: AttestationChallengesRepository,
) {
}
/**
* Clean up database occasionally
*/
@bindThis
public start(): void {
const tick = async () => {
await this.attestationChallengesRepository.delete({
createdAt: LessThan(new Date(new Date().getTime() - 5 * 60 * 1000)),
});
};
tick();
this.intervalId = setInterval(tick, interval);
}
@bindThis
public dispose(): void {
clearInterval(this.intervalId);
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}

View file

@ -19,7 +19,7 @@ const interval = 10000;
@Injectable()
export class QueueStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer;
private intervalId: NodeJS.Timeout;
constructor(
@Inject(DI.config)

View file

@ -20,7 +20,7 @@ const round = (num: number) => Math.round(num * 10) / 10;
@Injectable()
export class ServerStatsService implements OnApplicationShutdown {
private intervalId: NodeJS.Timer | null = null;
private intervalId: NodeJS.Timeout | null = null;
constructor(
private metaService: MetaService,

View file

@ -26,7 +26,6 @@ export const DI = {
userProfilesRepository: Symbol('userProfilesRepository'),
userKeypairsRepository: Symbol('userKeypairsRepository'),
userPendingsRepository: Symbol('userPendingsRepository'),
attestationChallengesRepository: Symbol('attestationChallengesRepository'),
userSecurityKeysRepository: Symbol('userSecurityKeysRepository'),
userPublickeysRepository: Symbol('userPublickeysRepository'),
userListsRepository: Symbol('userListsRepository'),

View file

@ -193,7 +193,7 @@ function nothingToDo<T, V = T>(value: T): V {
export class MemoryKVCache<T, V = T> {
public cache: Map<string, { date: number; value: V; }>;
private lifetime: number;
private gcIntervalHandle: NodeJS.Timer;
private gcIntervalHandle: NodeJS.Timeout;
private toMapConverter: (value: T) => V;
private fromMapConverter: (cached: V) => T | undefined;

View file

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
// AIDX
// 長さ8の[2000年1月1日からの経過ミリ秒をbase36でエンコードしたもの] + 長さ4の[個体ID] + 長さ4の[カウンタ]
// (c) mei23
// https://misskey.m544.net/notes/71899acdcc9859ec5708ac24
import { customAlphabet } from 'nanoid';
export const aidxRegExp = /^[0-9a-z]{16}$/;
const TIME2000 = 946684800000;
const TIME_LENGTH = 8;
const NODE_LENGTH = 4;
const NOISE_LENGTH = 4;
const nodeId = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', NODE_LENGTH)();
let counter = 0;
function getTime(time: number): string {
time = time - TIME2000;
if (time < 0) time = 0;
return time.toString(36).padStart(TIME_LENGTH, '0').slice(-TIME_LENGTH);
}
function getNoise(): string {
return counter.toString(36).padStart(NOISE_LENGTH, '0').slice(-NOISE_LENGTH);
}
export function genAidx(date: Date): string {
const t = date.getTime();
if (isNaN(t)) throw new Error('Failed to create AIDX: Invalid Date');
counter++;
return getTime(t) + nodeId + getNoise();
}
export function parseAidx(id: string): { date: Date; } {
const time = parseInt(id.slice(0, TIME_LENGTH), 36) + TIME2000;
return { date: new Date(time) };
}

View file

@ -5,7 +5,7 @@
import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAttestationChallenge, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
import { MiAbuseUserReport, MiAccessToken, MiAd, MiAnnouncement, MiAnnouncementRead, MiAntenna, MiApp, MiAuthSession, MiBlocking, MiChannel, MiChannelFavorite, MiChannelFollowing, MiClip, MiClipFavorite, MiClipNote, MiDriveFile, MiDriveFolder, MiEmoji, MiFlash, MiFlashLike, MiFollowRequest, MiFollowing, MiGalleryLike, MiGalleryPost, MiHashtag, MiInstance, MiMeta, MiModerationLog, MiMutedNote, MiMuting, MiNote, MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, MiNoteUnread, MiPage, MiPageLike, MiPasswordResetRequest, MiPoll, MiPollVote, MiPromoNote, MiPromoRead, MiRegistrationTicket, MiRegistryItem, MiRelay, MiRenoteMuting, MiRetentionAggregation, MiRole, MiRoleAssignment, MiSignin, MiSwSubscription, MiUsedUsername, MiUser, MiUserIp, MiUserKeypair, MiUserList, MiUserListFavorite, MiUserListJoining, MiUserMemo, MiUserNotePining, MiUserPending, MiUserProfile, MiUserPublickey, MiUserSecurityKey, MiWebhook } from './index.js';
import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common';
@ -93,12 +93,6 @@ const $userPendingsRepository: Provider = {
inject: [DI.db],
};
const $attestationChallengesRepository: Provider = {
provide: DI.attestationChallengesRepository,
useFactory: (db: DataSource) => db.getRepository(MiAttestationChallenge),
inject: [DI.db],
};
const $userSecurityKeysRepository: Provider = {
provide: DI.userSecurityKeysRepository,
useFactory: (db: DataSource) => db.getRepository(MiUserSecurityKey),
@ -423,7 +417,6 @@ const $userMemosRepository: Provider = {
$userProfilesRepository,
$userKeypairsRepository,
$userPendingsRepository,
$attestationChallengesRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,
@ -491,7 +484,6 @@ const $userMemosRepository: Provider = {
$userProfilesRepository,
$userKeypairsRepository,
$userPendingsRepository,
$attestationChallengesRepository,
$userSecurityKeysRepository,
$userPublickeysRepository,
$userListsRepository,

View file

@ -1,51 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, JoinColumn, Column, ManyToOne, Index } from 'typeorm';
import { id } from '../id.js';
import { MiUser } from './User.js';
@Entity('attestation_challenge')
export class MiAttestationChallenge {
@PrimaryColumn(id())
public id: string;
@Index()
@PrimaryColumn(id())
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Index()
@Column('varchar', {
length: 64,
comment: 'Hex-encoded sha256 hash of the challenge.',
})
public challenge: string;
@Column('timestamp with time zone', {
comment: 'The date challenge was created for expiry purposes.',
})
public createdAt: Date;
@Column('boolean', {
comment:
'Indicates that the challenge is only for registration purposes if true to prevent the challenge for being used as authentication.',
default: false,
})
public registrationChallenge: boolean;
constructor(data: Partial<MiAttestationChallenge>) {
if (data == null) return;
for (const [k, v] of Object.entries(data)) {
(this as any)[k] = v;
}
}
}

View file

@ -33,6 +33,7 @@ export type MiNotification = {
* followRequestAccepted -
* achievementEarned -
* app -
* test -
*/
type: typeof notificationTypes[number];

View file

@ -24,25 +24,48 @@ export class MiUserSecurityKey {
@JoinColumn()
public user: MiUser | null;
@Index()
@Column('varchar', {
comment:
'Variable-length public key used to verify attestations (hex-encoded).',
})
public publicKey: string;
@Column('timestamp with time zone', {
comment:
'The date of the last time the UserSecurityKey was successfully validated.',
})
public lastUsed: Date;
@Column('varchar', {
comment: 'User-defined name for this key',
length: 30,
})
public name: string;
@Index()
@Column('varchar', {
comment: 'The public key of the UserSecurityKey, hex-encoded.',
})
public publicKey: string;
@Column('bigint', {
comment: 'The number of times the UserSecurityKey was validated.',
default: 0,
})
public counter: number;
@Column('timestamp with time zone', {
comment: 'Timestamp of the last time the UserSecurityKey was used.',
default: () => 'now()',
})
public lastUsed: Date;
@Column('varchar', {
comment: 'The type of Backup Eligibility in authenticator data',
length: 32, nullable: true,
})
public credentialDeviceType: string | null;
@Column('boolean', {
comment: 'Whether or not the credential has been backed up',
nullable: true,
})
public credentialBackedUp: boolean | null;
@Column('varchar', {
comment: 'The type of the credential returned by the browser',
length: 32, array: true, nullable: true,
})
public transports: string[] | null;
constructor(data: Partial<MiUserSecurityKey>) {
if (data == null) return;

View file

@ -10,7 +10,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
import { MiAntenna } from '@/models/entities/Antenna.js';
import { MiApp } from '@/models/entities/App.js';
import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
import { MiAuthSession } from '@/models/entities/AuthSession.js';
import { MiBlocking } from '@/models/entities/Blocking.js';
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@ -79,7 +78,6 @@ export {
MiAnnouncementRead,
MiAntenna,
MiApp,
MiAttestationChallenge,
MiAuthSession,
MiBlocking,
MiChannelFollowing,
@ -147,7 +145,6 @@ export type AnnouncementsRepository = Repository<MiAnnouncement>;
export type AnnouncementReadsRepository = Repository<MiAnnouncementRead>;
export type AntennasRepository = Repository<MiAntenna>;
export type AppsRepository = Repository<MiApp>;
export type AttestationChallengesRepository = Repository<MiAttestationChallenge>;
export type AuthSessionsRepository = Repository<MiAuthSession>;
export type BlockingsRepository = Repository<MiBlocking>;
export type ChannelFollowingsRepository = Repository<MiChannelFollowing>;

View file

@ -18,7 +18,6 @@ import { MiAnnouncement } from '@/models/entities/Announcement.js';
import { MiAnnouncementRead } from '@/models/entities/AnnouncementRead.js';
import { MiAntenna } from '@/models/entities/Antenna.js';
import { MiApp } from '@/models/entities/App.js';
import { MiAttestationChallenge } from '@/models/entities/AttestationChallenge.js';
import { MiAuthSession } from '@/models/entities/AuthSession.js';
import { MiBlocking } from '@/models/entities/Blocking.js';
import { MiChannelFollowing } from '@/models/entities/ChannelFollowing.js';
@ -143,7 +142,6 @@ export const entities = [
MiUserNotePining,
MiUserSecurityKey,
MiUsedUsername,
MiAttestationChallenge,
MiFollowing,
MiFollowRequest,
MiMuting,

View file

@ -10,6 +10,7 @@ import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepositor
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { Config } from '@/config.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
@ -18,6 +19,9 @@ export class CleanProcessorService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository,
@ -54,12 +58,14 @@ export class CleanProcessorService {
reason: 'word',
});
// 7日以上使われてないアンテナを停止
this.antennasRepository.update({
lastUsedAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 7))),
}, {
isActive: false,
});
// 使われてないアンテナを停止
if (this.config.deactivateAntennaThreshold > 0) {
this.antennasRepository.update({
lastUsedAt: LessThan(new Date(Date.now() - this.config.deactivateAntennaThreshold)),
}, {
isActive: false,
});
}
const expiredRoleAssignments = await this.roleAssignmentsRepository.createQueryBuilder('assign')
.where('assign.expiresAt IS NOT NULL')

View file

@ -35,10 +35,10 @@ export class NodeinfoServerService {
@bindThis
public getLinks() {
return [/* (awaiting release) {
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
href: config.url + nodeinfo2_1path
}, */{
return [{
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.1',
href: this.config.url + nodeinfo2_1path
}, {
rel: 'http://nodeinfo.diaspora.software/ns/schema/2.0',
href: this.config.url + nodeinfo2_0path,
}];
@ -46,7 +46,7 @@ export class NodeinfoServerService {
@bindThis
public createServer(fastify: FastifyInstance, options: FastifyPluginOptions, done: (err?: Error) => void) {
const nodeinfo2 = async () => {
const nodeinfo2 = async (version: number) => {
const now = Date.now();
const notesChart = await this.notesChart.getChart('hour', 1, null);
@ -73,11 +73,11 @@ export class NodeinfoServerService {
const basePolicies = { ...DEFAULT_POLICIES, ...meta.policies };
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const document: any = {
software: {
name: 'misskey',
version: this.config.version,
repository: meta.repositoryUrl,
},
protocols: ['activitypub'],
services: {
@ -114,23 +114,36 @@ export class NodeinfoServerService {
themeColor: meta.themeColor ?? '#86b300',
},
};
if (version >= 21) {
document.software.repository = meta.repositoryUrl;
document.software.homepage = meta.repositoryUrl;
}
return document;
};
const cache = new MemorySingleCache<Awaited<ReturnType<typeof nodeinfo2>>>(1000 * 60 * 10);
fastify.get(nodeinfo2_1path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2());
const base = await cache.fetch(() => nodeinfo2(21));
reply.header('Cache-Control', 'public, max-age=600');
reply
.type(
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.1#"',
)
.header('Cache-Control', 'public, max-age=600');
return { version: '2.1', ...base };
});
fastify.get(nodeinfo2_0path, async (request, reply) => {
const base = await cache.fetch(() => nodeinfo2());
const base = await cache.fetch(() => nodeinfo2(20));
delete (base as any).software.repository;
reply.header('Cache-Control', 'public, max-age=600');
reply
.type(
'application/json; profile="http://nodeinfo.diaspora.software/ns/schema/2.0#"',
)
.header('Cache-Control', 'public, max-age=600');
return { version: '2.0', ...base };
});

View file

@ -35,7 +35,7 @@ const accessDenied = {
export class ApiCallService implements OnApplicationShutdown {
private logger: Logger;
private userIpHistories: Map<MiUser['id'], Set<string>>;
private userIpHistoriesClearIntervalId: NodeJS.Timer;
private userIpHistoriesClearIntervalId: NodeJS.Timeout;
constructor(
@Inject(DI.userIpsRepository)

View file

@ -284,6 +284,7 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
import * as ep___notifications_create from './endpoints/notifications/create.js';
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
import * as ep___pagePush from './endpoints/page-push.js';
import * as ep___pages_create from './endpoints/pages/create.js';
import * as ep___pages_delete from './endpoints/pages/delete.js';
@ -631,6 +632,7 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default };
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default };
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default };
const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default };
@ -982,6 +984,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_userListTimeline,
$notifications_create,
$notifications_markAllAsRead,
$notifications_testNotification,
$pagePush,
$pages_create,
$pages_delete,

View file

@ -3,22 +3,26 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import * as OTPAuth from 'otpauth';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { UserSecurityKeysRepository, SigninsRepository, UserProfilesRepository, AttestationChallengesRepository, UsersRepository } from '@/models/index.js';
import type {
SigninsRepository,
UserProfilesRepository,
UsersRepository,
} from '@/models/index.js';
import type { Config } from '@/config.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import type { MiLocalUser } from '@/models/entities/User.js';
import { IdService } from '@/core/IdService.js';
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
import { bindThis } from '@/decorators.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { RateLimiterService } from './RateLimiterService.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { AuthenticationResponseJSON } from '@simplewebauthn/typescript-types';
import type { FastifyReply, FastifyRequest } from 'fastify';
@Injectable()
export class SigninApiService {
@ -29,22 +33,16 @@ export class SigninApiService {
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.attestationChallengesRepository)
private attestationChallengesRepository: AttestationChallengesRepository,
@Inject(DI.signinsRepository)
private signinsRepository: SigninsRepository,
private idService: IdService,
private rateLimiterService: RateLimiterService,
private signinService: SigninService,
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
private webAuthnService: WebAuthnService,
) {
}
@ -55,11 +53,7 @@ export class SigninApiService {
username: string;
password: string;
token?: string;
signature?: string;
authenticatorData?: string;
clientDataJSON?: string;
credentialId?: string;
challengeId?: string;
credential?: AuthenticationResponseJSON;
};
}>,
reply: FastifyReply,
@ -181,64 +175,16 @@ export class SigninApiService {
} else {
return this.signinService.signin(request, reply, user);
}
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
} else if (body.credential) {
if (!same && !profile.usePasswordLessLogin) {
return await fail(403, {
id: '932c904e-9460-45b7-9ce6-7ed33be7eb2c',
});
}
const clientDataJSON = Buffer.from(body.clientDataJSON, 'hex');
const clientData = JSON.parse(clientDataJSON.toString('utf-8'));
const challenge = await this.attestationChallengesRepository.findOneBy({
userId: user.id,
id: body.challengeId,
registrationChallenge: false,
challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
});
const authorized = await this.webAuthnService.verifyAuthentication(user.id, body.credential);
if (!challenge) {
return await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
}
await this.attestationChallengesRepository.delete({
userId: user.id,
id: body.challengeId,
});
if (new Date().getTime() - challenge.createdAt.getTime() >= 5 * 60 * 1000) {
return await fail(403, {
id: '2715a88a-2125-4013-932f-aa6fe72792da',
});
}
const securityKey = await this.userSecurityKeysRepository.findOneBy({
id: Buffer.from(
body.credentialId
.replace(/-/g, '+')
.replace(/_/g, '/'),
'base64',
).toString('hex'),
});
if (!securityKey) {
return await fail(403, {
id: '66269679-aeaf-4474-862b-eb761197e046',
});
}
const isValid = this.twoFactorAuthenticationService.verifySignin({
publicKey: Buffer.from(securityKey.publicKey, 'hex'),
authenticatorData: Buffer.from(body.authenticatorData, 'hex'),
clientDataJSON,
clientData,
signature: Buffer.from(body.signature, 'hex'),
challenge: challenge.challenge,
});
if (isValid) {
if (authorized) {
return this.signinService.signin(request, reply, user);
} else {
return await fail(403, {
@ -252,42 +198,11 @@ export class SigninApiService {
});
}
const keys = await this.userSecurityKeysRepository.findBy({
userId: user.id,
});
if (keys.length === 0) {
return await fail(403, {
id: 'f27fd449-9af4-4841-9249-1f989b9fa4a4',
});
}
// 32 byte challenge
const challenge = randomBytes(32).toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const challengeId = this.idService.genId();
await this.attestationChallengesRepository.insert({
userId: user.id,
id: challengeId,
challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
createdAt: new Date(),
registrationChallenge: false,
});
const authRequest = await this.webAuthnService.initiateAuthentication(user.id);
reply.code(200);
return {
challenge,
challengeId,
securityKeys: keys.map(key => ({
id: key.id,
})),
};
return authRequest;
}
// never get here
}
}

View file

@ -284,6 +284,7 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
import * as ep___notifications_create from './endpoints/notifications/create.js';
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js';
import * as ep___pagePush from './endpoints/page-push.js';
import * as ep___pages_create from './endpoints/pages/create.js';
import * as ep___pages_delete from './endpoints/pages/delete.js';
@ -629,6 +630,7 @@ const eps = [
['notes/user-list-timeline', ep___notes_userListTimeline],
['notifications/create', ep___notifications_create],
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
['notifications/test-notification', ep___notifications_testNotification],
['page-push', ep___pagePush],
['pages/create', ep___pages_create],
['pages/delete', ep___pages_delete],

View file

@ -3,155 +3,86 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promisify } from 'node:util';
import bcrypt from 'bcryptjs';
import cbor from 'cbor';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
import type { AttestationChallengesRepository, UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
const cborDecodeFirst = promisify(cbor.decodeFirst) as any;
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '0d7ec6d2-e652-443e-a7bf-9ee9a0cd77b0',
},
twoFactorNotEnabled: {
message: '2fa not enabled.',
code: 'TWO_FACTOR_NOT_ENABLED',
id: '798d6847-b1ed-4f9c-b1f9-163c42655995',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
clientDataJSON: { type: 'string' },
attestationObject: { type: 'string' },
password: { type: 'string' },
challengeId: { type: 'string' },
name: { type: 'string', minLength: 1, maxLength: 30 },
credential: { type: 'object' },
},
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
required: ['password', 'name', 'credential'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.attestationChallengesRepository)
private attestationChallengesRepository: AttestationChallengesRepository,
private webAuthnService: WebAuthnService,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
) {
super(meta, paramDef, async (ps, me) => {
const rpIdHashReal = this.twoFactorAuthenticationService.hash(Buffer.from(this.config.hostname, 'utf-8'));
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
throw new Error('incorrect password');
throw new ApiError(meta.errors.incorrectPassword);
}
if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled');
throw new ApiError(meta.errors.twoFactorNotEnabled);
}
const clientData = JSON.parse(ps.clientDataJSON);
if (clientData.type !== 'webauthn.create') {
throw new Error('not a creation attestation');
}
if (clientData.origin !== this.config.scheme + '://' + this.config.host) {
throw new Error('origin mismatch');
}
const clientDataJSONHash = this.twoFactorAuthenticationService.hash(Buffer.from(ps.clientDataJSON, 'utf-8'));
const attestation = await cborDecodeFirst(ps.attestationObject);
const rpIdHash = attestation.authData.slice(0, 32);
if (!rpIdHashReal.equals(rpIdHash)) {
throw new Error('rpIdHash mismatch');
}
const flags = attestation.authData[32];
// eslint:disable-next-line:no-bitwise
if (!(flags & 1)) {
throw new Error('user not present');
}
const authData = Buffer.from(attestation.authData);
const credentialIdLength = authData.readUInt16BE(53);
const credentialId = authData.slice(55, 55 + credentialIdLength);
const publicKeyData = authData.slice(55 + credentialIdLength);
const publicKey: Map<number, any> = await cborDecodeFirst(publicKeyData);
if (publicKey.get(3) !== -7) {
throw new Error('alg mismatch');
}
const procedures = this.twoFactorAuthenticationService.getProcedures();
if (!(procedures as any)[attestation.fmt]) {
throw new Error(`unsupported fmt: ${attestation.fmt}. Supported ones: ${Object.keys(procedures)}`);
}
const verificationData = (procedures as any)[attestation.fmt].verify({
attStmt: attestation.attStmt,
authenticatorData: authData,
clientDataHash: clientDataJSONHash,
credentialId,
publicKey,
rpIdHash,
});
if (!verificationData.valid) throw new Error('signature invalid');
const attestationChallenge = await this.attestationChallengesRepository.findOneBy({
userId: me.id,
id: ps.challengeId,
registrationChallenge: true,
challenge: this.twoFactorAuthenticationService.hash(clientData.challenge).toString('hex'),
});
if (!attestationChallenge) {
throw new Error('non-existent challenge');
}
await this.attestationChallengesRepository.delete({
userId: me.id,
id: ps.challengeId,
});
// Expired challenge (> 5min old)
if (
new Date().getTime() - attestationChallenge.createdAt.getTime() >=
5 * 60 * 1000
) {
throw new Error('expired challenge');
}
const credentialIdString = credentialId.toString('hex');
const keyInfo = await this.webAuthnService.verifyRegistration(me.id, ps.credential);
const credentialId = Buffer.from(keyInfo.credentialID).toString('base64url');
await this.userSecurityKeysRepository.insert({
id: credentialId,
userId: me.id,
id: credentialIdString,
lastUsed: new Date(),
name: ps.name,
publicKey: verificationData.publicKey.toString('hex'),
publicKey: Buffer.from(keyInfo.credentialPublicKey).toString('base64url'),
counter: keyInfo.counter,
credentialDeviceType: keyInfo.credentialDeviceType,
credentialBackedUp: keyInfo.credentialBackedUp,
transports: keyInfo.transports,
});
// Publish meUpdated event
@ -161,7 +92,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}));
return {
id: credentialIdString,
id: credentialId,
name: ps.name,
};
});

View file

@ -3,22 +3,38 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { promisify } from 'node:util';
import * as crypto from 'node:crypto';
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository, AttestationChallengesRepository } from '@/models/index.js';
import { IdService } from '@/core/IdService.js';
import { TwoFactorAuthenticationService } from '@/core/TwoFactorAuthenticationService.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
const randomBytes = promisify(crypto.randomBytes);
import { WebAuthnService } from '@/core/WebAuthnService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
userNotFound: {
message: 'User not found.',
code: 'USER_NOT_FOUND',
id: '652f899f-66d4-490e-993e-6606c8ec04c3',
},
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '38769596-efe2-4faf-9bec-abbb3f2cd9ba',
},
twoFactorNotEnabled: {
message: '2fa not enabled.',
code: 'TWO_FACTOR_NOT_ENABLED',
id: 'bf32b864-449b-47b8-974e-f9a5468546f1',
},
},
} as const;
export const paramDef = {
@ -29,53 +45,43 @@ export const paramDef = {
required: ['password'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.attestationChallengesRepository)
private attestationChallengesRepository: AttestationChallengesRepository,
private idService: IdService,
private twoFactorAuthenticationService: TwoFactorAuthenticationService,
private webAuthnService: WebAuthnService,
) {
super(meta, paramDef, async (ps, me) => {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
const profile = await this.userProfilesRepository.findOne({
where: {
userId: me.id,
},
relations: ['user'],
});
if (profile == null) {
throw new ApiError(meta.errors.userNotFound);
}
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
throw new Error('incorrect password');
throw new ApiError(meta.errors.incorrectPassword);
}
if (!profile.twoFactorEnabled) {
throw new Error('2fa not enabled');
throw new ApiError(meta.errors.twoFactorNotEnabled);
}
// 32 byte challenge
const entropy = await randomBytes(32);
const challenge = entropy.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
const challengeId = this.idService.genId();
await this.attestationChallengesRepository.insert({
userId: me.id,
id: challengeId,
challenge: this.twoFactorAuthenticationService.hash(Buffer.from(challenge, 'utf-8')).toString('hex'),
createdAt: new Date(),
registrationChallenge: true,
});
return {
challengeId,
challenge,
};
return await this.webAuthnService.initiateRegistration(
me.id,
profile.user?.username ?? me.id,
profile.user?.name ?? undefined,
);
});
}
}

View file

@ -11,11 +11,20 @@ import type { UserProfilesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '78d6c839-20c9-4c66-b90a-fc0542168b48',
},
},
} as const;
export const paramDef = {
@ -39,10 +48,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
throw new Error('incorrect password');
throw new ApiError(meta.errors.incorrectPassword);
}
// Generate user's secret key

View file

@ -10,11 +10,20 @@ import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/model
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '141c598d-a825-44c8-9173-cfb9d92be493',
},
},
} as const;
export const paramDef = {
@ -42,10 +51,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
throw new Error('incorrect password');
throw new ApiError(meta.errors.incorrectPassword);
}
// Make sure we only delete the user's own creds

View file

@ -10,11 +10,20 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
incorrectPassword: {
message: 'Incorrect password.',
code: 'INCORRECT_PASSWORD',
id: '7add0395-9901-4098-82f9-4f67af65f775',
},
},
} as const;
export const paramDef = {
@ -38,10 +47,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
// Compare password
const same = await bcrypt.compare(ps.password, profile.password!);
const same = await bcrypt.compare(ps.password, profile.password ?? '');
if (!same) {
throw new Error('incorrect password');
throw new ApiError(meta.errors.incorrectPassword);
}
await this.userProfilesRepository.update(me.id, {

View file

@ -25,7 +25,7 @@ export const meta = {
},
accessDenied: {
message: 'You do not have edit privilege of the channel.',
message: 'You do not have edit privilege of this key.',
code: 'ACCESS_DENIED',
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
},

View file

@ -34,12 +34,12 @@ export const paramDef = {
properties: {
name: { type: 'string', minLength: 1, maxLength: 100 },
url: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', maxLength: 1024, default: '' },
on: { type: 'array', items: {
type: 'string', enum: webhookEventTypes,
} },
},
required: ['name', 'url', 'secret', 'on'],
required: ['name', 'url', 'on'],
} as const;
// TODO: ロジックをサービスに切り出す

View file

@ -34,13 +34,13 @@ export const paramDef = {
webhookId: { type: 'string', format: 'misskey:id' },
name: { type: 'string', minLength: 1, maxLength: 100 },
url: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', minLength: 1, maxLength: 1024 },
secret: { type: 'string', maxLength: 1024, default: '' },
on: { type: 'array', items: {
type: 'string', enum: webhookEventTypes,
} },
active: { type: 'boolean' },
},
required: ['webhookId', 'name', 'url', 'secret', 'on', 'active'],
required: ['webhookId', 'name', 'url', 'on', 'active'],
} as const;
// TODO: ロジックをサービスに切り出す

View file

@ -63,6 +63,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.where(`'{"${me.id}"}' <@ note.mentions`)
.orWhere(`'{"${me.id}"}' <@ note.visibleUserIds`);
}))
// Avoid scanning primary key index
.orderBy('CONCAT(note.id)', 'DESC')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')

View file

@ -69,7 +69,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
// パフォーマンス上の利点が無さそう?
//.andWhere('note.id > :minId', { minId: this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 10))) }) // 10日前まで
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')

View file

@ -0,0 +1,33 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NotificationService } from '@/core/NotificationService.js';
export const meta = {
tags: ['notifications'],
requireCredential: true,
kind: 'write:notifications',
} as const;
export const paramDef = {
type: 'object',
properties: {},
required: [],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, user) => {
this.notificationService.createNotification(user.id, 'test', {});
});
}
}

View file

@ -80,9 +80,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
query.andWhere(new Brackets(qb => {
qb.orWhere('note.channelId IS NULL');
qb.orWhere('channel.isSensitive = false');
}));
this.queryService.generateVisibilityQuery(query, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me, user);

View file

@ -19,7 +19,7 @@ class UserListChannel extends Channel {
public static requireCredential = false;
private listId: string;
public listUsers: MiUser['id'][] = [];
private listUsersClock: NodeJS.Timer;
private listUsersClock: NodeJS.Timeout;
constructor(
private userListsRepository: UserListsRepository,

View file

@ -35,7 +35,7 @@ export default class Connection {
public userIdsWhoMeMuting: Set<string> = new Set();
public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
private fetchIntervalId: NodeJS.Timer | null = null;
private fetchIntervalId: NodeJS.Timeout | null = null;
constructor(
private channelsService: ChannelsService,

View file

@ -37,7 +37,6 @@ import { deepClone } from '@/misc/clone.js';
import { bindThis } from '@/decorators.js';
import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import manifest from './manifest.json' assert { type: 'json' };
import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js';
@ -52,6 +51,45 @@ const assets = `${_dirname}/../../../../../built/_frontend_dist_/`;
const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
const viteOut = `${_dirname}/../../../../../built/_vite_/`;
const manifest = {
'short_name': 'Misskey',
'name': 'Misskey',
'start_url': '/',
'display': 'standalone',
'background_color': '#313a42',
'theme_color': '#86b300',
'icons': [
{
'src': '/static-assets/icons/192.png',
'sizes': '192x192',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': '/static-assets/icons/512.png',
'sizes': '512x512',
'type': 'image/png',
'purpose': 'maskable',
},
{
'src': '/static-assets/splash.png',
'sizes': '300x300',
'type': 'image/png',
'purpose': 'any',
},
],
'share_target': {
'action': '/share/',
'method': 'GET',
'enctype': 'application/x-www-form-urlencoded',
'params': {
'title': 'title',
'text': 'text',
'url': 'url',
},
},
};
@Injectable()
export class ClientServerService {
private logger: Logger;

View file

@ -7,15 +7,15 @@ doctype html
//
-
_____ _ _
| |_|___ ___| |_ ___ _ _
_____ _ _
| |_|___ ___| |_ ___ _ _
| | | | |_ -|_ -| '_| -_| | |
|_|_|_|_|___|___|_,_|___|_ |
|___|
Thank you for using Misskey!
If you are reading this message... how about joining the development?
https://github.com/misskey-dev/misskey
html
@ -35,7 +35,7 @@ html
link(rel='prefetch' href=infoImageUrl)
link(rel='prefetch' href=notFoundImageUrl)
//- https://github.com/misskey-dev/misskey/issues/9842
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.25.0')
link(rel='stylesheet' href='/assets/tabler-icons/tabler-icons.min.css?v2.32.0')
link(rel='modulepreload' href=`/vite/${clientEntry.file}`)
if !config.clientManifestExists

View file

@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app', 'test'] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;

View file

@ -9,8 +9,16 @@ import * as assert from 'assert';
import * as crypto from 'node:crypto';
import cbor from 'cbor';
import * as OTPAuth from 'otpauth';
import { loadConfig } from '../../src/config.js';
import { signup, api, post, react, startServer, waitFire } from '../utils.js';
import { loadConfig } from '@/config.js';
import { api, signup, startServer } from '../utils.js';
import type {
AuthenticationResponseJSON,
AuthenticatorAssertionResponseJSON,
AuthenticatorAttestationResponseJSON,
PublicKeyCredentialCreationOptionsJSON,
PublicKeyCredentialRequestOptionsJSON,
RegistrationResponseJSON,
} from '@simplewebauthn/typescript-types';
import type { INestApplicationContext } from '@nestjs/common';
import type * as misskey from 'misskey-js';
@ -47,21 +55,18 @@ describe('2要素認証', () => {
const rpIdHash = (): Buffer => {
return crypto.createHash('sha256')
.update(Buffer.from(config.hostname, 'utf-8'))
.update(Buffer.from(config.host, 'utf-8'))
.digest();
};
const keyDoneParam = (param: {
keyName: string,
challengeId: string,
challenge: string,
credentialId: Buffer,
creationOptions: PublicKeyCredentialCreationOptionsJSON,
}): {
attestationObject: string,
challengeId: string,
clientDataJSON: string,
password: string,
name: string,
credential: RegistrationResponseJSON,
} => {
// A COSE encoded public key
const credentialPublicKey = cbor.encode(new Map<number, unknown>([
@ -76,7 +81,7 @@ describe('2要素認証', () => {
// AuthenticatorAssertionResponse.authenticatorData
// https://developer.mozilla.org/en-US/docs/Web/API/AuthenticatorAssertionResponse/authenticatorData
const credentialIdLength = Buffer.allocUnsafe(2);
credentialIdLength.writeUInt16BE(param.credentialId.length);
credentialIdLength.writeUInt16BE(param.credentialId.length, 0);
const authData = Buffer.concat([
rpIdHash(), // rpIdHash(32)
Buffer.from([0x45]), // flags(1)
@ -88,20 +93,27 @@ describe('2要素認証', () => {
]);
return {
attestationObject: cbor.encode({
fmt: 'none',
attStmt: {},
authData,
}).toString('hex'),
challengeId: param.challengeId,
clientDataJSON: JSON.stringify({
type: 'webauthn.create',
challenge: param.challenge,
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}),
password,
name: param.keyName,
credential: <RegistrationResponseJSON>{
id: param.credentialId.toString('base64url'),
rawId: param.credentialId.toString('base64url'),
response: <AuthenticatorAttestationResponseJSON>{
clientDataJSON: Buffer.from(JSON.stringify({
type: 'webauthn.create',
challenge: param.creationOptions.challenge,
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}), 'utf-8').toString('base64url'),
attestationObject: cbor.encode({
fmt: 'none',
attStmt: {},
authData,
}).toString('base64url'),
},
clientExtensionResults: {},
type: 'public-key',
},
};
};
@ -121,17 +133,12 @@ describe('2要素認証', () => {
const signinWithSecurityKeyParam = (param: {
keyName: string,
challengeId: string,
challenge: string,
credentialId: Buffer,
requestOptions: PublicKeyCredentialRequestOptionsJSON,
}): {
authenticatorData: string,
credentialId: string,
challengeId: string,
clientDataJSON: string,
username: string,
password: string,
signature: string,
credential: AuthenticationResponseJSON,
'g-recaptcha-response'?: string | null,
'hcaptcha-response'?: string | null,
} => {
@ -144,10 +151,10 @@ describe('2要素認証', () => {
]);
const clientDataJSONBuffer = Buffer.from(JSON.stringify({
type: 'webauthn.get',
challenge: param.challenge,
challenge: param.requestOptions.challenge,
origin: config.scheme + '://' + config.host,
androidPackageName: 'org.mozilla.firefox',
}));
}), 'utf-8');
const hashedclientDataJSON = crypto.createHash('sha256')
.update(clientDataJSONBuffer)
.digest();
@ -156,13 +163,19 @@ describe('2要素認証', () => {
.update(Buffer.concat([authenticatorData, hashedclientDataJSON]))
.sign(privateKey);
return {
authenticatorData: authenticatorData.toString('hex'),
credentialId: param.credentialId.toString('base64'),
challengeId: param.challengeId,
clientDataJSON: clientDataJSONBuffer.toString('hex'),
username,
password,
signature: signature.toString('hex'),
credential: <AuthenticationResponseJSON>{
id: param.credentialId.toString('base64url'),
rawId: param.credentialId.toString('base64url'),
response: <AuthenticatorAssertionResponseJSON>{
clientDataJSON: clientDataJSONBuffer.toString('base64url'),
authenticatorData: authenticatorData.toString('base64url'),
signature: signature.toString('base64url'),
},
clientExtensionResults: {},
type: 'public-key',
},
'g-recaptcha-response': null,
'hcaptcha-response': null,
};
@ -222,19 +235,18 @@ describe('2要素認証', () => {
password,
}, alice);
assert.strictEqual(registerKeyResponse.status, 200);
assert.notEqual(registerKeyResponse.body.challengeId, undefined);
assert.notEqual(registerKeyResponse.body.rp, undefined);
assert.notEqual(registerKeyResponse.body.challenge, undefined);
const keyName = 'example-key';
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId,
creationOptions: registerKeyResponse.body,
}), alice);
assert.strictEqual(keyDoneResponse.status, 200);
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('hex'));
assert.strictEqual(keyDoneResponse.body.id, credentialId.toString('base64url'));
assert.strictEqual(keyDoneResponse.body.name, keyName);
const usersShowResponse = await api('/users/show', {
@ -248,16 +260,14 @@ describe('2要素認証', () => {
});
assert.strictEqual(signinResponse.status, 200);
assert.strictEqual(signinResponse.body.i, undefined);
assert.notEqual(signinResponse.body.challengeId, undefined);
assert.notEqual(signinResponse.body.challenge, undefined);
assert.notEqual(signinResponse.body.securityKeys, undefined);
assert.strictEqual(signinResponse.body.securityKeys[0].id, credentialId.toString('hex'));
assert.notEqual(signinResponse.body.allowCredentials, undefined);
assert.strictEqual(signinResponse.body.allowCredentials[0].id, credentialId.toString('base64url'));
const signinResponse2 = await api('/signin', signinWithSecurityKeyParam({
keyName,
challengeId: signinResponse.body.challengeId,
challenge: signinResponse.body.challenge,
credentialId,
requestOptions: signinResponse.body,
}));
assert.strictEqual(signinResponse2.status, 200);
assert.notEqual(signinResponse2.body.i, undefined);
@ -283,9 +293,8 @@ describe('2要素認証', () => {
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId,
creationOptions: registerKeyResponse.body,
}), alice);
assert.strictEqual(keyDoneResponse.status, 200);
@ -310,9 +319,8 @@ describe('2要素認証', () => {
const signinResponse2 = await api('/signin', {
...signinWithSecurityKeyParam({
keyName,
challengeId: signinResponse.body.challengeId,
challenge: signinResponse.body.challenge,
credentialId,
requestOptions: signinResponse.body,
}),
password: '',
});
@ -340,23 +348,22 @@ describe('2要素認証', () => {
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId,
creationOptions: registerKeyResponse.body,
}), alice);
assert.strictEqual(keyDoneResponse.status, 200);
const renamedKey = 'other-key';
const updateKeyResponse = await api('/i/2fa/update-key', {
name: renamedKey,
credentialId: credentialId.toString('hex'),
credentialId: credentialId.toString('base64url'),
}, alice);
assert.strictEqual(updateKeyResponse.status, 200);
const iResponse = await api('/i', {
}, alice);
assert.strictEqual(iResponse.status, 200);
const securityKeys = iResponse.body.securityKeysList.filter(s => s.id === credentialId.toString('hex'));
const securityKeys = iResponse.body.securityKeysList.filter((s: { id: string; }) => s.id === credentialId.toString('base64url'));
assert.strictEqual(securityKeys.length, 1);
assert.strictEqual(securityKeys[0].name, renamedKey);
assert.notEqual(securityKeys[0].lastUsed, undefined);
@ -382,9 +389,8 @@ describe('2要素認証', () => {
const credentialId = crypto.randomBytes(0x41);
const keyDoneResponse = await api('/i/2fa/key-done', keyDoneParam({
keyName,
challengeId: registerKeyResponse.body.challengeId,
challenge: registerKeyResponse.body.challenge,
credentialId,
creationOptions: registerKeyResponse.body,
}), alice);
assert.strictEqual(keyDoneResponse.status, 200);

View file

@ -10,8 +10,8 @@
"declaration": false,
"sourceMap": true,
"target": "ES2022",
"module": "es2020",
"moduleResolution": "node16",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true,
"removeComments": false,
"noLib": false,

View file

@ -10,9 +10,9 @@ import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { GlobalModule } from '@/GlobalModule.js';
import { AnnouncementService } from '@/core/AnnouncementService.js';
import type { Announcement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, User } from '@/models/index.js';
import type { MiAnnouncement, AnnouncementsRepository, AnnouncementReadsRepository, UsersRepository, MiUser } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { genAid } from '@/misc/id/aid.js';
import { genAidx } from '@/misc/id/aidx.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@ -30,10 +30,10 @@ describe('AnnouncementService', () => {
let announcementReadsRepository: AnnouncementReadsRepository;
let globalEventService: jest.Mocked<GlobalEventService>;
function createUser(data: Partial<User> = {}) {
function createUser(data: Partial<MiUser> = {}) {
const un = secureRndstr(16);
return usersRepository.insert({
id: genAid(new Date()),
id: genAidx(new Date()),
createdAt: new Date(),
username: un,
usernameLower: un,
@ -42,9 +42,9 @@ describe('AnnouncementService', () => {
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
}
function createAnnouncement(data: Partial<Announcement> = {}) {
function createAnnouncement(data: Partial<MiAnnouncement> = {}) {
return announcementsRepository.insert({
id: genAid(new Date()),
id: genAidx(new Date()),
createdAt: new Date(),
updatedAt: null,
title: 'Title',

View file

@ -6,7 +6,6 @@
process.env.NODE_ENV = 'test';
import { jest } from '@jest/globals';
import { ModuleMocker } from 'jest-mock';
import { Test } from '@nestjs/testing';
import { Redis } from 'ioredis';
import { GlobalModule } from '@/GlobalModule.js';
@ -18,7 +17,6 @@ import { UtilityService } from '@/core/UtilityService.js';
import { IdService } from '@/core/IdService.js';
import { DI } from '@/di-symbols.js';
import type { TestingModule } from '@nestjs/testing';
import type { MockFunctionMetadata } from 'jest-mock';
function mockRedis() {
const hash = {};
@ -35,9 +33,9 @@ describe('FetchInstanceMetadataService', () => {
let fetchInstanceMetadataService: jest.Mocked<FetchInstanceMetadataService>;
let federatedInstanceService: jest.Mocked<FederatedInstanceService>;
let httpRequestService: jest.Mocked<HttpRequestService>;
let redisClient: jest.Mocked<Redis.Redis>;
let redisClient: jest.Mocked<Redis>;
beforeAll(async () => {
beforeEach(async () => {
app = await Test
.createTestingModule({
imports: [
@ -64,11 +62,11 @@ describe('FetchInstanceMetadataService', () => {
fetchInstanceMetadataService = app.get<FetchInstanceMetadataService>(FetchInstanceMetadataService);
federatedInstanceService = app.get<FederatedInstanceService>(FederatedInstanceService) as jest.Mocked<FederatedInstanceService>;
redisClient = app.get<Redis.Redis>(DI.redis) as jest.Mocked<Redis.Redis>;
redisClient = app.get<Redis>(DI.redis) as jest.Mocked<Redis>;
httpRequestService = app.get<HttpRequestService>(HttpRequestService) as jest.Mocked<HttpRequestService>;
});
afterAll(async () => {
afterEach(async () => {
await app.close();
});
@ -85,6 +83,7 @@ describe('FetchInstanceMetadataService', () => {
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalled();
});
test('Lock and don\'t update', async () => {
redisClient.set = mockRedis();
const now = Date.now();
@ -98,6 +97,7 @@ describe('FetchInstanceMetadataService', () => {
expect(federatedInstanceService.fetch).toHaveBeenCalledTimes(1);
expect(httpRequestService.getJson).toHaveBeenCalledTimes(0);
});
test('Do nothing when lock not acquired', async () => {
redisClient.set = mockRedis();
federatedInstanceService.fetch.mockReturnValue({ infoUpdatedAt: { getTime: () => now - 10 * 1000 * 60 * 60 * 24 } });

View file

@ -11,10 +11,10 @@ import { Test } from '@nestjs/testing';
import * as lolex from '@sinonjs/fake-timers';
import { GlobalModule } from '@/GlobalModule.js';
import { RoleService } from '@/core/RoleService.js';
import type { Role, RolesRepository, RoleAssignmentsRepository, UsersRepository, User } from '@/models/index.js';
import type { MiRole, RolesRepository, RoleAssignmentsRepository, UsersRepository, MiUser } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { MetaService } from '@/core/MetaService.js';
import { genAid } from '@/misc/id/aid.js';
import { genAidx } from '@/misc/id/aidx.js';
import { CacheService } from '@/core/CacheService.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
@ -34,10 +34,10 @@ describe('RoleService', () => {
let metaService: jest.Mocked<MetaService>;
let clock: lolex.InstalledClock;
function createUser(data: Partial<User> = {}) {
function createUser(data: Partial<MiUser> = {}) {
const un = secureRndstr(16);
return usersRepository.insert({
id: genAid(new Date()),
id: genAidx(new Date()),
createdAt: new Date(),
username: un,
usernameLower: un,
@ -46,9 +46,9 @@ describe('RoleService', () => {
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
}
function createRole(data: Partial<Role> = {}) {
function createRole(data: Partial<MiRole> = {}) {
return rolesRepository.insert({
id: genAid(new Date()),
id: genAidx(new Date()),
createdAt: new Date(),
updatedAt: new Date(),
lastUsedAt: new Date(),
@ -204,7 +204,7 @@ describe('RoleService', () => {
createdAt: new Date(Date.now() - (1000 * 60 * 60 * 24 * 365)),
followersCount: 10,
});
const role = await createRole({
await createRole({
name: 'a',
policies: {
canManageCustomEmojis: {

View file

@ -10,8 +10,8 @@ import { UploadPartCommand, CompleteMultipartUploadCommand, CreateMultipartUploa
import { mockClient } from 'aws-sdk-client-mock';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { S3Service } from '@/core/S3Service';
import { Meta } from '@/models';
import { S3Service } from '@/core/S3Service.js';
import { MiMeta } from '@/models/index.js';
import type { TestingModule } from '@nestjs/testing';
describe('S3Service', () => {
@ -40,7 +40,7 @@ describe('S3Service', () => {
test('upload a file', async () => {
s3Mock.on(PutObjectCommand).resolves({});
await s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, {
await s3Service.upload({ objectStorageRegion: 'us-east-1' } as MiMeta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x',
@ -52,7 +52,7 @@ describe('S3Service', () => {
s3Mock.on(UploadPartCommand).resolves({ ETag: '1' });
s3Mock.on(CompleteMultipartUploadCommand).resolves({ Bucket: 'fake', Key: 'fake' });
await s3Service.upload({} as Meta, {
await s3Service.upload({} as MiMeta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ
@ -62,7 +62,7 @@ describe('S3Service', () => {
test('upload a file error', async () => {
s3Mock.on(PutObjectCommand).rejects({ name: 'Fake Error' });
await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as Meta, {
await expect(s3Service.upload({ objectStorageRegion: 'us-east-1' } as MiMeta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x',
@ -72,7 +72,7 @@ describe('S3Service', () => {
test('upload a large file error', async () => {
s3Mock.on(UploadPartCommand).rejects();
await expect(s3Service.upload({} as Meta, {
await expect(s3Service.upload({} as MiMeta, {
Bucket: 'fake',
Key: 'fake',
Body: 'x'.repeat(8 * 1024 * 1024 + 1), // デフォルトpartSizeにしている 8 * 1024 * 1024 を越えるサイズ

View file

@ -18,11 +18,11 @@ import { CoreModule } from '@/core/CoreModule.js';
import { FederatedInstanceService } from '@/core/FederatedInstanceService.js';
import { LoggerService } from '@/core/LoggerService.js';
import type { IActor, IApDocument, ICollection, IPost } from '@/core/activitypub/type.js';
import { Meta, Note } from '@/models/index.js';
import { MiMeta, MiNote } from '@/models/index.js';
import { secureRndstr } from '@/misc/secure-rndstr.js';
import { DownloadService } from '@/core/DownloadService.js';
import { MetaService } from '@/core/MetaService.js';
import type { RemoteUser } from '@/models/entities/User.js';
import type { MiRemoteUser } from '@/models/entities/User.js';
import { MockResolver } from '../misc/mock-resolver.js';
const host = 'https://host1.test';
@ -75,7 +75,7 @@ function createRandomFeaturedCollection(actor: NonTransientIActor, length: numbe
async function createRandomRemoteUser(
resolver: MockResolver,
personService: ApPersonService,
): Promise<RemoteUser> {
): Promise<MiRemoteUser> {
const actor = createRandomActor();
resolver.register(actor.id, actor);
@ -94,7 +94,7 @@ describe('ActivityPub', () => {
cacheRemoteSensitiveFiles: true,
blockedHosts: [] as string[],
sensitiveWords: [] as string[],
} as Meta;
} as MiMeta;
let meta = metaInitial;
beforeAll(async () => {
@ -109,7 +109,7 @@ describe('ActivityPub', () => {
},
})
.overrideProvider(MetaService).useValue({
async fetch(): Promise<Meta> {
async fetch(): Promise<MiMeta> {
return meta;
},
}).compile();
@ -199,7 +199,7 @@ describe('ActivityPub', () => {
rendererService.renderAnnounce(null, {
createdAt: new Date(0),
visibility: 'followers',
} as Note);
} as MiNote);
});
});

View file

@ -18,7 +18,7 @@ import { entity as TestGroupedChartEntity } from '@/core/chart/charts/entities/t
import { entity as TestUniqueChartEntity } from '@/core/chart/charts/entities/test-unique.js';
import { entity as TestIntersectionChartEntity } from '@/core/chart/charts/entities/test-intersection.js';
import { loadConfig } from '@/config.js';
import type { AppLockService } from '@/core/AppLockService';
import type { AppLockService } from '@/core/AppLockService.js';
import Logger from '@/logger.js';
describe('Chart', () => {

View file

@ -6,42 +6,42 @@
import { correctFilename } from '@/misc/correct-filename.js';
describe(correctFilename, () => {
it('no ext to null', () => {
expect(correctFilename('test', null)).toBe('test.unknown');
});
it('no ext to jpg', () => {
expect(correctFilename('test', 'jpg')).toBe('test.jpg');
});
it('jpg to webp', () => {
expect(correctFilename('test.jpg', 'webp')).toBe('test.jpg.webp');
});
it('jpg to .webp', () => {
expect(correctFilename('test.jpg', '.webp')).toBe('test.jpg.webp');
});
it('jpeg to jpg', () => {
expect(correctFilename('test.jpeg', 'jpg')).toBe('test.jpeg');
});
it('JPEG to jpg', () => {
expect(correctFilename('test.JPEG', 'jpg')).toBe('test.JPEG');
});
it('jpg to jpg', () => {
expect(correctFilename('test.jpg', 'jpg')).toBe('test.jpg');
});
it('JPG to jpg', () => {
expect(correctFilename('test.JPG', 'jpg')).toBe('test.JPG');
});
it('tiff to tif', () => {
expect(correctFilename('test.tiff', 'tif')).toBe('test.tiff');
});
it('skip gz', () => {
expect(correctFilename('test.unitypackage', 'gz')).toBe('test.unitypackage');
});
it('skip text file', () => {
expect(correctFilename('test.txt', null)).toBe('test.txt');
});
it('unknown', () => {
expect(correctFilename('test.hoge', null)).toBe('test.hoge');
});
it('no ext to null', () => {
expect(correctFilename('test', null)).toBe('test.unknown');
});
it('no ext to jpg', () => {
expect(correctFilename('test', 'jpg')).toBe('test.jpg');
});
it('jpg to webp', () => {
expect(correctFilename('test.jpg', 'webp')).toBe('test.jpg.webp');
});
it('jpg to .webp', () => {
expect(correctFilename('test.jpg', '.webp')).toBe('test.jpg.webp');
});
it('jpeg to jpg', () => {
expect(correctFilename('test.jpeg', 'jpg')).toBe('test.jpeg');
});
it('JPEG to jpg', () => {
expect(correctFilename('test.JPEG', 'jpg')).toBe('test.JPEG');
});
it('jpg to jpg', () => {
expect(correctFilename('test.jpg', 'jpg')).toBe('test.jpg');
});
it('JPG to jpg', () => {
expect(correctFilename('test.JPG', 'jpg')).toBe('test.JPG');
});
it('tiff to tif', () => {
expect(correctFilename('test.tiff', 'tif')).toBe('test.tiff');
});
it('skip gz', () => {
expect(correctFilename('test.unitypackage', 'gz')).toBe('test.unitypackage');
});
it('skip text file', () => {
expect(correctFilename('test.txt', null)).toBe('test.txt');
});
it('unknown', () => {
expect(correctFilename('test.hoge', null)).toBe('test.hoge');
});
test('non ascii with space', () => {
expect(correctFilename('ファイル 名前', 'jpg')).toBe('ファイル 名前.jpg');
});

View file

@ -6,6 +6,7 @@
import { ulid } from 'ulid';
import { describe, test, expect } from '@jest/globals';
import { aidRegExp, genAid, parseAid } from '@/misc/id/aid.js';
import { aidxRegExp, genAidx, parseAidx } from '@/misc/id/aidx.js';
import { genMeid, meidRegExp, parseMeid } from '@/misc/id/meid.js';
import { genMeidg, meidgRegExp, parseMeidg } from '@/misc/id/meidg.js';
import { genObjectId, objectIdRegExp, parseObjectId } from '@/misc/id/object-id.js';
@ -19,6 +20,13 @@ describe('misc:id', () => {
expect(parseAid(gotAid).date.getTime()).toBe(date.getTime());
});
test('aidx', () => {
const date = new Date();
const gotAidx = genAidx(date);
expect(gotAidx).toMatch(aidxRegExp);
expect(parseAidx(gotAidx).date.getTime()).toBe(date.getTime());
});
test('meid', () => {
const date = new Date();
const gotMeid = genMeid(date);

View file

@ -8,7 +8,7 @@ import { readFile } from 'node:fs/promises';
import { isAbsolute, basename } from 'node:path';
import { inspect } from 'node:util';
import WebSocket, { ClientOptions } from 'ws';
import fetch, { Blob, File, RequestInit } from 'node-fetch';
import fetch, { File, RequestInit } from 'node-fetch';
import { DataSource } from 'typeorm';
import { JSDOM } from 'jsdom';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';

View file

@ -10,8 +10,8 @@
"declaration": false,
"sourceMap": false,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node16",
"module": "nodenext",
"moduleResolution": "nodenext",
"allowSyntheticDefaultImports": true,
"removeComments": false,
"noLib": false,
@ -33,8 +33,9 @@
"node"
],
"typeRoots": [
"./src/@types",
"./node_modules/@types",
"./src/@types"
"./node_modules"
],
"lib": [
"esnext"