Merge upstream
This commit is contained in:
commit
ad42eccfa4
24 changed files with 529 additions and 20 deletions
11
packages/backend/migration/1723311628855-mutuallinks.js
Normal file
11
packages/backend/migration/1723311628855-mutuallinks.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
export class Mutuallinks1723311628855 {
|
||||
name = 'Mutuallinks1723311628855'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutualLinkSections" jsonb NOT NULL DEFAULT '[]'`);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutualLinkSections"`);
|
||||
}
|
||||
}
|
|
@ -70,6 +70,8 @@ export type RolePolicies = {
|
|||
rateLimitFactor: number;
|
||||
avatarDecorationLimit: number;
|
||||
canUseAccountRemoval: boolean;
|
||||
mutualLinkSectionLimit: number;
|
||||
mutualLinkLimit: number;
|
||||
};
|
||||
|
||||
export const DEFAULT_POLICIES: RolePolicies = {
|
||||
|
@ -110,6 +112,8 @@ export const DEFAULT_POLICIES: RolePolicies = {
|
|||
rateLimitFactor: 1,
|
||||
avatarDecorationLimit: 1,
|
||||
canUseAccountRemoval: true,
|
||||
mutualLinkSectionLimit: 1,
|
||||
mutualLinkLimit: 15,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
|
@ -423,6 +427,8 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
|
|||
userEachUserListsLimit: calc('userEachUserListsLimit', vs => Math.max(...vs)),
|
||||
rateLimitFactor: calc('rateLimitFactor', vs => Math.max(...vs)),
|
||||
avatarDecorationLimit: calc('avatarDecorationLimit', vs => Math.max(...vs)),
|
||||
mutualLinkSectionLimit: calc('mutualLinkSectionLimit', vs => Math.max(...vs)),
|
||||
mutualLinkLimit: calc('mutualLinkLimit', vs => Math.max(...vs)),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -51,7 +51,6 @@ import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
|||
import { isNotNull } from '@/misc/is-not-null.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
import type { NoteEntityService } from './NoteEntityService.js';
|
||||
import type { DriveFileEntityService } from './DriveFileEntityService.js';
|
||||
import type { PageEntityService } from './PageEntityService.js';
|
||||
|
||||
const Ajv = _Ajv.default;
|
||||
|
@ -535,6 +534,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
lang: profile?.lang,
|
||||
fields: profile?.fields,
|
||||
verifiedLinks: profile?.verifiedLinks,
|
||||
mutualLinkSections: profile?.mutualLinkSections,
|
||||
followersCount: followersCount ?? 0,
|
||||
followingCount: followingCount ?? 0,
|
||||
notesCount: user.notesCount,
|
||||
|
@ -564,7 +564,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
isModerator: role.isModerator,
|
||||
isAdministrator: role.isAdministrator,
|
||||
displayOrder: role.displayOrder,
|
||||
}))
|
||||
})),
|
||||
),
|
||||
memo: memo,
|
||||
moderationNote: iAmModerator ? (profile?.moderationNote ?? '') : undefined,
|
||||
|
|
|
@ -4,11 +4,12 @@
|
|||
*/
|
||||
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { obsoleteNotificationTypes, followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js';
|
||||
import { followingVisibilities, followersVisibilities, notificationTypes } from '@/types.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
import { MiPage } from './Page.js';
|
||||
import { MiUserList } from './UserList.js';
|
||||
import type { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
|
||||
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
|
||||
|
@ -42,6 +43,18 @@ export class MiUserProfile {
|
|||
})
|
||||
public description: string | null;
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
public mutualLinkSections: {
|
||||
name: string | null;
|
||||
mutualLinks: {
|
||||
fileId: MiDriveFile['id'];
|
||||
description: string | null;
|
||||
imgSrc: string;
|
||||
}[];
|
||||
}[] | [];
|
||||
|
||||
@Column('jsonb', {
|
||||
default: [],
|
||||
})
|
||||
|
|
|
@ -316,6 +316,14 @@ export const packedRolePoliciesSchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mutualLinkSectionLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
mutualLinkLimit: {
|
||||
type: 'integer',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
@ -179,7 +179,7 @@ export const packedUserLiteSchema = {
|
|||
behavior: {
|
||||
type: 'string',
|
||||
nullable: false, optional: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -386,6 +386,29 @@ export const packedUserDetailedNotMeOnlySchema = {
|
|||
type: 'string',
|
||||
nullable: false, optional: true,
|
||||
},
|
||||
mutualLinkSections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true },
|
||||
mutualLinks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string' },
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
description: { type: 'string', nullable: true },
|
||||
imgSrc: { type: 'string' },
|
||||
},
|
||||
required: ['url', 'fileId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['mutualLinks'],
|
||||
},
|
||||
},
|
||||
//#region relations
|
||||
isFollowing: {
|
||||
type: 'boolean',
|
||||
|
|
|
@ -32,6 +32,7 @@ import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-d
|
|||
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
|
||||
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
|
||||
import * as ep___admin_disposeCache from './endpoints/admin/dispose-cache.js';
|
||||
import * as ep___admin_unsetUserMutualLink from './endpoints/admin/unset-user-mutual-link.js';
|
||||
import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js';
|
||||
import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js';
|
||||
import * as ep___admin_drive_deleteAllFilesOfAUser from './endpoints/admin/drive/delete-all-files-of-a-user.js';
|
||||
|
@ -423,6 +424,7 @@ const $admin_avatarDecorations_update: Provider = { provide: 'ep:admin/avatar-de
|
|||
const $admin_unsetUserAvatar: Provider = { provide: 'ep:admin/unset-user-avatar', useClass: ep___admin_unsetUserAvatar.default };
|
||||
const $admin_unsetUserBanner: Provider = { provide: 'ep:admin/unset-user-banner', useClass: ep___admin_unsetUserBanner.default };
|
||||
const $admin_disposeCache: Provider = { provide: 'ep:admin/dispose-cache', useClass: ep___admin_disposeCache.default };
|
||||
const $admin_unsetUserMutualLink: Provider = { provide: 'ep:admin/unset-user-mutual-link', useClass: ep___admin_unsetUserMutualLink.default };
|
||||
const $admin_drive_cleanRemoteFiles: Provider = { provide: 'ep:admin/drive/clean-remote-files', useClass: ep___admin_drive_cleanRemoteFiles.default };
|
||||
const $admin_drive_cleanup: Provider = { provide: 'ep:admin/drive/cleanup', useClass: ep___admin_drive_cleanup.default };
|
||||
const $admin_drive_deleteAllFilesOfAUser: Provider = { provide: 'ep:admin/drive/delete-all-files-of-a-user', useClass: ep___admin_drive_deleteAllFilesOfAUser.default };
|
||||
|
@ -818,6 +820,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_unsetUserAvatar,
|
||||
$admin_unsetUserBanner,
|
||||
$admin_disposeCache,
|
||||
$admin_unsetUserMutualLink,
|
||||
$admin_drive_cleanRemoteFiles,
|
||||
$admin_drive_cleanup,
|
||||
$admin_drive_deleteAllFilesOfAUser,
|
||||
|
@ -1207,6 +1210,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
|
|||
$admin_unsetUserAvatar,
|
||||
$admin_unsetUserBanner,
|
||||
$admin_disposeCache,
|
||||
$admin_unsetUserMutualLink,
|
||||
$admin_drive_cleanRemoteFiles,
|
||||
$admin_drive_cleanup,
|
||||
$admin_drive_deleteAllFilesOfAUser,
|
||||
|
|
|
@ -18,6 +18,17 @@ const ajv = new Ajv({
|
|||
});
|
||||
|
||||
ajv.addFormat('misskey:id', /^[a-zA-Z0-9]+$/);
|
||||
ajv.addFormat('url', {
|
||||
type: 'string',
|
||||
validate: (url: string) => {
|
||||
try {
|
||||
new URL(url);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export type Response = Record<string, any> | void;
|
||||
|
||||
|
|
|
@ -32,6 +32,7 @@ import * as ep___admin_avatarDecorations_update from './endpoints/admin/avatar-d
|
|||
import * as ep___admin_unsetUserAvatar from './endpoints/admin/unset-user-avatar.js';
|
||||
import * as ep___admin_unsetUserBanner from './endpoints/admin/unset-user-banner.js';
|
||||
import * as ep___admin_disposeCache from './endpoints/admin/dispose-cache.js';
|
||||
import * as ep___admin_unsetUserMutualLink from './endpoints/admin/unset-user-mutual-link.js';
|
||||
import * as ep___admin_drive_cleanRemoteFiles from './endpoints/admin/drive/clean-remote-files.js';
|
||||
import * as ep___admin_drive_cleanup from './endpoints/admin/drive/cleanup.js';
|
||||
import * as ep___admin_drive_deleteAllFilesOfAUser from './endpoints/admin/drive/delete-all-files-of-a-user.js';
|
||||
|
@ -421,6 +422,7 @@ const eps = [
|
|||
['admin/unset-user-avatar', ep___admin_unsetUserAvatar],
|
||||
['admin/unset-user-banner', ep___admin_unsetUserBanner],
|
||||
['admin/dispose-cache', ep___admin_disposeCache],
|
||||
['admin/unset-user-mutual-link', ep___admin_unsetUserMutualLink],
|
||||
['admin/drive/clean-remote-files', ep___admin_drive_cleanRemoteFiles],
|
||||
['admin/drive/cleanup', ep___admin_drive_cleanup],
|
||||
['admin/drive/delete-all-files-of-a-user', ep___admin_drive_deleteAllFilesOfAUser],
|
||||
|
|
|
@ -0,0 +1,56 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
UsersRepository,
|
||||
UserProfilesRepository,
|
||||
} from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
||||
requireCredential: true,
|
||||
requireModerator: true,
|
||||
kind: 'write:admin:unset-user-mutual-link',
|
||||
} as const;
|
||||
|
||||
export const paramDef = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
userId: { type: 'string', format: 'misskey:id' },
|
||||
},
|
||||
required: ['userId'],
|
||||
} as const;
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
private moderationLogService: ModerationLogService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const user = await this.usersRepository.findOneBy({ id: ps.userId });
|
||||
const userProfile = await this.userProfilesRepository.findOneBy({ userId: ps.userId });
|
||||
|
||||
if (user == null || userProfile == null) {
|
||||
throw new Error('user not found');
|
||||
}
|
||||
|
||||
await this.userProfilesRepository.update(user.id, {
|
||||
mutualLinkSections: [],
|
||||
});
|
||||
|
||||
this.moderationLogService.log(me, 'unsetUserMutualLink', {
|
||||
userId: user.id,
|
||||
userUsername: user.username,
|
||||
userMutualLinkSections: userProfile.mutualLinkSections,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
|
@ -56,6 +56,12 @@ export const meta = {
|
|||
id: '539f3a45-f215-4f81-a9a8-31293640207f',
|
||||
},
|
||||
|
||||
noSuchFile: {
|
||||
message: 'No such file.',
|
||||
code: 'NO_SUCH_FILE',
|
||||
id: 'e0f0d3c7-e704-4314-a0b5-04286d69a65c',
|
||||
},
|
||||
|
||||
noSuchBanner: {
|
||||
message: 'No such banner file.',
|
||||
code: 'NO_SUCH_BANNER',
|
||||
|
@ -68,6 +74,12 @@ export const meta = {
|
|||
id: 'f419f9f8-2f4d-46b1-9fb4-49d3a2fd7191',
|
||||
},
|
||||
|
||||
fileNotAnImage: {
|
||||
message: 'The specified file is not an image.',
|
||||
code: 'FILE_NOT_AN_IMAGE',
|
||||
id: '2851568b-5ad1-4031-bf0d-5320afebf3a9',
|
||||
},
|
||||
|
||||
bannerNotAnImage: {
|
||||
message: 'The file specified as a banner is not an image.',
|
||||
code: 'BANNER_NOT_AN_IMAGE',
|
||||
|
@ -178,8 +190,8 @@ export const paramDef = {
|
|||
mutedWords: { type: 'array', items: {
|
||||
oneOf: [
|
||||
{ type: 'array', items: { type: 'string' } },
|
||||
{ type: 'string' }
|
||||
]
|
||||
{ type: 'string' },
|
||||
],
|
||||
} },
|
||||
mutedInstances: { type: 'array', items: {
|
||||
type: 'string',
|
||||
|
@ -213,6 +225,28 @@ export const paramDef = {
|
|||
uniqueItems: true,
|
||||
items: { type: 'string' },
|
||||
},
|
||||
mutualLinkSections: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: { type: 'string', nullable: true },
|
||||
mutualLinks: {
|
||||
type: 'array',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
url: { type: 'string', format: 'url' },
|
||||
fileId: { type: 'string', format: 'misskey:id' },
|
||||
description: { type: 'string', nullable: true },
|
||||
},
|
||||
required: ['url', 'fileId'],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['mutualLinks'],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
@ -322,6 +356,43 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
updates.avatarBlurhash = null;
|
||||
}
|
||||
|
||||
if (ps.mutualLinkSections) {
|
||||
if (ps.mutualLinkSections.length > policy.mutualLinkSectionLimit) {
|
||||
throw new ApiError(meta.errors.restrictedByRole);
|
||||
}
|
||||
|
||||
const mutualLinkSections = ps.mutualLinkSections.map(async (section) => {
|
||||
if (section.mutualLinks.length > policy.mutualLinkLimit) {
|
||||
throw new ApiError(meta.errors.restrictedByRole);
|
||||
}
|
||||
|
||||
const mutualLinks = await Promise.all(section.mutualLinks.map(async (mutualLink) => {
|
||||
const file = await this.driveFilesRepository.findOneBy({ id: mutualLink.fileId });
|
||||
|
||||
if (!file) {
|
||||
throw new ApiError(meta.errors.noSuchFile);
|
||||
}
|
||||
if (!file.type.startsWith('image/')) {
|
||||
throw new ApiError(meta.errors.fileNotAnImage);
|
||||
}
|
||||
|
||||
return {
|
||||
url: mutualLink.url,
|
||||
fileId: file.id,
|
||||
imgSrc: this.driveFileEntityService.getPublicUrl(file),
|
||||
description: mutualLink.description ?? null,
|
||||
};
|
||||
}));
|
||||
|
||||
return {
|
||||
name: section.name ?? null,
|
||||
mutualLinks,
|
||||
};
|
||||
});
|
||||
|
||||
profileUpdates.mutualLinkSections = await Promise.all(mutualLinkSections);
|
||||
}
|
||||
|
||||
if (ps.bannerId) {
|
||||
if (!policy.canUpdateBanner) throw new ApiError(meta.errors.restrictedByRole);
|
||||
const banner = await this.driveFilesRepository.findOneBy({ id: ps.bannerId });
|
||||
|
|
|
@ -96,6 +96,7 @@ export const moderationLogTypes = [
|
|||
'deleteAvatarDecoration',
|
||||
'unsetUserAvatar',
|
||||
'unsetUserBanner',
|
||||
'unsetUserMutualLink',
|
||||
] as const;
|
||||
|
||||
export type ModerationLogPayloads = {
|
||||
|
@ -314,6 +315,11 @@ export type ModerationLogPayloads = {
|
|||
userHost: string | null;
|
||||
fileId: string;
|
||||
};
|
||||
unsetUserMutualLink: {
|
||||
userId: string;
|
||||
userUsername: string;
|
||||
userMutualLinkSections: { name: string | null; mutualLinks: { fileId: string; description: string | null; imgSrc: string; }[]; }[] | []
|
||||
}
|
||||
};
|
||||
|
||||
export type Serialized<T> = {
|
||||
|
|
|
@ -8,7 +8,7 @@ process.env.NODE_ENV = 'test';
|
|||
import * as assert from 'assert';
|
||||
import { inspect } from 'node:util';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import { api, post, role, signup, successfulApiCall, uploadFile, failedApiCall } from '../utils.js';
|
||||
import { api, failedApiCall, post, role, signup, successfulApiCall, uploadFile } from '../utils.js';
|
||||
import type * as misskey from 'misskey-js';
|
||||
|
||||
describe('ユーザー', () => {
|
||||
|
@ -75,6 +75,7 @@ describe('ユーザー', () => {
|
|||
lang: user.lang,
|
||||
fields: user.fields,
|
||||
verifiedLinks: user.verifiedLinks,
|
||||
mutualLinkSections: user.mutualLinkSections,
|
||||
followersCount: user.followersCount,
|
||||
followingCount: user.followingCount,
|
||||
notesCount: user.notesCount,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue