Merge branch 'develop' into mkusername-empty

This commit is contained in:
Kagami Sascha Rosylight
2023-02-25 20:04:48 +01:00
committed by GitHub
198 changed files with 4163 additions and 2847 deletions

View File

@@ -0,0 +1,8 @@
declare module 'redis-lock' {
import type Redis from 'ioredis';
type Lock = (lockName: string, timeout?: number, taskToPerform?: () => Promise<void>) => void;
function redisLock(client: Redis.Redis, retryDelay: number): Lock;
export = redisLock;
}

View File

@@ -12,7 +12,7 @@ const retryDelay = 100;
@Injectable()
export class AppLockService {
private lock: (key: string, timeout?: number) => Promise<() => void>;
private lock: (key: string, timeout?: number, _?: (() => Promise<void>) | undefined) => Promise<() => void>;
constructor(
@Inject(DI.redis)

View File

@@ -1,7 +1,7 @@
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import * as parse5 from 'parse5';
import { JSDOM } from 'jsdom';
import { Window } from 'happy-dom';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { intersperse } from '@/misc/prelude/array.js';
@@ -235,7 +235,7 @@ export class MfmService {
return null;
}
const { window } = new JSDOM('');
const { window } = new Window();
const doc = window.document;
@@ -300,7 +300,7 @@ export class MfmService {
hashtag: (node) => {
const a = doc.createElement('a');
a.href = `${this.config.url}/tags/${node.props.hashtag}`;
a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
a.textContent = `#${node.props.hashtag}`;
a.setAttribute('rel', 'tag');
return a;
@@ -326,7 +326,7 @@ export class MfmService {
link: (node) => {
const a = doc.createElement('a');
a.href = node.props.url;
a.setAttribute('href', node.props.url);
appendChildren(node.children, a);
return a;
},
@@ -335,7 +335,7 @@ export class MfmService {
const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
a.href = remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`;
a.setAttribute('href', remoteUserInfo ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri) : `${this.config.url}/${acct}`);
a.className = 'u-url mention';
a.textContent = acct;
return a;
@@ -360,14 +360,14 @@ export class MfmService {
url: (node) => {
const a = doc.createElement('a');
a.href = node.props.url;
a.setAttribute('href', node.props.url);
a.textContent = node.props.url;
return a;
},
search: (node) => {
const a = doc.createElement('a');
a.href = `https://www.google.com/search?q=${node.props.query}`;
a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
a.textContent = node.props.content;
return a;
},

View File

@@ -9,7 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
import { bindThis } from '@/decorators.js';
// Defined also packages/sw/types.ts#L13
type pushNotificationsTypes = {
type PushNotificationsTypes = {
'notification': Packed<'Notification'>;
'unreadAntennaNote': {
antenna: { id: string, name: string };
@@ -22,8 +22,8 @@ type pushNotificationsTypes = {
};
// Reduce length because push message servers have character limits
function truncateBody<T extends keyof pushNotificationsTypes>(type: T, body: pushNotificationsTypes[T]): pushNotificationsTypes[T] {
if (body === undefined) return body;
function truncateBody<T extends keyof PushNotificationsTypes>(type: T, body: PushNotificationsTypes[T]): PushNotificationsTypes[T] {
if (typeof body !== 'object') return body;
return {
...body,
@@ -56,7 +56,7 @@ export class PushNotificationService {
}
@bindThis
public async pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {
public async pushNotification<T extends keyof PushNotificationsTypes>(userId: string, type: T, body: PushNotificationsTypes[T]) {
const meta = await this.metaService.fetch();
if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;

View File

@@ -450,9 +450,11 @@ export class ApInboxService {
return `skip: delete actor ${actor.uri} !== ${uri}`;
}
const user = await this.usersRepository.findOneByOrFail({ id: actor.id });
if (user.isDeleted) {
this.logger.info('skip: already deleted');
const user = await this.usersRepository.findOneBy({ id: actor.id });
if (user == null) {
return 'skip: actor not found';
} else if (user.isDeleted) {
return 'skip: already deleted';
}
const job = await this.queueService.createDeleteAccountJob(actor);

View File

@@ -28,6 +28,101 @@ type PrivateKey = {
keyId: string;
};
export class ApRequestCreator {
static createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
const request: Request = {
url: u.href,
method: 'POST',
headers: this.#objectAssignWithLcKey({
'Date': new Date().toUTCString(),
'Host': u.host,
'Content-Type': 'application/activity+json',
'Digest': digestHeader,
}, args.additionalHeaders),
};
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
static createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const request: Request = {
url: u.href,
method: 'GET',
headers: this.#objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).host,
}, args.additionalHeaders),
};
const result = this.#signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
static #signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
const signingString = this.#genSigningString(request, includeHeaders);
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
request.headers = this.#objectAssignWithLcKey(request.headers, {
Signature: signatureHeader,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return {
request,
signingString,
signature,
signatureHeader,
};
}
static #genSigningString(request: Request, includeHeaders: string[]): string {
request.headers = this.#lcObjectKey(request.headers);
const results: string[] = [];
for (const key of includeHeaders.map(x => x.toLowerCase())) {
if (key === '(request-target)') {
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
} else {
results.push(`${key}: ${request.headers[key]}`);
}
}
return results.join('\n');
}
static #lcObjectKey(src: Record<string, string>): Record<string, string> {
const dst: Record<string, string> = {};
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
return dst;
}
static #objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
return Object.assign(this.#lcObjectKey(a), this.#lcObjectKey(b));
}
}
@Injectable()
export class ApRequestService {
private logger: Logger;
@@ -44,112 +139,13 @@ export class ApRequestService {
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
}
@bindThis
private createSignedPost(args: { key: PrivateKey, url: string, body: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const digestHeader = `SHA-256=${crypto.createHash('sha256').update(args.body).digest('base64')}`;
const request: Request = {
url: u.href,
method: 'POST',
headers: this.objectAssignWithLcKey({
'Date': new Date().toUTCString(),
'Host': u.host,
'Content-Type': 'application/activity+json',
'Digest': digestHeader,
}, args.additionalHeaders),
};
const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'digest']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
@bindThis
private createSignedGet(args: { key: PrivateKey, url: string, additionalHeaders: Record<string, string> }): Signed {
const u = new URL(args.url);
const request: Request = {
url: u.href,
method: 'GET',
headers: this.objectAssignWithLcKey({
'Accept': 'application/activity+json, application/ld+json',
'Date': new Date().toUTCString(),
'Host': new URL(args.url).host,
}, args.additionalHeaders),
};
const result = this.signToRequest(request, args.key, ['(request-target)', 'date', 'host', 'accept']);
return {
request,
signingString: result.signingString,
signature: result.signature,
signatureHeader: result.signatureHeader,
};
}
@bindThis
private signToRequest(request: Request, key: PrivateKey, includeHeaders: string[]): Signed {
const signingString = this.genSigningString(request, includeHeaders);
const signature = crypto.sign('sha256', Buffer.from(signingString), key.privateKeyPem).toString('base64');
const signatureHeader = `keyId="${key.keyId}",algorithm="rsa-sha256",headers="${includeHeaders.join(' ')}",signature="${signature}"`;
request.headers = this.objectAssignWithLcKey(request.headers, {
Signature: signatureHeader,
});
// node-fetch will generate this for us. if we keep 'Host', it won't change with redirects!
delete request.headers['host'];
return {
request,
signingString,
signature,
signatureHeader,
};
}
@bindThis
private genSigningString(request: Request, includeHeaders: string[]): string {
request.headers = this.lcObjectKey(request.headers);
const results: string[] = [];
for (const key of includeHeaders.map(x => x.toLowerCase())) {
if (key === '(request-target)') {
results.push(`(request-target): ${request.method.toLowerCase()} ${new URL(request.url).pathname}`);
} else {
results.push(`${key}: ${request.headers[key]}`);
}
}
return results.join('\n');
}
@bindThis
private lcObjectKey(src: Record<string, string>): Record<string, string> {
const dst: Record<string, string> = {};
for (const key of Object.keys(src).filter(x => x !== '__proto__' && typeof src[x] === 'string')) dst[key.toLowerCase()] = src[key];
return dst;
}
@bindThis
private objectAssignWithLcKey(a: Record<string, string>, b: Record<string, string>): Record<string, string> {
return Object.assign(this.lcObjectKey(a), this.lcObjectKey(b));
}
@bindThis
public async signedPost(user: { id: User['id'] }, url: string, object: any) {
const body = JSON.stringify(object);
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
const req = this.createSignedPost({
const req = ApRequestCreator.createSignedPost({
key: {
privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`,
@@ -176,7 +172,7 @@ export class ApRequestService {
public async signedGet(url: string, user: { id: User['id'] }) {
const keypair = await this.userKeypairStoreService.getUserKeypair(user.id);
const req = this.createSignedGet({
const req = ApRequestCreator.createSignedGet({
key: {
privateKeyPem: keypair.privateKey,
keyId: `${this.config.url}/users/${user.id}#main-key`,

View File

@@ -1,8 +1,8 @@
export type obj = { [x: string]: any };
export type Obj = { [x: string]: any };
export type ApObject = IObject | string | (IObject | string)[];
export interface IObject {
'@context'?: string | string[] | obj | obj[];
'@context'?: string | string[] | Obj | Obj[];
type: string | string[];
id?: string;
name?: string | null;

View File

@@ -94,13 +94,6 @@ export class NotificationEntityService implements OnModuleInit {
}),
reaction: notification.reaction,
} : {}),
...(notification.type === 'pollVote' ? { // TODO: そのうち消す
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,
_hint_: options._hintForEachNotes_,
}),
choice: notification.choice,
} : {}),
...(notification.type === 'pollEnded' ? {
note: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
detail: true,

View File

@@ -25,14 +25,7 @@ export class RoleEntityService {
public async pack(
src: Role['id'] | Role,
me?: { id: User['id'] } | null | undefined,
options?: {
detail?: boolean;
},
) {
const opts = Object.assign({
detail: true,
}, options);
const role = typeof src === 'object' ? src : await this.rolesRepository.findOneByOrFail({ id: src });
const assigns = await this.roleAssignmentsRepository.findBy({
@@ -65,9 +58,6 @@ export class RoleEntityService {
canEditMembersByModerator: role.canEditMembersByModerator,
policies: policies,
usersCount: assigns.length,
...(opts.detail ? {
users: this.userEntityService.packMany(assigns.map(x => x.userId), me),
} : {}),
});
}
@@ -75,11 +65,8 @@ export class RoleEntityService {
public packMany(
roles: any[],
me: { id: User['id'] },
options?: {
detail?: boolean;
},
) {
return Promise.all(roles.map(x => this.pack(x, me, options)));
return Promise.all(roles.map(x => this.pack(x, me)));
}
}

View File

@@ -1,5 +1,5 @@
import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm';
import { notificationTypes } from '@/types.js';
import { notificationTypes, obsoleteNotificationTypes } from '@/types.js';
import { id } from '../id.js';
import { User } from './User.js';
import { Note } from './Note.js';
@@ -58,7 +58,6 @@ export class Notification {
* renote - 投稿がRenoteされた
* quote - 投稿が引用Renoteされた
* reaction - 投稿にリアクションされた
* pollVote - 投稿のアンケートに投票された (廃止)
* pollEnded - 自分のアンケートもしくは自分が投票したアンケートが終了した
* receiveFollowRequest - フォローリクエストされた
* followRequestAccepted - 自分の送ったフォローリクエストが承認された
@@ -67,7 +66,10 @@ export class Notification {
*/
@Index()
@Column('enum', {
enum: notificationTypes,
enum: [
...notificationTypes,
...obsoleteNotificationTypes,
],
comment: 'The type of the Notification.',
})
public type: typeof notificationTypes[number];

View File

@@ -1,5 +1,5 @@
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
import { ffVisibility, notificationTypes } from '@/types.js';
import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/types.js';
import { id } from '../id.js';
import { User } from './User.js';
import { Page } from './Page.js';
@@ -205,7 +205,7 @@ export class UserProfile {
enum: [
...notificationTypes,
// マイグレーションで削除が困難なので古いenumは残しておく
'groupInvited',
...obsoleteNotificationTypes,
],
array: true,
default: [],

View File

@@ -108,9 +108,9 @@ export class ApiCallService implements OnApplicationShutdown {
const [path] = await createTemp();
await pump(multipartData.file, fs.createWriteStream(path));
const fields = {} as Record<string, string | undefined>;
const fields = {} as Record<string, unknown>;
for (const [k, v] of Object.entries(multipartData.fields)) {
fields[k] = v.value;
fields[k] = typeof v === 'object' && 'value' in v ? v.value : undefined;
}
const token = fields['i'];

View File

@@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@@ -170,6 +171,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js';
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
import * as ep___i_apps from './endpoints/i/apps.js';
@@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
import * as ep___ping from './endpoints/ping.js';
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -382,6 +387,7 @@ const $admin_roles_update: Provider = { provide: 'ep:admin/roles/update', useCla
const $admin_roles_assign: Provider = { provide: 'ep:admin/roles/assign', useClass: ep___admin_roles_assign.default };
const $admin_roles_unassign: Provider = { provide: 'ep:admin/roles/unassign', useClass: ep___admin_roles_unassign.default };
const $admin_roles_updateDefaultPolicies: Provider = { provide: 'ep:admin/roles/update-default-policies', useClass: ep___admin_roles_updateDefaultPolicies.default };
const $admin_roles_users: Provider = { provide: 'ep:admin/roles/users', useClass: ep___admin_roles_users.default };
const $announcements: Provider = { provide: 'ep:announcements', useClass: ep___announcements.default };
const $antennas_create: Provider = { provide: 'ep:antennas/create', useClass: ep___antennas_create.default };
const $antennas_delete: Provider = { provide: 'ep:antennas/delete', useClass: ep___antennas_delete.default };
@@ -486,6 +492,7 @@ const $i_2fa_keyDone: Provider = { provide: 'ep:i/2fa/key-done', useClass: ep___
const $i_2fa_passwordLess: Provider = { provide: 'ep:i/2fa/password-less', useClass: ep___i_2fa_passwordLess.default };
const $i_2fa_registerKey: Provider = { provide: 'ep:i/2fa/register-key', useClass: ep___i_2fa_registerKey.default };
const $i_2fa_register: Provider = { provide: 'ep:i/2fa/register', useClass: ep___i_2fa_register.default };
const $i_2fa_updateKey: Provider = { provide: 'ep:i/2fa/update-key', useClass: ep___i_2fa_updateKey.default };
const $i_2fa_removeKey: Provider = { provide: 'ep:i/2fa/remove-key', useClass: ep___i_2fa_removeKey.default };
const $i_2fa_unregister: Provider = { provide: 'ep:i/2fa/unregister', useClass: ep___i_2fa_unregister.default };
const $i_apps: Provider = { provide: 'ep:i/apps', useClass: ep___i_apps.default };
@@ -592,6 +599,9 @@ const $flash_myLikes: Provider = { provide: 'ep:flash/my-likes', useClass: ep___
const $ping: Provider = { provide: 'ep:ping', useClass: ep___ping.default };
const $pinnedUsers: Provider = { provide: 'ep:pinned-users', useClass: ep___pinnedUsers.default };
const $promo_read: Provider = { provide: 'ep:promo/read', useClass: ep___promo_read.default };
const $roles_list: Provider = { provide: 'ep:roles/list', useClass: ep___roles_list.default };
const $roles_show: Provider = { provide: 'ep:roles/show', useClass: ep___roles_show.default };
const $roles_users: Provider = { provide: 'ep:roles/users', useClass: ep___roles_users.default };
const $requestResetPassword: Provider = { provide: 'ep:request-reset-password', useClass: ep___requestResetPassword.default };
const $resetDb: Provider = { provide: 'ep:reset-db', useClass: ep___resetDb.default };
const $resetPassword: Provider = { provide: 'ep:reset-password', useClass: ep___resetPassword.default };
@@ -702,6 +712,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_roles_assign,
$admin_roles_unassign,
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements,
$antennas_create,
$antennas_delete,
@@ -806,6 +817,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_2fa_passwordLess,
$i_2fa_registerKey,
$i_2fa_register,
$i_2fa_updateKey,
$i_2fa_removeKey,
$i_2fa_unregister,
$i_apps,
@@ -912,6 +924,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$ping,
$pinnedUsers,
$promo_read,
$roles_list,
$roles_show,
$roles_users,
$requestResetPassword,
$resetDb,
$resetPassword,
@@ -1016,6 +1031,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$admin_roles_assign,
$admin_roles_unassign,
$admin_roles_updateDefaultPolicies,
$admin_roles_users,
$announcements,
$antennas_create,
$antennas_delete,
@@ -1120,6 +1136,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$i_2fa_passwordLess,
$i_2fa_registerKey,
$i_2fa_register,
$i_2fa_updateKey,
$i_2fa_removeKey,
$i_2fa_unregister,
$i_apps,
@@ -1226,6 +1243,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$ping,
$pinnedUsers,
$promo_read,
$roles_list,
$roles_show,
$roles_users,
$requestResetPassword,
$resetDb,
$resetPassword,

View File

@@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { NotesRepository, UsersRepository } from '@/models/index.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { User } from '@/models/entities/User.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import type { Note } from '@/models/entities/Note.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
@@ -45,7 +45,7 @@ export class GetterService {
throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.');
}
return user;
return user as LocalUser | RemoteUser;
}
/**

View File

@@ -1,7 +1,7 @@
import { randomBytes } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy';
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';
@@ -155,19 +155,19 @@ export class SigninApiService {
});
}
const verified = (speakeasy as any).totp.verify({
secret: profile.twoFactorSecret,
encoding: 'base32',
token: token,
window: 2,
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorSecret!),
digits: 6,
token,
window: 1,
});
if (verified) {
return this.signinService.signin(request, reply, user);
} else {
if (delta === null) {
return await fail(403, {
id: 'cdf1235b-ac71-46d4-a3a6-84ccce48df6f',
});
} else {
return this.signinService.signin(request, reply, user);
}
} else if (body.credentialId && body.clientDataJSON && body.authenticatorData && body.signature) {
if (!same && !profile.usePasswordLessLogin) {

View File

@@ -20,14 +20,14 @@ type File = {
};
// TODO: paramsの型をT['params']のスキーマ定義から推論する
type executor<T extends IEndpointMeta, Ps extends Schema> =
type Executor<T extends IEndpointMeta, Ps extends Schema> =
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
public exec: (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
constructor(meta: T, paramDef: Ps, cb: executor<T, Ps>) {
constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
const validate = ajv.compile(paramDef);
this.exec = (params: any, user: T['requireCredential'] extends true ? LocalUser : LocalUser | null, token: AccessToken | null, file?: File, ip?: string | null, headers?: Record<string, string> | null) => {

View File

@@ -66,6 +66,7 @@ import * as ep___admin_roles_update from './endpoints/admin/roles/update.js';
import * as ep___admin_roles_assign from './endpoints/admin/roles/assign.js';
import * as ep___admin_roles_unassign from './endpoints/admin/roles/unassign.js';
import * as ep___admin_roles_updateDefaultPolicies from './endpoints/admin/roles/update-default-policies.js';
import * as ep___admin_roles_users from './endpoints/admin/roles/users.js';
import * as ep___announcements from './endpoints/announcements.js';
import * as ep___antennas_create from './endpoints/antennas/create.js';
import * as ep___antennas_delete from './endpoints/antennas/delete.js';
@@ -170,6 +171,7 @@ import * as ep___i_2fa_keyDone from './endpoints/i/2fa/key-done.js';
import * as ep___i_2fa_passwordLess from './endpoints/i/2fa/password-less.js';
import * as ep___i_2fa_registerKey from './endpoints/i/2fa/register-key.js';
import * as ep___i_2fa_register from './endpoints/i/2fa/register.js';
import * as ep___i_2fa_updateKey from './endpoints/i/2fa/update-key.js';
import * as ep___i_2fa_removeKey from './endpoints/i/2fa/remove-key.js';
import * as ep___i_2fa_unregister from './endpoints/i/2fa/unregister.js';
import * as ep___i_apps from './endpoints/i/apps.js';
@@ -276,6 +278,9 @@ import * as ep___flash_myLikes from './endpoints/flash/my-likes.js';
import * as ep___ping from './endpoints/ping.js';
import * as ep___pinnedUsers from './endpoints/pinned-users.js';
import * as ep___promo_read from './endpoints/promo/read.js';
import * as ep___roles_list from './endpoints/roles/list.js';
import * as ep___roles_show from './endpoints/roles/show.js';
import * as ep___roles_users from './endpoints/roles/users.js';
import * as ep___requestResetPassword from './endpoints/request-reset-password.js';
import * as ep___resetDb from './endpoints/reset-db.js';
import * as ep___resetPassword from './endpoints/reset-password.js';
@@ -380,6 +385,7 @@ const eps = [
['admin/roles/assign', ep___admin_roles_assign],
['admin/roles/unassign', ep___admin_roles_unassign],
['admin/roles/update-default-policies', ep___admin_roles_updateDefaultPolicies],
['admin/roles/users', ep___admin_roles_users],
['announcements', ep___announcements],
['antennas/create', ep___antennas_create],
['antennas/delete', ep___antennas_delete],
@@ -484,6 +490,7 @@ const eps = [
['i/2fa/password-less', ep___i_2fa_passwordLess],
['i/2fa/register-key', ep___i_2fa_registerKey],
['i/2fa/register', ep___i_2fa_register],
['i/2fa/update-key', ep___i_2fa_updateKey],
['i/2fa/remove-key', ep___i_2fa_removeKey],
['i/2fa/unregister', ep___i_2fa_unregister],
['i/apps', ep___i_apps],
@@ -590,6 +597,9 @@ const eps = [
['ping', ep___ping],
['pinned-users', ep___pinnedUsers],
['promo/read', ep___promo_read],
['roles/list', ep___roles_list],
['roles/show', ep___roles_show],
['roles/users', ep___roles_users],
['request-reset-password', ep___requestResetPassword],
['reset-db', ep___resetDb],
['reset-password', ep___resetPassword],

View File

@@ -1,5 +1,5 @@
import { Inject, Injectable } from '@nestjs/common';
import type { DriveFilesRepository } from '@/models/index.js';
import type { DriveFilesRepository, UsersRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
@@ -161,6 +161,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
private roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
@@ -178,7 +181,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new ApiError(meta.errors.noSuchFile);
}
const isModerator = await this.roleService.isModerator(me);
const owner = file.userId ? await this.usersRepository.findOneByOrFail({
id: file.userId,
}) : null;
const iAmModerator = await this.roleService.isModerator(me);
const ownerIsModerator = owner ? await this.roleService.isModerator(owner) : false;
return {
id: file.id,
@@ -207,8 +215,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
name: file.name,
md5: file.md5,
createdAt: file.createdAt.toISOString(),
requestIp: isModerator ? file.requestIp : null,
requestHeaders: isModerator ? file.requestHeaders : null,
requestIp: iAmModerator ? file.requestIp : null,
requestHeaders: iAmModerator && !ownerIsModerator ? file.requestHeaders : null,
};
});
}

View File

@@ -32,7 +32,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const roles = await this.rolesRepository.find({
order: { lastUsedAt: 'DESC' },
});
return await this.roleEntityService.packMany(roles, me, { detail: false });
return await this.roleEntityService.packMany(roles, me);
});
}
}

View File

@@ -39,12 +39,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private roleEntityService: RoleEntityService,
) {
super(meta, paramDef, async (ps) => {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({ id: ps.roleId });
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
return await this.roleEntityService.pack(role);
return await this.roleEntityService.pack(role, me);
});
}
}

View File

@@ -0,0 +1,71 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['admin', 'role', 'users'],
requireCredential: false,
requireAdmin: true,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: '224eff5e-2488-4b18-b3e7-f50d94421648',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private queryService: QueryService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
.take(ps.limit)
.getMany();
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
})));
});
}
}

View File

@@ -59,12 +59,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new Error('cannot show info of admin');
}
if (!await this.roleService.isAdministrator(_me)) {
return {
isSuspended: user.isSuspended,
};
}
const signins = await this.signinsRepository.findBy({ userId: user.id });
const roles = await this.roleService.getUserRoles(user.id);
@@ -89,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
moderationNote: profile.moderationNote,
signins,
policies: await this.roleService.getUserPolicies(user.id),
roles: await this.roleEntityService.packMany(roles, me, { detail: false }),
roles: await this.roleEntityService.packMany(roles, me),
};
});
}

View File

@@ -1,7 +1,10 @@
import * as speakeasy from 'speakeasy';
import * as OTPAuth from 'otpauth';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
export const meta = {
@@ -22,8 +25,14 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const token = ps.token.replace(/\s/g, '');
@@ -34,13 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
throw new Error('二段階認証の設定が開始されていません');
}
const verified = (speakeasy as any).totp.verify({
secret: profile.twoFactorTempSecret,
encoding: 'base32',
token: token,
const delta = OTPAuth.TOTP.validate({
secret: OTPAuth.Secret.fromBase32(profile.twoFactorTempSecret),
digits: 6,
token,
window: 1,
});
if (!verified) {
if (delta === null) {
throw new Error('not verified');
}
@@ -48,6 +58,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
twoFactorSecret: profile.twoFactorTempSecret,
twoFactorEnabled: true,
});
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
});
}
}

View File

@@ -25,7 +25,7 @@ export const paramDef = {
attestationObject: { type: 'string' },
password: { type: 'string' },
challengeId: { type: 'string' },
name: { type: 'string' },
name: { type: 'string', minLength: 1, maxLength: 30 },
},
required: ['clientDataJSON', 'attestationObject', 'password', 'challengeId', 'name'],
} as const;

View File

@@ -1,12 +1,23 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository } from '@/models/index.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
noKey: {
message: 'No security key.',
code: 'NO_SECURITY_KEY',
id: 'f9c54d7f-d4c2-4d3c-9a8g-a70daac86512',
},
},
} as const;
export const paramDef = {
@@ -23,11 +34,45 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.value === true) {
// セキュリティキーがなければパスワードレスを有効にはできない
const keyCount = await this.userSecurityKeysRepository.count({
where: {
userId: me.id,
},
select: {
id: true,
name: true,
lastUsed: true,
},
});
if (keyCount === 0) {
await this.userProfilesRepository.update(me.id, {
usePasswordLessLogin: false,
});
throw new ApiError(meta.errors.noKey);
}
}
await this.userProfilesRepository.update(me.id, {
usePasswordLessLogin: ps.value,
});
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
});
}
}

View File

@@ -1,5 +1,5 @@
import bcrypt from 'bcryptjs';
import * as speakeasy from 'speakeasy';
import * as OTPAuth from 'otpauth';
import * as QRCode from 'qrcode';
import { Inject, Injectable } from '@nestjs/common';
import type { UserProfilesRepository } from '@/models/index.js';
@@ -42,25 +42,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
}
// Generate user's secret key
const secret = speakeasy.generateSecret({
length: 32,
});
const secret = new OTPAuth.Secret();
await this.userProfilesRepository.update(me.id, {
twoFactorTempSecret: secret.base32,
});
// Get the data URL of the authenticator URL
const url = speakeasy.otpauthURL({
secret: secret.base32,
encoding: 'base32',
const totp = new OTPAuth.TOTP({
secret,
digits: 6,
label: me.username,
issuer: this.config.host,
});
const dataUrl = await QRCode.toDataURL(url);
const url = totp.toString();
const qr = await QRCode.toDataURL(url);
return {
qr: dataUrl,
qr,
url,
secret: secret.base32,
label: me.username,

View File

@@ -50,6 +50,24 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
id: ps.credentialId,
});
// 使われているキーがなくなったらパスワードレスログインをやめる
const keyCount = await this.userSecurityKeysRepository.count({
where: {
userId: me.id,
},
select: {
id: true,
name: true,
lastUsed: true,
},
});
if (keyCount === 0) {
await this.userProfilesRepository.update(me.id, {
usePasswordLessLogin: false,
});
}
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,

View File

@@ -1,7 +1,9 @@
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
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';
export const meta = {
@@ -24,6 +26,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: me.id });
@@ -38,7 +43,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userProfilesRepository.update(me.id, {
twoFactorSecret: null,
twoFactorEnabled: false,
usePasswordLessLogin: false,
});
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
});
}
}

View File

@@ -0,0 +1,78 @@
import bcrypt from 'bcryptjs';
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UserProfilesRepository, UserSecurityKeysRepository } from '@/models/index.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { ApiError } from '../../../error.js';
export const meta = {
requireCredential: true,
secure: true,
errors: {
noSuchKey: {
message: 'No such key.',
code: 'NO_SUCH_KEY',
id: 'f9c5467f-d492-4d3c-9a8g-a70dacc86512',
},
accessDenied: {
message: 'You do not have edit privilege of the channel.',
code: 'ACCESS_DENIED',
id: '1fb7cb09-d46a-4fff-b8df-057708cce513',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
name: { type: 'string', minLength: 1, maxLength: 30 },
credentialId: { type: 'string' },
},
required: ['name', 'credentialId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.userSecurityKeysRepository)
private userSecurityKeysRepository: UserSecurityKeysRepository,
@Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository,
private userEntityService: UserEntityService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, me) => {
const key = await this.userSecurityKeysRepository.findOneBy({
id: ps.credentialId,
});
if (key == null) {
throw new ApiError(meta.errors.noSuchKey);
}
if (key.userId !== me.id) {
throw new ApiError(meta.errors.accessDenied);
}
await this.userSecurityKeysRepository.update(key.id, {
name: ps.name,
});
// Publish meUpdated event
this.globalEventService.publishMainStream(me.id, 'meUpdated', await this.userEntityService.pack(me.id, me, {
detail: true,
includeSecrets: true,
}));
return {};
});
}
}

View File

@@ -1,7 +1,7 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js';
import { notificationTypes } from '@/types.js';
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteReadService } from '@/core/NoteReadService.js';
@@ -41,11 +41,12 @@ export const paramDef = {
following: { type: 'boolean', default: false },
unreadOnly: { type: 'boolean', default: false },
markAsRead: { type: 'boolean', default: true },
// 後方互換のため、廃止された通知タイプも受け付ける
includeTypes: { type: 'array', items: {
type: 'string', enum: notificationTypes,
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
} },
excludeTypes: { type: 'array', items: {
type: 'string', enum: notificationTypes,
type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes],
} },
},
required: [],
@@ -84,6 +85,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) {
return [];
}
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
@@ -143,10 +148,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
query.setParameters(followingQuery.getParameters());
}
if (ps.includeTypes && ps.includeTypes.length > 0) {
query.andWhere('notification.type IN (:...includeTypes)', { includeTypes: ps.includeTypes });
} else if (ps.excludeTypes && ps.excludeTypes.length > 0) {
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes: ps.excludeTypes });
if (includeTypes && includeTypes.length > 0) {
query.andWhere('notification.type IN (:...includeTypes)', { includeTypes });
} else if (excludeTypes && excludeTypes.length > 0) {
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes });
}
if (ps.unreadOnly) {

View File

@@ -79,6 +79,12 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'b390d7e1-8a5e-46ed-b625-06271cafd3d3',
},
noSuchFile: {
message: 'Some files are not found.',
code: 'NO_SUCH_FILE',
id: 'b6992544-63e7-67f0-fa7f-32444b1b5306',
},
},
} as const;
@@ -207,6 +213,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.orderBy('array_position(ARRAY[:...fileIds], "id"::text)')
.setParameters({ fileIds })
.getMany();
if (files.length !== fileIds.length) {
throw new ApiError(meta.errors.noSuchFile);
}
}
let renote: Note | null = null;

View File

@@ -28,6 +28,7 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
channelId: { type: 'string', nullable: true, format: 'misskey:id' },
},
required: [],
} as const;
@@ -63,12 +64,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
if (ps.channelId) query.andWhere('note.channelId = :channelId', { channelId: ps.channelId });
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
let notes = await query
.orderBy('note.score', 'DESC')
.take(ps.limit)
.take(50)
.getMany();
notes.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());

View File

@@ -0,0 +1,37 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RolesRepository } from '@/models/index.js';
import { DI } from '@/di-symbols.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
export const meta = {
tags: ['role'],
requireCredential: true,
} as const;
export const paramDef = {
type: 'object',
properties: {
},
required: [
],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
private roleEntityService: RoleEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const roles = await this.rolesRepository.findBy({
isPublic: true,
});
return await this.roleEntityService.packMany(roles, me);
});
}
}

View File

@@ -0,0 +1,52 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['role', 'users'],
requireCredential: false,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: 'de5502bf-009a-4639-86c1-fec349e46dcb',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
private roleEntityService: RoleEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
isPublic: true,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
return await this.roleEntityService.pack(role, me);
});
}
}

View File

@@ -0,0 +1,71 @@
import { Inject, Injectable } from '@nestjs/common';
import type { RoleAssignmentsRepository, RolesRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['role', 'users'],
requireCredential: false,
errors: {
noSuchRole: {
message: 'No such role.',
code: 'NO_SUCH_ROLE',
id: '30aaaee3-4792-48dc-ab0d-cf501a575ac5',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
roleId: { type: 'string', format: 'misskey:id' },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
},
required: ['roleId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.rolesRepository)
private rolesRepository: RolesRepository,
@Inject(DI.roleAssignmentsRepository)
private roleAssignmentsRepository: RoleAssignmentsRepository,
private queryService: QueryService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const role = await this.rolesRepository.findOneBy({
id: ps.roleId,
isPublic: true,
});
if (role == null) {
throw new ApiError(meta.errors.noSuchRole);
}
const query = this.queryService.makePaginationQuery(this.roleAssignmentsRepository.createQueryBuilder('assign'), ps.sinceId, ps.untilId)
.andWhere('assign.roleId = :roleId', { roleId: role.id })
.innerJoinAndSelect('assign.user', 'user');
const assigns = await query
.take(ps.limit)
.getMany();
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
user: await this.userEntityService.pack(assign.user!, me, { detail: true }),
})));
});
}
}

View File

@@ -1,6 +1,7 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import type { User } from '@/models/entities/User.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
@@ -36,13 +37,13 @@ export const paramDef = {
properties: {
username: { type: 'string', nullable: true },
},
required: ['username']
required: ['username'],
},
{
properties: {
host: { type: 'string', nullable: true },
},
required: ['host']
required: ['host'],
},
],
} as const;
@@ -53,6 +54,9 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.config)
private config: Config,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -62,79 +66,76 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
if (ps.host) {
const q = this.usersRepository.createQueryBuilder('user')
.where('user.isSuspended = FALSE')
.andWhere('user.host LIKE :host', { host: sqlLikeEscape(ps.host.toLowerCase()) + '%' });
const setUsernameAndHostQuery = (query = this.usersRepository.createQueryBuilder('user')) => {
if (ps.username) {
q.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
query.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' });
}
q.andWhere('user.updatedAt IS NOT NULL');
q.orderBy('user.updatedAt', 'DESC');
const users = await q.take(ps.limit).getMany();
return await this.userEntityService.packMany(users, me, { detail: ps.detail });
} else if (ps.username) {
let users: User[] = [];
if (me) {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const query = this.usersRepository.createQueryBuilder('user')
.where(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere(new Brackets(qb => { qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}));
query.setParameters(followingQuery.getParameters());
users = await query
.orderBy('user.usernameLower', 'ASC')
.take(ps.limit)
.getMany();
if (users.length < ps.limit) {
const otherQuery = await this.usersRepository.createQueryBuilder('user')
.where(`user.id NOT IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere('user.updatedAt IS NOT NULL');
otherQuery.setParameters(followingQuery.getParameters());
const otherUsers = await otherQuery
.orderBy('user.updatedAt', 'DESC')
.take(ps.limit - users.length)
.getMany();
users = users.concat(otherUsers);
if (ps.host) {
if (ps.host === this.config.hostname || ps.host === '.') {
query.andWhere('user.host IS NULL');
} else {
query.andWhere('user.host LIKE :host', {
host: sqlLikeEscape(ps.host.toLowerCase()) + '%',
});
}
} else {
users = await this.usersRepository.createQueryBuilder('user')
.where('user.isSuspended = FALSE')
.andWhere('user.usernameLower LIKE :username', { username: sqlLikeEscape(ps.username.toLowerCase()) + '%' })
.andWhere('user.updatedAt IS NOT NULL')
}
return query;
};
const activeThreshold = new Date(Date.now() - (1000 * 60 * 60 * 24 * 30)); // 30日
let users: User[] = [];
if (me) {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const query = setUsernameAndHostQuery()
.andWhere(`user.id IN (${ followingQuery.getQuery() })`)
.andWhere('user.id != :meId', { meId: me.id })
.andWhere('user.isSuspended = FALSE')
.andWhere(new Brackets(qb => { qb
.where('user.updatedAt IS NULL')
.orWhere('user.updatedAt > :activeThreshold', { activeThreshold: activeThreshold });
}));
query.setParameters(followingQuery.getParameters());
users = await query
.orderBy('user.usernameLower', 'ASC')
.take(ps.limit)
.getMany();
if (users.length < ps.limit) {
const otherQuery = setUsernameAndHostQuery()
.andWhere(`user.id NOT IN (${ followingQuery.getQuery() })`)
.andWhere('user.isSuspended = FALSE')
.andWhere('user.updatedAt IS NOT NULL');
otherQuery.setParameters(followingQuery.getParameters());
const otherUsers = await otherQuery
.orderBy('user.updatedAt', 'DESC')
.take(ps.limit - users.length)
.getMany();
}
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
users = users.concat(otherUsers);
}
} else {
const query = setUsernameAndHostQuery()
.andWhere('user.isSuspended = FALSE')
.andWhere('user.updatedAt IS NOT NULL');
users = await query
.orderBy('user.updatedAt', 'DESC')
.take(ps.limit - users.length)
.getMany();
}
return [];
return await this.userEntityService.packMany(users, me, { detail: !!ps.detail });
});
}
}

View File

@@ -9,16 +9,26 @@
{
"src": "/static-assets/icons/192.png",
"sizes": "192x192",
"type": "image/png"
"type": "image/png",
"purpose": "maskable"
},
{
"src": "/static-assets/icons/512.png",
"sizes": "512x512",
"type": "image/png"
"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",

View File

@@ -1,4 +1,5 @@
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] as const;
export const obsoleteNotificationTypes = ['pollVote', 'groupInvited'] as const;
export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;