Merge branch 'develop' into upstream/2025.5.0

This commit is contained in:
dakkar 2025-05-30 11:13:37 +01:00
commit 46bb75d274
116 changed files with 2636 additions and 973 deletions

View file

@ -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;

View file

@ -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,

View file

@ -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),
},
};
});
}

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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' });
});

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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"');
}
}

View file

@ -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() }),

View file

@ -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;
}
}

View file

@ -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