Merge branch 'develop' into upstream/2025.5.0
This commit is contained in:
commit
46bb75d274
116 changed files with 2636 additions and 973 deletions
|
|
@ -445,6 +445,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
translationTimeout: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
deeplAuthKey: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
@ -477,6 +481,10 @@ export const meta = {
|
|||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
},
|
||||
defaultLike: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
optional: false, nullable: true,
|
||||
|
|
@ -741,6 +749,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
objectStorageUseProxy: instance.objectStorageUseProxy,
|
||||
objectStorageSetPublicRead: instance.objectStorageSetPublicRead,
|
||||
objectStorageS3ForcePathStyle: instance.objectStorageS3ForcePathStyle,
|
||||
translationTimeout: instance.translationTimeout,
|
||||
deeplAuthKey: instance.deeplAuthKey,
|
||||
deeplIsPro: instance.deeplIsPro,
|
||||
deeplFreeMode: instance.deeplFreeMode,
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { RoleEntityService } from '@/core/entities/RoleEntityService.js';
|
|||
import { IdService } from '@/core/IdService.js';
|
||||
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
|
@ -186,6 +187,36 @@ export const meta = {
|
|||
},
|
||||
},
|
||||
},
|
||||
followStats: {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
totalFollowing: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
totalFollowers: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localFollowing: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
localFollowers: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
remoteFollowing: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
remoteFollowers: {
|
||||
type: 'number',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -213,6 +244,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private roleService: RoleService,
|
||||
private roleEntityService: RoleEntityService,
|
||||
private idService: IdService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const [user, profile] = await Promise.all([
|
||||
|
|
@ -237,6 +269,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
const roleAssigns = await this.roleService.getUserAssigns(user.id);
|
||||
const roles = await this.roleService.getUserRoles(user.id);
|
||||
|
||||
const followStats = await this.cacheService.getFollowStats(user.id);
|
||||
|
||||
return {
|
||||
email: profile.email,
|
||||
emailVerified: profile.emailVerified,
|
||||
|
|
@ -269,6 +303,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
expiresAt: a.expiresAt ? a.expiresAt.toISOString() : null,
|
||||
roleId: a.roleId,
|
||||
})),
|
||||
followStats: {
|
||||
...followStats,
|
||||
totalFollowers: Math.max(user.followersCount, followStats.localFollowers + followStats.remoteFollowers),
|
||||
totalFollowing: Math.max(user.followingCount, followStats.localFollowing + followStats.remoteFollowing),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ export const paramDef = {
|
|||
description: { type: 'string', nullable: true },
|
||||
defaultLightTheme: { type: 'string', nullable: true },
|
||||
defaultDarkTheme: { type: 'string', nullable: true },
|
||||
defaultLike: { type: 'string', nullable: true },
|
||||
defaultLike: { type: 'string' },
|
||||
cacheRemoteFiles: { type: 'boolean' },
|
||||
cacheRemoteSensitiveFiles: { type: 'boolean' },
|
||||
emailRequiredForSignup: { type: 'boolean' },
|
||||
|
|
@ -103,6 +103,7 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
translationTimeout: { type: 'number' },
|
||||
deeplAuthKey: { type: 'string', nullable: true },
|
||||
deeplIsPro: { type: 'boolean' },
|
||||
deeplFreeMode: { type: 'boolean' },
|
||||
|
|
@ -571,6 +572,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.objectStorageS3ForcePathStyle = ps.objectStorageS3ForcePathStyle;
|
||||
}
|
||||
|
||||
if (ps.translationTimeout !== undefined) {
|
||||
set.translationTimeout = ps.translationTimeout;
|
||||
}
|
||||
|
||||
if (ps.deeplAuthKey !== undefined) {
|
||||
if (ps.deeplAuthKey === '') {
|
||||
set.deeplAuthKey = null;
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// ランキング更新
|
||||
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
|
||||
await this.featuredService.updateGalleryPostsRanking(post.id, 1);
|
||||
await this.featuredService.updateGalleryPostsRanking(post, 1);
|
||||
}
|
||||
|
||||
this.galleryPostsRepository.increment({ id: post.id }, 'likedCount', 1);
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
// ランキング更新
|
||||
if (Date.now() - this.idService.parse(post.id).date.getTime() < GALLERY_POSTS_RANKING_WINDOW) {
|
||||
await this.featuredService.updateGalleryPostsRanking(post.id, -1);
|
||||
await this.featuredService.updateGalleryPostsRanking(post, -1);
|
||||
}
|
||||
|
||||
this.galleryPostsRepository.decrement({ id: post.id }, 'likedCount', 1);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { safeForSql } from "@/misc/safe-for-sql.js";
|
|||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
|
||||
export const meta = {
|
||||
requireCredential: false,
|
||||
|
|
@ -41,6 +42,7 @@ export const paramDef = {
|
|||
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
|
||||
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
|
||||
trending: { type: 'boolean', default: false },
|
||||
},
|
||||
required: ['tag', 'sort'],
|
||||
} as const;
|
||||
|
|
@ -52,6 +54,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private usersRepository: UsersRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
if (!safeForSql(normalizeForSearch(ps.tag))) throw new Error('Injection');
|
||||
|
|
@ -80,7 +83,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
case '-updatedAt': query.orderBy('user.updatedAt', 'ASC'); break;
|
||||
}
|
||||
|
||||
const users = await query.limit(ps.limit).getMany();
|
||||
let users = await query.limit(ps.limit).getMany();
|
||||
|
||||
// This is not ideal, for a couple of reasons:
|
||||
// 1. It may return less than "limit" results.
|
||||
// 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
|
||||
// Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
|
||||
if (ps.trending) {
|
||||
const usersWithRoles = await Promise.all(users.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
|
||||
users = usersWithRoles
|
||||
.filter(([,p]) => p.canTrend)
|
||||
.map(([u]) => u);
|
||||
}
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -117,7 +117,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
.leftJoinAndSelect('note.renote', 'renote')
|
||||
.leftJoinAndSelect('reply.user', 'replyUser')
|
||||
.leftJoinAndSelect('renote.user', 'renoteUser')
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
.leftJoinAndSelect('note.channel', 'channel')
|
||||
.andWhere('user.isExplorable = TRUE');
|
||||
|
||||
this.queryService.generateBlockedHostQueryForNote(query);
|
||||
this.queryService.generateSuspendedUserQueryForNote(query);
|
||||
|
|
|
|||
|
|
@ -10,22 +10,28 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
|
|||
import { HttpRequestService } from '@/core/HttpRequestService.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { MiMeta } from '@/models/_.js';
|
||||
import type { MiMeta, MiNote } from '@/models/_.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { hasText } from '@/models/Note.js';
|
||||
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notes'],
|
||||
|
||||
// TODO allow unauthenticated if default template allows?
|
||||
// Maybe a value 'optional' that allows unauthenticated OR a token w/ appropriate role.
|
||||
// This will allow unauthenticated requests without leaking post data to restricted clients.
|
||||
requireCredential: true,
|
||||
kind: 'read:account',
|
||||
|
||||
res: {
|
||||
type: 'object',
|
||||
optional: true, nullable: false,
|
||||
optional: false, nullable: false,
|
||||
properties: {
|
||||
sourceLang: { type: 'string' },
|
||||
text: { type: 'string' },
|
||||
sourceLang: { type: 'string', optional: true, nullable: false },
|
||||
text: { type: 'string', optional: true, nullable: false },
|
||||
},
|
||||
},
|
||||
|
||||
|
|
@ -45,6 +51,11 @@ export const meta = {
|
|||
code: 'CANNOT_TRANSLATE_INVISIBLE_NOTE',
|
||||
id: 'ea29f2ca-c368-43b3-aaf1-5ac3e74bbe5d',
|
||||
},
|
||||
translationFailed: {
|
||||
message: 'Failed to translate note. Please try again later or contact an administrator for assistance.',
|
||||
code: 'TRANSLATION_FAILED',
|
||||
id: '4e7a1a4f-521c-4ba2-b10a-69e5e2987b2f',
|
||||
},
|
||||
},
|
||||
|
||||
// 10 calls per 5 seconds
|
||||
|
|
@ -73,6 +84,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private getterService: GetterService,
|
||||
private httpRequestService: HttpRequestService,
|
||||
private roleService: RoleService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly loggerService: ApiLoggerService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const policies = await this.roleService.getUserPolicies(me.id);
|
||||
|
|
@ -89,8 +102,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
|
||||
}
|
||||
|
||||
if (note.text == null) {
|
||||
return;
|
||||
if (!hasText(note)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const canDeeplFree = this.serverSettings.deeplFreeMode && !!this.serverSettings.deeplFreeInstance;
|
||||
|
|
@ -101,13 +114,33 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
let targetLang = ps.targetLang;
|
||||
if (targetLang.includes('-')) targetLang = targetLang.split('-')[0];
|
||||
|
||||
let response = await this.cacheService.getCachedTranslation(note, targetLang);
|
||||
if (!response) {
|
||||
this.loggerService.logger.debug(`Fetching new translation for note=${note.id} lang=${targetLang}`);
|
||||
response = await this.fetchTranslation(note, targetLang);
|
||||
if (!response) {
|
||||
throw new ApiError(meta.errors.translationFailed);
|
||||
}
|
||||
|
||||
await this.cacheService.setCachedTranslation(note, targetLang, response);
|
||||
}
|
||||
return response;
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchTranslation(note: MiNote & { text: string }, targetLang: string) {
|
||||
// Load-bearing try/catch - removing this will shift indentation and cause ~80 lines of upstream merge conflicts
|
||||
try {
|
||||
// Ignore deeplFreeInstance unless deeplFreeMode is set
|
||||
const deeplFreeInstance = this.serverSettings.deeplFreeMode ? this.serverSettings.deeplFreeInstance : null;
|
||||
|
||||
// DeepL/DeepLX handling
|
||||
if (canDeepl) {
|
||||
if (this.serverSettings.deeplAuthKey || deeplFreeInstance) {
|
||||
const params = new URLSearchParams();
|
||||
if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
|
||||
params.append('text', note.text);
|
||||
params.append('target_lang', targetLang);
|
||||
const endpoint = canDeeplFree ? this.serverSettings.deeplFreeInstance as string : this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
const endpoint = deeplFreeInstance ?? this.serverSettings.deeplIsPro ? 'https://api.deepl.com/v2/translate' : 'https://api-free.deepl.com/v2/translate';
|
||||
|
||||
const res = await this.httpRequestService.send(endpoint, {
|
||||
method: 'POST',
|
||||
|
|
@ -116,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
Accept: 'application/json, */*',
|
||||
},
|
||||
body: params.toString(),
|
||||
timeout: this.serverSettings.translationTimeout,
|
||||
});
|
||||
if (this.serverSettings.deeplAuthKey) {
|
||||
const json = (await res.json()) as {
|
||||
|
|
@ -151,8 +185,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
}
|
||||
|
||||
// LibreTranslate handling
|
||||
if (canLibre) {
|
||||
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL as string, {
|
||||
if (this.serverSettings.libreTranslateURL) {
|
||||
const res = await this.httpRequestService.send(this.serverSettings.libreTranslateURL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -165,6 +199,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
format: 'text',
|
||||
api_key: this.serverSettings.libreTranslateKey ?? '',
|
||||
}),
|
||||
timeout: this.serverSettings.translationTimeout,
|
||||
});
|
||||
|
||||
const json = (await res.json()) as {
|
||||
|
|
@ -182,8 +217,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
text: json.translatedText,
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
this.loggerService.logger.error('Unhandled error from translation API: ', { e });
|
||||
}
|
||||
|
||||
return;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@
|
|||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { UsersRepository } from '@/models/_.js';
|
||||
import { MiFollowing } from '@/models/_.js';
|
||||
import type { MiUser, UsersRepository } from '@/models/_.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { QueryService } from '@/core/QueryService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import type { SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
export const meta = {
|
||||
tags: ['users'],
|
||||
|
|
@ -38,7 +41,7 @@ export const paramDef = {
|
|||
properties: {
|
||||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
offset: { type: 'integer', default: 0 },
|
||||
sort: { type: 'string', enum: ['+follower', '-follower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
sort: { type: 'string', enum: ['+follower', '-follower', '+localFollower', '-localFollower', '+createdAt', '-createdAt', '+updatedAt', '-updatedAt'] },
|
||||
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
|
||||
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
|
||||
hostname: {
|
||||
|
|
@ -59,6 +62,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
|
||||
private userEntityService: UserEntityService,
|
||||
private queryService: QueryService,
|
||||
private readonly roleService: RoleService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const query = this.usersRepository.createQueryBuilder('user')
|
||||
|
|
@ -81,6 +85,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
switch (ps.sort) {
|
||||
case '+follower': query.orderBy('user.followersCount', 'DESC'); break;
|
||||
case '-follower': query.orderBy('user.followersCount', 'ASC'); break;
|
||||
case '+localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'DESC'); break;
|
||||
case '-localFollower': this.addLocalFollowers(query); query.orderBy('f."localFollowers"', 'ASC'); break;
|
||||
case '+createdAt': query.orderBy('user.id', 'DESC'); break;
|
||||
case '-createdAt': query.orderBy('user.id', 'ASC'); break;
|
||||
case '+updatedAt': query.andWhere('user.updatedAt IS NOT NULL').orderBy('user.updatedAt', 'DESC'); break;
|
||||
|
|
@ -94,9 +100,29 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
query.limit(ps.limit);
|
||||
query.offset(ps.offset);
|
||||
|
||||
const users = await query.getMany();
|
||||
const allUsers = await query.getMany();
|
||||
|
||||
// This is not ideal, for a couple of reasons:
|
||||
// 1. It may return less than "limit" results.
|
||||
// 2. A span of more than "limit" consecutive non-trendable users may cause the pagination to stop early.
|
||||
// Unfortunately, there's no better solution unless we refactor role policies to be persisted to the DB.
|
||||
const usersWithRoles = await Promise.all(allUsers.map(async u => [u, await this.roleService.getUserPolicies(u)] as const));
|
||||
const users = usersWithRoles
|
||||
.filter(([,p]) => p.canTrend)
|
||||
.map(([u]) => u);
|
||||
|
||||
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
|
||||
});
|
||||
}
|
||||
|
||||
private addLocalFollowers(query: SelectQueryBuilder<MiUser>) {
|
||||
query.innerJoin(qb => {
|
||||
return qb
|
||||
.from(MiFollowing, 'f')
|
||||
.addSelect('f."followeeId"')
|
||||
.addSelect('COUNT(*) FILTER (where f."followerHost" IS NULL)', 'localFollowers')
|
||||
.addSelect('COUNT(*) FILTER (where f."followeeHost" IS NOT NULL)', 'remoteFollowers')
|
||||
.groupBy('"followeeId"');
|
||||
}, 'f', 'user.id = f."followeeId"');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue