Merge branch 'develop' into upstream/2025.5.0
This commit is contained in:
commit
46bb75d274
116 changed files with 2636 additions and 973 deletions
|
|
@ -675,9 +675,11 @@ export class FileServerService {
|
|||
if (info.blocked) {
|
||||
reply.code(429);
|
||||
reply.send({
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
error: {
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
},
|
||||
});
|
||||
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -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"');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -890,6 +890,7 @@ export class ClientServerService {
|
|||
return await reply.view('info-card', {
|
||||
version: this.config.version,
|
||||
host: this.config.host,
|
||||
url: this.config.url,
|
||||
meta: this.meta,
|
||||
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
|
||||
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
|
||||
|
|
|
|||
|
|
@ -15,15 +15,21 @@ import type Logger from '@/logger.js';
|
|||
import { query } from '@/misc/prelude/url.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { ApiError } from '@/server/api/error.js';
|
||||
import { MiMeta } from '@/models/Meta.js';
|
||||
import { RedisKVCache } from '@/misc/cache.js';
|
||||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||
import type { NotesRepository } from '@/models/_.js';
|
||||
import type { MiAccessToken, NotesRepository } from '@/models/_.js';
|
||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
||||
import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js';
|
||||
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||
import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||
|
||||
export type LocalSummalyResult = SummalyResult & {
|
||||
|
|
@ -31,7 +37,27 @@ export type LocalSummalyResult = SummalyResult & {
|
|||
};
|
||||
|
||||
// Increment this to invalidate cached previews after a major change.
|
||||
const cacheFormatVersion = 2;
|
||||
const cacheFormatVersion = 3;
|
||||
|
||||
type PreviewRoute = {
|
||||
Querystring: {
|
||||
url?: string
|
||||
lang?: string,
|
||||
fetch?: string,
|
||||
i?: string,
|
||||
},
|
||||
};
|
||||
|
||||
type AuthArray = [user: MiLocalUser | null | undefined, app: MiAccessToken | null | undefined, actor: MiLocalUser | string];
|
||||
|
||||
// Up to 50 requests, then 10 / second (at 2 / 200ms rate)
|
||||
const previewLimit: Keyed<BucketRateLimit> = {
|
||||
key: '/url',
|
||||
type: 'bucket',
|
||||
size: 50,
|
||||
dripSize: 2,
|
||||
dripRate: 200,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UrlPreviewService {
|
||||
|
|
@ -58,6 +84,9 @@ export class UrlPreviewService {
|
|||
private readonly apDbResolverService: ApDbResolverService,
|
||||
private readonly apRequestService: ApRequestService,
|
||||
private readonly systemAccountService: SystemAccountService,
|
||||
private readonly apNoteService: ApNoteService,
|
||||
private readonly authenticateService: AuthenticateService,
|
||||
private readonly rateLimiterService: SkRateLimiterService,
|
||||
) {
|
||||
this.logger = this.loggerService.getLogger('url-preview');
|
||||
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
|
||||
|
|
@ -85,9 +114,9 @@ export class UrlPreviewService {
|
|||
|
||||
@bindThis
|
||||
public async handle(
|
||||
request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>,
|
||||
request: FastifyRequest<PreviewRoute>,
|
||||
reply: FastifyReply,
|
||||
): Promise<object | undefined> {
|
||||
): Promise<void> {
|
||||
const url = request.query.url;
|
||||
if (typeof url !== 'string' || !URL.canParse(url)) {
|
||||
reply.code(400);
|
||||
|
|
@ -101,38 +130,39 @@ export class UrlPreviewService {
|
|||
}
|
||||
|
||||
if (!this.meta.urlPreviewEnabled) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
message: 'URL preview is disabled',
|
||||
code: 'URL_PREVIEW_DISABLED',
|
||||
id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check rate limit
|
||||
const auth = await this.authenticate(request);
|
||||
if (!await this.checkRateLimit(auth, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
message: 'URL is blocked',
|
||||
code: 'URL_PREVIEW_BLOCKED',
|
||||
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fetch = !!request.query.fetch;
|
||||
if (fetch && !await this.checkFetchPermissions(auth, reply)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
|
||||
const cached = await this.previewCache.get(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
// Cache 1 day (matching redis)
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
|
||||
if (cached.activityPub) {
|
||||
cached.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(cached.activityPub);
|
||||
}
|
||||
|
||||
return cached;
|
||||
if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -144,14 +174,13 @@ export class UrlPreviewService {
|
|||
|
||||
// Repeat check, since redirects are allowed.
|
||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
|
||||
reply.code(403);
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
message: 'URL is blocked',
|
||||
code: 'URL_PREVIEW_BLOCKED',
|
||||
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
|
||||
|
|
@ -164,33 +193,76 @@ export class UrlPreviewService {
|
|||
await this.inferActivityPubLink(summary);
|
||||
}
|
||||
|
||||
if (summary.activityPub) {
|
||||
if (summary.activityPub && !summary.haveNoteLocally) {
|
||||
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
||||
summary.haveNoteLocally ||= !!await this.apDbResolverService.getNoteFromApId(summary.activityPub);
|
||||
const exists = await this.noteExists(summary.activityPub, fetch);
|
||||
|
||||
// Remove the AP flag if we encounter a permanent error fetching the note.
|
||||
if (exists === false) {
|
||||
summary.activityPub = null;
|
||||
summary.haveNoteLocally = undefined;
|
||||
} else {
|
||||
summary.haveNoteLocally = exists ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
// Await this to avoid hammering redis when a bunch of URLs are fetched at once
|
||||
await this.previewCache.set(cacheKey, summary);
|
||||
|
||||
// Cache 1 day (matching redis)
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
// Cache 1 day (matching redis), but only once we finalize the result
|
||||
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
return summary;
|
||||
return reply.code(200).send(summary);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
|
||||
|
||||
reply.code(422);
|
||||
reply.header('Cache-Control', 'max-age=3600');
|
||||
return {
|
||||
error: new ApiError({
|
||||
return reply.code(422).send({
|
||||
error: {
|
||||
message: 'Failed to get preview',
|
||||
code: 'URL_PREVIEW_FAILED',
|
||||
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendCachedPreview(cacheKey: string, reply: FastifyReply, fetch: boolean): Promise<boolean> {
|
||||
const summary = await this.previewCache.get(cacheKey);
|
||||
if (summary === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if note has loaded since we last cached the preview
|
||||
if (summary.activityPub && !summary.haveNoteLocally) {
|
||||
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
||||
const exists = await this.noteExists(summary.activityPub, fetch);
|
||||
|
||||
// Remove the AP flag if we encounter a permanent error fetching the note.
|
||||
if (exists === false) {
|
||||
summary.activityPub = null;
|
||||
summary.haveNoteLocally = undefined;
|
||||
} else {
|
||||
summary.haveNoteLocally = exists ?? false;
|
||||
}
|
||||
|
||||
// Persist the result once we finalize the result
|
||||
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||
await this.previewCache.set(cacheKey, summary);
|
||||
}
|
||||
}
|
||||
|
||||
// Cache 1 day (matching redis), but only once we finalize the result
|
||||
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||
reply.header('Cache-Control', 'public, max-age=86400');
|
||||
}
|
||||
|
||||
reply.code(200).send(summary);
|
||||
return true;
|
||||
}
|
||||
|
||||
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||
const agent = this.config.proxy
|
||||
? {
|
||||
|
|
@ -211,6 +283,7 @@ export class UrlPreviewService {
|
|||
}
|
||||
|
||||
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const proxy = meta.urlPreviewSummaryProxyUrl!;
|
||||
const queryStr = query({
|
||||
followRedirects: true,
|
||||
|
|
@ -302,4 +375,129 @@ export class UrlPreviewService {
|
|||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// true = exists, false = does not exist (permanently), null = does not exist (temporarily)
|
||||
private async noteExists(uri: string, fetch = false): Promise<boolean | null> {
|
||||
try {
|
||||
// Local note or cached remote note
|
||||
if (await this.apDbResolverService.getNoteFromApId(uri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Un-cached remote note
|
||||
if (!fetch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Newly cached remote note
|
||||
if (await this.apNoteService.resolveNote(uri)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Non-existent or deleted note
|
||||
return false;
|
||||
} catch (err) {
|
||||
// Errors, including invalid notes and network errors
|
||||
return isRetryableError(err) ? null : false;
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from ApiCallService
|
||||
private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise<AuthArray> {
|
||||
const body = request.method === 'GET' ? request.query : request.body;
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
|
||||
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||
? request.headers.authorization.slice(7)
|
||||
: body?.['i'];
|
||||
if (token != null && typeof token !== 'string') {
|
||||
return [undefined, undefined, getIpHash(request.ip)];
|
||||
}
|
||||
|
||||
try {
|
||||
const auth = await this.authenticateService.authenticate(token);
|
||||
return [auth[0], auth[1], auth[0] ?? getIpHash(request.ip)];
|
||||
} catch (err) {
|
||||
if (err instanceof AuthenticationError) {
|
||||
return [undefined, undefined, getIpHash(request.ip)];
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Adapted from ApiCallService
|
||||
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||
const [user, app] = auth;
|
||||
|
||||
// Authentication
|
||||
if (user === undefined) {
|
||||
reply.code(401).send({
|
||||
error: {
|
||||
message: 'Authentication failed. Please ensure your token is correct.',
|
||||
code: 'AUTHENTICATION_FAILED',
|
||||
id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (user === null) {
|
||||
reply.code(401).send({
|
||||
error: {
|
||||
message: 'Credential required.',
|
||||
code: 'CREDENTIAL_REQUIRED',
|
||||
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Authorization
|
||||
if (user.isSuspended || user.isDeleted) {
|
||||
reply.code(403).send({
|
||||
error: {
|
||||
message: 'Your account has been suspended.',
|
||||
code: 'YOUR_ACCOUNT_SUSPENDED',
|
||||
kind: 'permission',
|
||||
|
||||
id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
if (app && !app.permission.includes('read:account')) {
|
||||
reply.code(403).send({
|
||||
error: {
|
||||
message: 'Your app does not have the necessary permissions to use this endpoint.',
|
||||
code: 'PERMISSION_DENIED',
|
||||
kind: 'permission',
|
||||
id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838',
|
||||
},
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||
const info = await this.rateLimiterService.limit(previewLimit, auth[2]);
|
||||
|
||||
// Always send headers, even if not blocked
|
||||
sendRateLimitHeaders(reply, info);
|
||||
|
||||
if (info.blocked) {
|
||||
reply.code(429).send({
|
||||
error: {
|
||||
message: 'Rate limit exceeded. Please try again later.',
|
||||
code: 'RATE_LIMIT_EXCEEDED',
|
||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||
},
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ html
|
|||
}
|
||||
|
||||
body
|
||||
a#a(href=`https://${host}` target="_blank")
|
||||
a#a(href=url target="_blank")
|
||||
header#banner(style=`background-image: url(${meta.bannerUrl})`)
|
||||
div#title= meta.name || host
|
||||
div#content
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue