Merge branch 'develop' into upstream/2025.5.0

This commit is contained in:
dakkar 2025-06-10 14:02:32 +01:00
commit 3ebf9c4a71
317 changed files with 6144 additions and 2603 deletions

View file

@ -32,6 +32,7 @@ import { getIpHash } from '@/misc/get-ip-hash.js';
import { AuthenticateService } from '@/server/api/AuthenticateService.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { Keyed, RateLimit, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
const _filename = fileURLToPath(import.meta.url);
@ -120,7 +121,7 @@ export class FileServerService {
@bindThis
private async errorHandler(request: FastifyRequest<{ Params?: { [x: string]: any }; Querystring?: { [x: string]: any }; }>, reply: FastifyReply, err?: any) {
this.logger.error(`${err}`);
this.logger.error(`Unhandled error in file server: ${renderInlineError(err)}`);
reply.header('Cache-Control', 'max-age=300');
@ -353,7 +354,7 @@ export class FileServerService {
if (!request.headers['user-agent']) {
throw new StatusError('User-Agent is required', 400, 'User-Agent is required');
} else if (request.headers['user-agent'].toLowerCase().indexOf('misskey/') !== -1) {
throw new StatusError('Refusing to proxy a request from another proxy', 403, 'Proxy is recursive');
throw new StatusError(`Refusing to proxy recursive request to ${url} (from user-agent ${request.headers['user-agent']})`, 403, 'Proxy is recursive');
}
// Create temp file
@ -383,7 +384,7 @@ export class FileServerService {
) {
if (!isConvertibleImage) {
// 画像でないなら404でお茶を濁す
throw new StatusError('Unexpected mime', 404);
throw new StatusError(`Unexpected non-convertible mime: ${file.mime}`, 404, 'Unexpected mime');
}
}
@ -447,7 +448,7 @@ export class FileServerService {
} else if (file.mime === 'image/svg+xml') {
image = this.imageProcessingService.convertToWebpStream(file.path, 2048, 2048);
} else if (!file.mime.startsWith('image/') || !FILE_TYPE_BROWSERSAFE.includes(file.mime)) {
throw new StatusError('Rejected type', 403, 'Rejected type');
throw new StatusError(`Blocked mime type: ${file.mime}`, 403, 'Blocked mime type');
}
if (!image) {
@ -521,7 +522,7 @@ export class FileServerService {
> {
if (url.startsWith(`${this.config.url}/files/`)) {
const key = url.replace(`${this.config.url}/files/`, '').split('/').shift();
if (!key) throw new StatusError('Invalid File Key', 400, 'Invalid File Key');
if (!key) throw new StatusError(`Invalid file URL ${url}`, 400, 'Invalid file url');
return await this.getFileFromKey(key);
}

View file

@ -21,6 +21,7 @@ import { genIdenticon } from '@/misc/gen-identicon.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { LoggerService } from '@/core/LoggerService.js';
import { bindThis } from '@/decorators.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { ActivityPubServerService } from './ActivityPubServerService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { ApiServerService } from './api/ApiServerService.js';
@ -277,7 +278,7 @@ export class ServerService implements OnApplicationShutdown {
this.logger.error(`Port ${this.config.port} is already in use by another process.`);
break;
default:
this.logger.error(err);
this.logger.error(`Unhandled error in server: ${renderInlineError(err)}`);
break;
}

View file

@ -389,7 +389,7 @@ function createLimitKey(limit: ParsedLimit, actor: string, value: string): strin
return `rl_${actor}_${limit.key}_${value}`;
}
class ConflictError extends Error {}
export class ConflictError extends Error {}
interface LimitCounter {
timestamp: number;

View file

@ -20,12 +20,14 @@ import { RoleService } from '@/core/RoleService.js';
import type { Config } from '@/config.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { ApiError } from './error.js';
import { ApiLoggerService } from './ApiLoggerService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { OnApplicationShutdown } from '@nestjs/common';
import type { IEndpointMeta, IEndpoint } from './endpoints.js';
import { renderFullError } from '@/misc/render-full-error.js';
const accessDenied = {
message: 'Access denied.',
@ -100,26 +102,26 @@ export class ApiCallService implements OnApplicationShutdown {
throw err;
} else {
const errId = randomUUID();
this.logger.error(`Internal error occurred in ${ep.name}: ${err.message}`, {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
stack: err.stack,
id: errId,
},
const fullError = renderFullError(err);
const message = typeof(fullError) === 'string'
? `Internal error id=${errId} occurred in ${ep.name}: ${fullError}`
: `Internal error id=${errId} occurred in ${ep.name}:`;
const data = typeof(fullError) === 'object'
? { e: fullError }
: {};
this.logger.error(message, {
user: userId ?? '<unauthenticated>',
...data,
});
if (this.config.sentryForBackend) {
Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${err.message}`, {
Sentry.captureMessage(`Internal error occurred in ${ep.name}: ${renderInlineError(err)}`, {
level: 'error',
user: {
id: userId,
},
extra: {
ep: ep.name,
ps: data,
e: {
message: err.message,
code: err.name,
@ -344,14 +346,14 @@ export class ApiCallService implements OnApplicationShutdown {
}
if (ep.meta.requireCredential || ep.meta.requireModerator || ep.meta.requireAdmin) {
if (user == null) {
if (user == null && ep.meta.requireCredential !== 'optional') {
throw new ApiError({
message: 'Credential required.',
code: 'CREDENTIAL_REQUIRED',
id: '1384574d-a912-4b81-8601-c7b1c4085df1',
httpStatusCode: 401,
});
} else if (user!.isSuspended) {
} else if (user?.isSuspended) {
throw new ApiError({
message: 'Your account has been suspended.',
code: 'YOUR_ACCOUNT_SUSPENDED',
@ -372,8 +374,8 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
if ((ep.meta.requireModerator || ep.meta.requireAdmin) && (this.meta.rootUserId !== user?.id)) {
const myRoles = user ? await this.roleService.getUserRoles(user) : [];
if (ep.meta.requireModerator && !myRoles.some(r => r.isModerator || r.isAdministrator)) {
throw new ApiError({
message: 'You are not assigned to a moderator role.',
@ -392,9 +394,9 @@ export class ApiCallService implements OnApplicationShutdown {
}
}
if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user!.id)) {
const myRoles = await this.roleService.getUserRoles(user!.id);
const policies = await this.roleService.getUserPolicies(user!.id);
if (ep.meta.requiredRolePolicy != null && (this.meta.rootUserId !== user?.id)) {
const myRoles = user ? await this.roleService.getUserRoles(user) : [];
const policies = await this.roleService.getUserPolicies(user ?? null);
if (!policies[ep.meta.requiredRolePolicy] && !myRoles.some(r => r.isAdministrator)) {
throw new ApiError({
message: 'You are not assigned to a required role.',
@ -418,7 +420,7 @@ export class ApiCallService implements OnApplicationShutdown {
// Cast non JSON input
if ((ep.meta.requireFile || request.method === 'GET') && ep.params.properties) {
for (const k of Object.keys(ep.params.properties)) {
const param = ep.params.properties![k];
const param = ep.params.properties[k];
if (['boolean', 'number', 'integer'].includes(param.type ?? '') && typeof data[k] === 'string') {
try {
data[k] = JSON.parse(data[k]);

View file

@ -36,7 +36,7 @@ export class GetterService {
const note = await this.notesRepository.findOneBy({ id: noteId });
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', `Note ${noteId} does not exist`);
}
return note;
@ -47,7 +47,7 @@ export class GetterService {
const note = await this.notesRepository.findOne({ where: { id: noteId }, relations: ['user'] });
if (note == null) {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', `Note ${noteId} does not exist`);
}
return note;
@ -59,7 +59,7 @@ export class GetterService {
@bindThis
public async getEdits(noteId: MiNote['id']) {
const edits = await this.noteEditRepository.findBy({ noteId: noteId }).catch(() => {
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', 'No such note.');
throw new IdentifiableError('9725d0ce-ba28-4dde-95a7-2cbb2c15de24', `Note ${noteId} does not exist`);
});
return edits;
@ -73,7 +73,7 @@ export class GetterService {
const user = await this.usersRepository.findOneBy({ id: userId });
if (user == null) {
throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', 'No such user.');
throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', `User ${userId} does not exist`);
}
return user as MiLocalUser | MiRemoteUser;

View file

@ -205,37 +205,37 @@ export class SigninApiService {
if (process.env.NODE_ENV !== 'test') {
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableFC && this.meta.fcSecretKey) {
await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
}

View file

@ -128,7 +128,7 @@ export class SigninWithPasskeyApiService {
try {
authorizedUserId = await this.webAuthnService.verifySignInWithPasskeyAuthentication(context, credential);
} catch (err) {
this.logger.warn(`Passkey challenge Verify error! : ${err}`);
this.logger.warn('Passkey challenge verify error:', err as Error);
const errorId = (err as IdentifiableError).id;
return error(403, {
id: errorId,

View file

@ -83,37 +83,37 @@ export class SignupApiService {
if (process.env.NODE_ENV !== 'test') {
if (this.meta.enableHcaptcha && this.meta.hcaptchaSecretKey) {
await this.captchaService.verifyHcaptcha(this.meta.hcaptchaSecretKey, body['hcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableMcaptcha && this.meta.mcaptchaSecretKey && this.meta.mcaptchaSitekey && this.meta.mcaptchaInstanceUrl) {
await this.captchaService.verifyMcaptcha(this.meta.mcaptchaSecretKey, this.meta.mcaptchaSitekey, this.meta.mcaptchaInstanceUrl, body['m-captcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableRecaptcha && this.meta.recaptchaSecretKey) {
await this.captchaService.verifyRecaptcha(this.meta.recaptchaSecretKey, body['g-recaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableTurnstile && this.meta.turnstileSecretKey) {
await this.captchaService.verifyTurnstile(this.meta.turnstileSecretKey, body['turnstile-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableFC && this.meta.fcSecretKey) {
await this.captchaService.verifyFriendlyCaptcha(this.meta.fcSecretKey, body['frc-captcha-solution']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
if (this.meta.enableTestcaptcha) {
await this.captchaService.verifyTestcaptcha(body['testcaptcha-response']).catch(err => {
throw new FastifyReplyError(400, err);
throw new FastifyReplyError(400, String(err), err);
});
}
}
@ -287,7 +287,7 @@ export class SignupApiService {
token: secret,
};
} catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());
throw new FastifyReplyError(400, String(err), err);
}
}
}
@ -356,7 +356,7 @@ export class SignupApiService {
return this.signinService.signin(request, reply, account as MiLocalUser);
} catch (err) {
throw new FastifyReplyError(400, typeof err === 'string' ? err : (err as Error).toString());
throw new FastifyReplyError(400, String(err), err);
}
}
}

View file

@ -92,7 +92,7 @@ export type IEndpointMeta = (Omit<IEndpointMetaBase, 'requireCrential' | 'requir
}) | (Omit<IEndpointMetaBase, 'secure'> & {
secure: true,
}) | (Omit<IEndpointMetaBase, 'requireCredential' | 'kind'> & {
requireCredential: true,
requireCredential: true | 'optional',
kind: (typeof permissions)[number],
}) | (Omit<IEndpointMetaBase, 'requireModerator' | 'kind'> & {
requireModerator: true,

View file

@ -69,6 +69,11 @@ export const meta = {
nullable: false, optional: false,
ref: 'UserDetailedNotMe',
},
targetInstance: {
type: 'object',
nullable: true, optional: false,
ref: 'FederationInstance',
},
assignee: {
type: 'object',
nullable: true, optional: false,
@ -115,7 +120,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId);
const query = this.queryService.makePaginationQuery(this.abuseUserReportsRepository.createQueryBuilder('report'), ps.sinceId, ps.untilId)
.leftJoinAndSelect('report.targetUser', 'targetUser')
.leftJoinAndSelect('targetUser.userProfile', 'targetUserProfile')
.leftJoinAndSelect('report.targetUserInstance', 'targetUserInstance')
.leftJoinAndSelect('report.reporter', 'reporter')
.leftJoinAndSelect('reporter.userProfile', 'reporterProfile')
.leftJoinAndSelect('report.assignee', 'assignee')
.leftJoinAndSelect('assignee.userProfile', 'assigneeProfile')
;
switch (ps.state) {
case 'resolved': query.andWhere('report.resolved = TRUE'); break;
@ -134,7 +147,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const reports = await query.limit(ps.limit).getMany();
return await this.abuseUserReportEntityService.packMany(reports);
return await this.abuseUserReportEntityService.packMany(reports, me);
});
}
}

View file

@ -68,11 +68,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private readonly moderationLogService: ModerationLogService,
) {
super(meta, paramDef, async (ps, me) => {
try {
if (new URL(ps.inbox).protocol !== 'https:') throw new Error('https only');
} catch {
throw new ApiError(meta.errors.invalidUrl);
}
if (!URL.canParse(ps.inbox)) throw new ApiError(meta.errors.invalidUrl);
if (new URL(ps.inbox).protocol !== 'https:') throw new ApiError(meta.errors.invalidUrl);
await this.moderationLogService.log(me, 'addRelay', {
inbox: ps.inbox,

View file

@ -122,6 +122,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
isAdministrator: {
type: 'boolean',
optional: false, nullable: false,
},
isSystem: {
type: 'boolean',
optional: false, nullable: false,
@ -257,6 +261,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
const isModerator = await this.roleService.isModerator(user);
const isAdministrator = await this.roleService.isAdministrator(user);
const isSilenced = user.isSilenced || !(await this.roleService.getUserPolicies(user.id)).canPublicNote;
const _me = await this.usersRepository.findOneByOrFail({ id: me.id });
@ -289,6 +294,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
mutedInstances: profile.mutedInstances,
notificationRecieveConfig: profile.notificationRecieveConfig,
isModerator: isModerator,
isAdministrator: isAdministrator,
isSystem: isSystemAccount(user),
isSilenced: isSilenced,
isSuspended: user.isSuspended,

View file

@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { trackPromise } from '@/misc/promise-tracker.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -75,6 +76,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService,
private globalEventService: GlobalEventService,
private readonly activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -106,7 +108,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return [];
}
const query = this.notesRepository.createQueryBuilder('note')
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId)
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
@ -122,13 +125,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany();
if (sinceId != null && untilId == null) {
notes.sort((a, b) => a.id < b.id ? -1 : 1);
} else {
notes.sort((a, b) => a.id > b.id ? -1 : 1);
}
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(notes, me);
});

View file

@ -3,10 +3,17 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
import { isCollectionOrOrderedCollection, isOrderedCollection, isOrderedCollectionPage } from '@/core/activitypub/type.js';
import { ApiError } from '@/server/api/error.js';
import { CacheService } from '@/core/CacheService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { DI } from '@/di-symbols.js';
import type { NotesRepository } from '@/models/_.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
export const meta = {
tags: ['federation'],
@ -21,6 +28,16 @@ export const meta = {
},
errors: {
noInputSpecified: {
message: 'uri, userId, or noteId must be specified.',
code: 'NO_INPUT_SPECIFIED',
id: 'b43ff2a7-e7a2-4237-ad7f-7b079563c09e',
},
multipleInputsSpecified: {
message: 'Only one of uri, userId, or noteId can be specified',
code: 'MULTIPLE_INPUTS_SPECIFIED',
id: 'f1aa27ed-8f20-44f3-a92a-fe073c8ca52b',
},
},
res: {
@ -32,19 +49,57 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {
uri: { type: 'string' },
uri: { type: 'string', nullable: true },
userId: { type: 'string', format: 'misskey:id', nullable: true },
noteId: { type: 'string', format: 'misskey:id', nullable: true },
expandCollectionItems: { type: 'boolean' },
expandCollectionLimit: { type: 'integer', nullable: true },
allowAnonymous: { type: 'boolean' },
},
required: ['uri'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.notesRepository)
private readonly notesRepository: NotesRepository,
private readonly cacheService: CacheService,
private readonly userEntityService: UserEntityService,
private readonly noteEntityService: NoteEntityService,
private apResolverService: ApResolverService,
) {
super(meta, paramDef, async (ps, me) => {
super(meta, paramDef, async (ps) => {
if (ps.uri && ps.userId && ps.noteId) {
throw new ApiError(meta.errors.multipleInputsSpecified);
}
let uri: string;
if (ps.uri) {
uri = ps.uri;
} else if (ps.userId) {
const user = await this.cacheService.findUserById(ps.userId);
uri = user.uri ?? this.userEntityService.genLocalUserUri(ps.userId);
} else if (ps.noteId) {
const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId });
uri = note.uri ?? this.noteEntityService.genLocalNoteUri(ps.noteId);
} else {
throw new ApiError(meta.errors.noInputSpecified);
}
const resolver = this.apResolverService.createResolver();
const object = await resolver.resolve(ps.uri);
const object = await resolver.resolve(uri, ps.allowAnonymous ?? false);
if (ps.expandCollectionItems && isCollectionOrOrderedCollection(object)) {
const items = await resolver.resolveCollectionItems(object, ps.expandCollectionLimit, ps.allowAnonymous ?? false);
if (isOrderedCollection(object) || isOrderedCollectionPage(object)) {
object.orderedItems = items;
} else {
object.items = items;
}
}
return object;
});
}

View file

@ -173,6 +173,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
case '09d79f9e-64f1-4316-9cfa-e75c4d091574':
throw new ApiError(meta.errors.federationNotAllowed);
case '72180409-793c-4973-868e-5a118eb5519b':
case 'd09dc850-b76c-4f45-875a-7389339d78b8':
case 'dc110060-a5f2-461d-808b-39c62702ca64':
case '45793ab7-7648-4886-b503-429f8a0d0f73':
case '4bf8f36b-4d33-4ac9-ad76-63fa11f354e9':
throw new ApiError(meta.errors.responseInvalid);
// resolveLocal

View file

@ -96,7 +96,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchChannel);
}
if (me) this.activeUsersChart.read(me);
if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
});
}
if (!this.serverSettings.enableFanoutTimeline) {
return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id, withFiles: ps.withFiles, withRenotes: ps.withRenotes }, me), me);
@ -135,30 +139,29 @@ 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')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion
return await query.limit(ps.limit).getMany();
return await query.getMany();
}
}

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -17,11 +17,11 @@ export const meta = {
allowGet: true,
cacheSec: 60 * 60,
// Burst up to 100, then 2/sec average
// Burst up to 200, then 5/sec average
limit: {
type: 'bucket',
size: 100,
dripRate: 500,
size: 200,
dripRate: 200,
},
} as const;

View file

@ -97,6 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
const notes = await query

View file

@ -81,10 +81,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.noSuchFile);
}
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId);
query.andWhere(':file <@ note.fileIds', { file: [file.id] });
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(':file <@ note.fileIds', { file: [file.id] })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
const notes = await query.limit(ps.limit).getMany();
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me, {
detail: true,

View file

@ -10,6 +10,8 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { DriveFileEntityService } from '@/core/entities/DriveFileEntityService.js';
import { DriveService } from '@/core/DriveService.js';
import type { Config } from '@/config.js';
import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { ApiError } from '../../../error.js';
import { MiMeta } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
@ -95,6 +97,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private driveFileEntityService: DriveFileEntityService,
private driveService: DriveService,
private readonly apiLoggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => {
// Get 'name' parameter
@ -130,14 +133,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return await this.driveFileEntityService.pack(driveFile, { self: true });
} catch (err) {
if (err instanceof Error || typeof err === 'string') {
console.error(err);
this.apiLoggerService.logger.error(`Error saving drive file: ${renderInlineError(err)}`);
}
if (err instanceof IdentifiableError) {
if (err.id === '282f77bf-5816-4f72-9264-aa14d8261a21') throw new ApiError(meta.errors.inappropriate);
if (err.id === 'c6244ed2-a39a-4e1c-bf93-f0fbd7764fa6') throw new ApiError(meta.errors.noFreeSpace);
if (err.id === 'f9e4e5f3-4df4-40b5-b400-f236945f7073') throw new ApiError(meta.errors.maxFileSizeExceeded);
}
throw new ApiError();
throw err;
} finally {
cleanup!();
}

View file

@ -72,7 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
))).filter(x => x != null);
if (files.length === 0) {
throw new Error();
throw new Error('no files specified');
}
const post = await this.galleryPostsRepository.insertOne(new MiGalleryPost({

View file

@ -73,7 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
))).filter(x => x != null);
if (files.length === 0) {
throw new Error();
throw new Error('no files');
}
}

View file

@ -70,7 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
try {
await this.userAuthService.twoFactorAuthenticate(profile, token);
} catch (e) {
throw new Error('authentication failed');
throw new Error('authentication failed', { cause: e });
}
}

View file

@ -17,7 +17,7 @@ import { ApiLoggerService } from '@/server/api/ApiLoggerService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import * as Acct from '@/misc/acct.js';
import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/_.js';
@ -105,7 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const { username, host } = Acct.parse(ps.moveToAccount);
// retrieve the destination account
let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(e)}`);
throw new ApiError(meta.errors.noSuchUser);
});
const destination = await this.getterService.getUser(moveTo.id) as MiLocalUser | MiRemoteUser;

View file

@ -20,9 +20,7 @@ export const meta = {
},
},
res: {
type: 'object',
},
res: {},
// 10 calls per 5 seconds
limit: {

View file

@ -34,6 +34,7 @@ import { verifyFieldLinks } from '@/misc/verify-field-link.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
import { userUnsignedFetchOptions } from '@/const.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { ApiLoggerService } from '../../ApiLoggerService.js';
import { ApiError } from '../../error.js';
@ -263,6 +264,15 @@ export const paramDef = {
enum: userUnsignedFetchOptions,
nullable: false,
},
attributionDomains: {
type: 'array',
items: {
type: 'string',
minLength: 1,
maxLength: 128,
},
maxItems: 32,
},
},
} as const;
@ -373,6 +383,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (ps.attributionDomains !== undefined) updates.attributionDomains = ps.attributionDomains;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;
@ -506,7 +517,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`);
this.apiLoggerService.logger.warn(`failed to resolve destination user: ${renderInlineError(e)}`);
throw new ApiError(meta.errors.noSuchUser);
});
if (knownAs.id === _user.id) throw new ApiError(meta.errors.forbiddenToSetYourself);
@ -663,7 +674,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// these two methods need to be kept in sync with
// `ApRendererService.renderPerson`
private userNeedsPublishing(oldUser: MiLocalUser, newUser: Partial<MiUser>): boolean {
const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore'];
const basicFields: (keyof MiUser)[] = ['avatarId', 'bannerId', 'backgroundId', 'isBot', 'username', 'name', 'isLocked', 'isExplorable', 'isCat', 'noindex', 'speakAsCat', 'movedToUri', 'alsoKnownAs', 'hideOnlineStatus', 'enableRss', 'requireSigninToViewContents', 'makeNotesFollowersOnlyBefore', 'makeNotesHiddenBefore', 'attributionDomains'];
for (const field of basicFields) {
if ((field in newUser) && oldUser[field] !== newUser[field]) {
return true;

View file

@ -64,7 +64,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.local) {
query.andWhere('note.userHost IS NULL');
@ -75,7 +84,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
if (ps.renote !== undefined) {
query.andWhere(ps.renote ? 'note.renoteId IS NOT NULL' : 'note.renoteId IS NULL');
if (ps.renote) {
this.queryService.andIsRenote(query, 'note');
if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
} else {
this.queryService.andIsNotRenote(query, 'note');
}
}
if (ps.withFiles !== undefined) {
@ -91,7 +108,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// query.isBot = bot;
//}
const notes = await query.limit(ps.limit).getMany();
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes);
});

View file

@ -1,13 +1,16 @@
/*
* SPDX-FileCopyrightText: Marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import type { NotesRepository, MiMeta } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -56,9 +59,6 @@ export const paramDef = {
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.meta)
private serverSettings: MiMeta,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@ -66,7 +66,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -74,29 +73,34 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.btlDisabled);
}
const [
followings,
] = me ? await Promise.all([
this.cacheService.userFollowingsCache.fetch(me.id),
]) : [undefined];
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.andWhere('note.userHost IN (:...hosts)', { hosts: this.serverSettings.bubbleInstances })
.andWhere('note.userHost IS NOT NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
// This subquery mess teaches postgres how to use the right indexes.
// Using WHERE or ON conditions causes a fallback to full sequence scan, which times out.
// Important: don't use a query builder here or TypeORM will get confused and stop quoting column names! (known, unfixed bug apparently)
query
.leftJoin('(select "host" from "instance" where "isBubbled" = true)', 'bubbleInstance', '"bubbleInstance"."host" = "note"."userHost"')
.andWhere('"bubbleInstance" IS NOT NULL');
this.queryService
.leftJoinInstance(query, 'note.userInstance', 'userInstance', '"userInstance"."host" = "bubbleInstance"."host"');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
@ -104,29 +108,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.where('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.where('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion
let timeline = await query.limit(ps.limit).getMany();
const timeline = await query.getMany();
timeline = timeline.filter(note => {
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
return true;
});
process.nextTick(() => {
if (me) {
if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
}
});
});
}
return await this.noteEntityService.packMany(timeline, me);
});

View file

@ -57,26 +57,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
qb
.where('note.replyId = :noteId', { noteId: ps.noteId });
if (ps.showQuotes) {
qb.orWhere(new Brackets(qb => {
qb
.where('note.renoteId = :noteId', { noteId: ps.noteId })
.andWhere(new Brackets(qb => {
qb
.where('note.text IS NOT NULL')
.orWhere('note.fileIds != \'{}\'')
.orWhere('note.hasPoll = TRUE');
}));
}));
}
qb.orWhere('note.replyId = :noteId');
if (ps.showQuotes) {
qb.orWhere(new Brackets(qbb => this.queryService
.andIsQuote(qbb, 'note')
.andWhere('note.renoteId = :noteId'),
));
}
}))
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.setParameters({ noteId: ps.noteId })
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
@ -86,7 +82,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const notes = await query.limit(ps.limit).getMany();
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me);
});

View file

@ -4,7 +4,7 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import { ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { IsNull, ObjectLiteral, SelectQueryBuilder } from 'typeorm';
import { SkLatestNote, MiFollowing } from '@/models/_.js';
import type { NotesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
@ -12,6 +12,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import { ApiError } from '@/server/api/error.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
export const meta = {
tags: ['notes'],
@ -76,8 +77,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private readonly noteEntityService: NoteEntityService,
private readonly queryService: QueryService,
private readonly activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
if (ps.includeReplies && ps.filesOnly) throw new ApiError(meta.errors.bothWithRepliesAndWithFiles);
@ -85,7 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.notesRepository
.createQueryBuilder('note')
.setParameter('me', me.id)
.setParameters({ meId: me.id })
// Limit to latest notes
.innerJoin(
@ -130,7 +132,9 @@ 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')
// Exclude channel notes
.andWhere({ channelId: IsNull() })
;
// Limit to files, if requested
@ -145,23 +149,26 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Hide blocked users / instances
query.andWhere('"user"."isSuspended" = false');
query.andWhere('("replyUser" IS NULL OR "replyUser"."isSuspended" = false)');
query.andWhere('("renoteUser" IS NULL OR "renoteUser"."isSuspended" = false)');
this.queryService.generateBlockedHostQueryForNote(query);
// Respect blocks and mutes
// Respect blocks, mutes, and privacy
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
// Support pagination
this.queryService
.makePaginationQuery(query, ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.orderBy('note.id', 'DESC')
.take(ps.limit);
// Query and return the next page
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(notes, me, { skipHide: true });
});
}
}
@ -170,14 +177,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
* Limit to followers (they follow us)
*/
function addFollower<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :me');
return query.innerJoin(MiFollowing, 'follower', 'follower."followerId" = latest.user_id AND follower."followeeId" = :meId');
}
/**
* Limit to followees (we follow them)
*/
function addFollowee<T extends SelectQueryBuilder<ObjectLiteral>>(query: T): T {
return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :me AND followee."followeeId" = latest.user_id');
return query.innerJoin(MiFollowing, 'followee', 'followee."followerId" = :meId AND followee."followeeId" = latest.user_id');
}
/**

View file

@ -12,7 +12,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { DI } from '@/di-symbols.js';
import { RoleService } from '@/core/RoleService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -68,7 +67,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private queryService: QueryService,
private roleService: RoleService,
private activeUsersChart: ActiveUsersChart,
private cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me ? me.id : null);
@ -76,8 +74,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw new ApiError(meta.errors.gtlDisabled);
}
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
@ -90,11 +86,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withFiles) {
@ -103,29 +98,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.where('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.where('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion
let timeline = await query.limit(ps.limit).getMany();
const timeline = await query.limit(ps.limit).getMany();
timeline = timeline.filter(note => {
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
return true;
});
process.nextTick(() => {
if (me) {
if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
}
});
});
}
return await this.noteEntityService.packMany(timeline, me);
});

View file

@ -66,9 +66,6 @@ export const paramDef = {
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
withReplies: { type: 'boolean', default: false },
@ -114,12 +111,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me);
process.nextTick(() => {
@ -178,12 +173,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId,
sinceId,
limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withReplies: ps.withReplies,
withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me),
});
@ -199,104 +192,59 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null,
sinceId: string | null,
limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean,
withReplies: boolean,
withBots: boolean,
withRenotes: boolean,
}, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({
where: {
followerId: me.id,
},
});
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
if (followees.length > 0) {
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
} else {
qb.where('note.userId = :meId', { meId: me.id });
qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
}
}))
// 1. by a user I follow, 2. a public local post, 3. my own post
.andWhere(new Brackets(qb => this.queryService
.orFollowingUser(qb, ':meId', 'note.userId')
.orWhere(new Brackets(qbb => qbb
.andWhere('note.visibility = \'public\'')
.andWhere('note.userHost IS NULL')))
.orWhere(':meId = note.userId')))
// 1. in a channel I follow, 2. not in a channel
.andWhere(new Brackets(qb => this.queryService
.orFollowingChannel(qb, ':meId', 'note.channelId')
.orWhere('note.channelId IS NULL')))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followingChannels.length > 0) {
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
qb.orWhere('note.channelId IS NULL');
}));
} else {
query.andWhere('note.channelId IS NULL');
}
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
query
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')));
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion
return await query.limit(ps.limit).getMany();
return await query.getMany();
}
}

View file

@ -103,13 +103,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles,
withReplies: ps.withReplies,
withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me);
process.nextTick(() => {
if (me) {
if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
}
});
});
}
return await this.noteEntityService.packMany(timeline, me);
}
@ -136,14 +137,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: ps.withFiles,
withReplies: ps.withReplies,
withBots: ps.withBots,
withRenotes: ps.withRenotes,
}, me),
});
process.nextTick(() => {
if (me) {
if (me) {
process.nextTick(() => {
this.activeUsersChart.read(me);
}
});
});
}
return timeline;
});
@ -156,41 +158,48 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
withFiles: boolean,
withReplies: boolean,
withBots: boolean,
withRenotes: boolean,
}, me: MiLocalUser | null) {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
ps.sinceId, ps.untilId)
.andWhere('(note.visibility = \'public\') AND (note.userHost IS NULL) AND (note.channelId IS NULL)')
.andWhere('note.visibility = \'public\'')
.andWhere('note.channelId IS NULL')
.andWhere('note.userHost IS NULL')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
if (!ps.withReplies) {
query
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')));
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (!ps.withReplies) {
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
}
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
return await query.limit(ps.limit).getMany();
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else if (me) {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
return await query.getMany();
}
}

View file

@ -6,10 +6,12 @@
import { Brackets } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, FollowingsRepository } from '@/models/_.js';
import { MiNote } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
export const meta = {
tags: ['notes'],
@ -57,43 +59,60 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private readonly activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
const followingQuery = this.followingsRepository.createQueryBuilder('following')
.select('following.followeeId')
.where('following.followerId = :followerId', { followerId: me.id });
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere(new Brackets(qb => {
qb // このmeIdAsListパラメータはqueryServiceのgenerateVisibilityQueryでセットされる
.where(':meIdAsList <@ note.mentions')
.orWhere(':meIdAsList <@ note.visibleUserIds');
}))
// Avoid scanning primary key index
.orderBy('CONCAT(note.id)', 'DESC')
.innerJoin(qb => {
qb
.select('note.id', 'id')
.from(qbb => qbb
.select('note.id', 'id')
.from(MiNote, 'note')
.where(new Brackets(qbbb => qbbb
// DM to me
.orWhere(':meIdAsList <@ note.visibleUserIds')
// Mentions me
.orWhere(':meIdAsList <@ note.mentions'),
))
.setParameters({ meIdAsList: [me.id] })
, 'source')
.innerJoin(MiNote, 'note', 'note.id = source.id');
// Mentioned or visible users can always access
//this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(qb);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(qb, me);
this.queryService.generateMutedNoteThreadQuery(qb, me);
this.queryService.generateBlockedUserQueryForNotes(qb, me);
// A renote can't mention a user, so it will never appear here anyway.
//this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.visibility) {
qb.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
}
if (ps.following) {
this.queryService
.andFollowingUser(qb, ':meId', 'note.userId')
.setParameters({ meId: me.id });
}
return qb;
}, 'source', 'source.id = note.id')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
const mentions = await query.getMany();
if (ps.visibility) {
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });
}
if (ps.following) {
query.andWhere(`((note.userId IN (${ followingQuery.getQuery() })) OR (note.userId = :meId))`, { meId: me.id });
query.setParameters(followingQuery.getParameters());
}
const mentions = await query.limit(ps.limit).getMany();
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(mentions, me);
});

View file

@ -9,13 +9,13 @@ import type { NotesRepository, MutingsRepository, PollsRepository, PollVotesRepo
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'read:account',
res: {
type: 'array',
optional: false, nullable: false,
@ -26,10 +26,24 @@ export const meta = {
},
},
// 2 calls per second
errors: {
ltlDisabled: {
message: 'Local timeline has been disabled.',
code: 'LTL_DISABLED',
id: '45a6eb02-7695-4393-b023-dd3be9aaaefd',
},
gtlDisabled: {
message: 'Global timeline has been disabled.',
code: 'GTL_DISABLED',
id: '0332fc13-6ab2-4427-ae80-a9fadffd1a6b',
},
},
// Up to 10 calls, then 2 per second
limit: {
duration: 1000,
max: 2,
type: 'bucket',
size: 10,
dripRate: 500,
},
} as const;
@ -39,6 +53,8 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
excludeChannels: { type: 'boolean', default: false },
local: { type: 'boolean', nullable: true, default: null },
expired: { type: 'boolean', default: false },
},
required: [],
} as const;
@ -59,18 +75,54 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private mutingsRepository: MutingsRepository,
private noteEntityService: NoteEntityService,
private readonly queryService: QueryService,
private readonly roleService: RoleService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.pollsRepository.createQueryBuilder('poll')
.where('poll.userHost IS NULL')
.andWhere('poll.userId != :meId', { meId: me.id })
.andWhere('poll.noteVisibility = \'public\'')
.andWhere(new Brackets(qb => {
.innerJoinAndSelect('poll.note', 'note')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('reply.user', 'replyUser')
.andWhere('user.isExplorable = TRUE')
;
if (me) {
query.andWhere('poll.userId != :meId', { meId: me.id });
}
if (ps.expired) {
query.andWhere('poll.expiresAt IS NOT NULL');
query.andWhere('poll.expiresAt <= :expiresMax', {
expiresMax: new Date(),
});
query.andWhere('poll.expiresAt >= :expiresMin', {
expiresMin: new Date(Date.now() - (1000 * 60 * 60 * 24 * 7)),
});
} else {
query.andWhere(new Brackets(qb => {
qb
.where('poll.expiresAt IS NULL')
.orWhere('poll.expiresAt > :now', { now: new Date() });
}));
}
const policies = await this.roleService.getUserPolicies(me?.id ?? null);
if (ps.local != null) {
if (ps.local) {
if (!policies.ltlAvailable) throw new ApiError(meta.errors.ltlDisabled);
query.andWhere('poll.userHost IS NULL');
} else {
if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled);
query.andWhere('poll.userHost IS NOT NULL');
}
} else {
if (!policies.gtlAvailable) throw new ApiError(meta.errors.gtlDisabled);
}
/*
//#region exclude arleady voted polls
const votedQuery = this.pollVotesRepository.createQueryBuilder('vote')
.select('vote.noteId')
@ -81,16 +133,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.setParameters(votedQuery.getParameters());
//#endregion
*/
//#region mute
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
.select('muting.muteeId')
.where('muting.muterId = :muterId', { muterId: me.id });
query
.andWhere(`poll.userId NOT IN (${ mutingQuery.getQuery() })`);
query.setParameters(mutingQuery.getParameters());
//#region block/mute/vis
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
if (me) {
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
}
//#endregion
//#region exclude channels
@ -107,6 +158,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (polls.length === 0) return [];
/*
const notes = await this.notesRepository.find({
where: {
id: In(polls.map(poll => poll.noteId)),
@ -115,6 +167,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
id: 'DESC',
},
});
*/
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const notes = polls.map(poll => poll.note!);
return await this.noteEntityService.packMany(notes, me, {
detail: true,

View file

@ -47,7 +47,7 @@ export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
userId: { type: "string", format: "misskey:id" },
userId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
@ -81,20 +81,22 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('renote.user', 'renoteUser');
if (ps.userId) {
query.andWhere("user.id = :userId", { userId: ps.userId });
query.andWhere('user.id = :userId', { userId: ps.userId });
}
if (ps.quote) {
query.andWhere("note.text IS NOT NULL");
this.queryService.andIsQuote(query, 'note');
} else {
query.andWhere("note.text IS NULL");
this.queryService.andIsRenote(query, 'note');
}
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const renotes = await query.limit(ps.limit).getMany();

View file

@ -59,15 +59,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
const timeline = await query.limit(ps.limit).getMany();
const timeline = await query.getMany();
return await this.noteEntityService.packMany(timeline, me);
});

View file

@ -12,8 +12,6 @@ import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { DI } from '@/di-symbols.js';
import { CacheService } from '@/core/CacheService.js';
import { UtilityService } from '@/core/UtilityService.js';
export const meta = {
tags: ['notes', 'hashtags'],
@ -82,27 +80,27 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private cacheService: CacheService,
private utilityService: UtilityService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.andWhere("note.visibility IN ('public', 'home')") // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
.andWhere(new Brackets(qb => qb
.orWhere('note.visibility = \'public\'')
.orWhere('note.visibility = \'home\''))) // keep in sync with NoteCreateService call to `hashtagService.updateHashtags()`
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const followings = me ? await this.cacheService.userFollowingsCache.fetch(me.id) : {};
if (!this.serverSettings.enableBotTrending) query.andWhere('user.isBot = FALSE');
try {
if (ps.tag) {
@ -135,9 +133,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.renote != null) {
if (ps.renote) {
query.andWhere('note.renoteId IS NOT NULL');
this.queryService.andIsRenote(query, 'note');
} else {
query.andWhere('note.renoteId IS NULL');
this.queryService.andIsNotRenote(query, 'note');
}
}
@ -154,17 +152,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// Search notes
let notes = await query.limit(ps.limit).getMany();
notes = notes.filter(note => {
if (note.user?.isSilenced && me && followings && note.userId !== me.id && !followings[note.userId]) return false;
if (note.user?.isSuspended) return false;
if (note.userHost) {
if (!this.utilityService.isFederationAllowedHost(note.userHost)) return false;
if (this.utilityService.isSilencedHost(this.serverSettings.silencedHosts, note.userHost)) return false;
}
return true;
});
const notes = await query.getMany();
return await this.noteEntityService.packMany(notes, me);
});

View file

@ -49,9 +49,6 @@ export const paramDef = {
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withFiles: { type: 'boolean', default: false },
withRenotes: { type: 'boolean', default: true },
withBots: { type: 'boolean', default: true },
@ -88,9 +85,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
withBots: ps.withBots,
@ -131,9 +125,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId,
sinceId,
limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
withBots: ps.withBots,
@ -148,114 +139,49 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
}
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
const followees = await this.userFollowingService.getFollowees(me.id);
const followingChannels = await this.channelFollowingsRepository.find({
where: {
followerId: me.id,
},
});
private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; withFiles: boolean; withRenotes: boolean; withBots: boolean; }, me: MiLocalUser) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
// 1. in a channel I follow, 2. my own post, 3. by a user I follow
.andWhere(new Brackets(qb => this.queryService
.orFollowingChannel(qb, ':meId', 'note.channelId')
.orWhere(':meId = note.userId')
.orWhere(new Brackets(qb2 => this.queryService
.andFollowingUser(qb2, ':meId', 'note.userId')
.andWhere('note.channelId IS NULL'))),
))
// 1. Not a reply, 2. a self-reply
.andWhere(new Brackets(qb => qb
.orWhere('note.replyId IS NULL') // 返信ではない
.orWhere('note.replyUserId = note.userId')))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
if (followees.length > 0 && followingChannels.length > 0) {
// ユーザー・チャンネルともにフォローあり
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where(new Brackets(qb2 => {
qb2
.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds })
.andWhere('note.channelId IS NULL');
}))
.orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds });
}));
} else if (followees.length > 0) {
// ユーザーフォローのみ(チャンネルフォローなし)
const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
} else if (followingChannels.length > 0) {
// チャンネルフォローのみ(ユーザーフォローなし)
const followingChannelIds = followingChannels.map(x => x.followeeId);
query.andWhere(new Brackets(qb => {
qb
.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds })
.orWhere('note.userId = :meId', { meId: me.id });
}));
} else {
// フォローなし
query
.andWhere('note.channelId IS NULL')
.andWhere('note.userId = :meId', { meId: me.id });
}
query.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}));
}));
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (ps.withRenotes === false) {
query.andWhere('note.renoteId IS NULL');
}
if (!ps.withBots) query.andWhere('user.isBot = FALSE');
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion
return await query.limit(ps.limit).getMany();
return await query.getMany();
}
}

View file

@ -20,11 +20,9 @@ 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,
requireCredential: 'optional',
kind: 'read:account',
requiredRolePolicy: 'canUseTranslator',
res: {
type: 'object',
@ -88,17 +86,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private readonly loggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me) => {
const policies = await this.roleService.getUserPolicies(me.id);
if (!policies.canUseTranslator) {
throw new ApiError(meta.errors.unavailable);
}
const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
if (!(await this.noteEntityService.isVisibleForMe(note, me.id))) {
if (!(await this.noteEntityService.isVisibleForMe(note, me?.id ?? null))) {
throw new ApiError(meta.errors.cannotTranslateInvisibleNote);
}
@ -140,7 +133,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (this.serverSettings.deeplAuthKey) params.append('auth_key', this.serverSettings.deeplAuthKey);
params.append('text', note.text);
params.append('target_lang', targetLang);
const endpoint = deeplFreeInstance ?? 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',

View file

@ -57,9 +57,6 @@ export const paramDef = {
sinceDate: { type: 'integer' },
untilDate: { type: 'integer' },
allowPartial: { type: 'boolean', default: false }, // true is recommended but for compatibility false by default
includeMyRenotes: { type: 'boolean', default: true },
includeRenotedMyNotes: { type: 'boolean', default: true },
includeLocalRenotes: { type: 'boolean', default: true },
withRenotes: { type: 'boolean', default: true },
withFiles: {
type: 'boolean',
@ -109,14 +106,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId,
sinceId,
limit: ps.limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me);
this.activeUsersChart.read(me);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(timeline, me);
}
@ -135,15 +131,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId,
sinceId,
limit,
includeMyRenotes: ps.includeMyRenotes,
includeRenotedMyNotes: ps.includeRenotedMyNotes,
includeLocalRenotes: ps.includeLocalRenotes,
withFiles: ps.withFiles,
withRenotes: ps.withRenotes,
}, me),
});
this.activeUsersChart.read(me);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return timeline;
});
@ -153,94 +148,50 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
untilId: string | null,
sinceId: string | null,
limit: number,
includeMyRenotes: boolean,
includeRenotedMyNotes: boolean,
includeLocalRenotes: boolean,
withFiles: boolean,
withRenotes: boolean,
}, me: MiLocalUser) {
//#region Construct query
const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId)
.innerJoin(this.userListMembershipsRepository.metadata.targetName, 'userListMemberships', 'userListMemberships.userId = note.userId')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => qb
// 返信ではない
.orWhere('note.replyId IS NULL')
// 返信だけど投稿者自身への返信
.orWhere('note.replyUserId = note.userId')
// 返信だけど自分宛ての返信
.orWhere('note.replyUserId = :meId')
// 返信だけどwithRepliesがtrueの場合
.orWhere('userListMemberships.withReplies = true'),
))
.setParameters({ meId: me.id })
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser')
.andWhere('userListMemberships.userListId = :userListId', { userListId: list.id })
.andWhere('note.channelId IS NULL') // チャンネルノートではない
.andWhere(new Brackets(qb => {
qb
.where('note.replyId IS NULL') // 返信ではない
.orWhere(new Brackets(qb => {
qb // 返信だけど投稿者自身への返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = note.userId');
}))
.orWhere(new Brackets(qb => {
qb // 返信だけど自分宛ての返信
.where('note.replyId IS NOT NULL')
.andWhere('note.replyUserId = :meId', { meId: me.id });
}))
.orWhere(new Brackets(qb => {
qb // 返信だけどwithRepliesがtrueの場合
.where('note.replyId IS NOT NULL')
.andWhere('userListMemberships.withReplies = true');
}));
}));
.limit(ps.limit);
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeRenotedMyNotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.includeLocalRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteUserHost IS NOT NULL');
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
}
if (ps.withRenotes === false) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.renoteId IS NULL');
qb.orWhere(new Brackets(qb => {
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
}));
}));
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');
}
if (!ps.withRenotes) {
this.queryService.generateExcludedRenotesQueryForNotes(query);
} else {
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
//#endregion
return await query.limit(ps.limit).getMany();
return await query.getMany();
}
}

View file

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { IdService } from '@/core/IdService.js';
import { FanoutTimelineService } from '@/core/FanoutTimelineService.js';
import ActiveUsersChart from '@/core/chart/charts/active-users.js';
import { ApiError } from '../../error.js';
export const meta = {
@ -74,6 +75,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private noteEntityService: NoteEntityService,
private queryService: QueryService,
private fanoutTimelineService: FanoutTimelineService,
private readonly activeUsersChart: ActiveUsersChart,
) {
super(meta, paramDef, async (ps, me) => {
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@ -101,20 +103,25 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.notesRepository.createQueryBuilder('note')
.where('note.id IN (:...noteIds)', { noteIds: noteIds })
.andWhere('(note.visibility = \'public\')')
.orderBy('note.id', 'DESC')
.innerJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
this.queryService.generateSilencedUserQueryForNotes(query, me);
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);
process.nextTick(() => {
this.activeUsersChart.read(me);
});
return await this.noteEntityService.packMany(notes, me);
});

View file

@ -206,7 +206,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('note.channel', 'channel')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('renote.user', 'renoteUser');
.leftJoinAndSelect('renote.user', 'renoteUser')
.limit(ps.limit);
if (ps.withChannelNotes) {
if (!isSelf) query.andWhere(new Brackets(qb => {
@ -232,26 +233,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (!ps.withRenotes && !ps.withQuotes) {
query.andWhere('note.renoteId IS NULL');
} else if (!ps.withRenotes) {
query.andWhere(new Brackets(qb => {
qb.orWhere('note.userId != :userId', { userId: ps.userId });
qb.orWhere('note.renoteId IS NULL');
qb.orWhere('note.text IS NOT NULL');
qb.orWhere('note.fileIds != \'{}\'');
qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
}));
this.queryService.andIsNotRenote(query, 'note');
} else if (!ps.withQuotes) {
query.andWhere(`
(
note."renoteId" IS NULL
OR (
note.text IS NULL
AND note.cw IS NULL
AND note."replyId" IS NULL
AND note."hasPoll" IS FALSE
AND note."fileIds" = '{}'
)
)
`);
this.queryService.andIsNotQuote(query, 'note');
}
if (!ps.withRepliesToOthers && !ps.withRepliesToSelf) {
@ -270,6 +254,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
query.andWhere('"user"."isBot" = false');
}
return await query.limit(ps.limit).getMany();
return await query.getMany();
}
}

View file

@ -105,7 +105,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const query = this.queryService.makePaginationQuery(this.noteReactionsRepository.createQueryBuilder('reaction'),
ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
.andWhere('reaction.userId = :userId', { userId: ps.userId })
.leftJoinAndSelect('reaction.note', 'note')
.innerJoinAndSelect('reaction.note', 'note');
.leftJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
@ -115,6 +115,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
this.queryService.generateSuspendedUserQueryForNote(query);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
const reactions = (await query
.limit(ps.limit)

View file

@ -13,6 +13,7 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { DI } from '@/di-symbols.js';
import PerUserPvChart from '@/core/chart/charts/per-user-pv.js';
import { RoleService } from '@/core/RoleService.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { ApiError } from '../../error.js';
import { ApiLoggerService } from '../../ApiLoggerService.js';
import type { FindOptionsWhere } from 'typeorm';
@ -131,7 +132,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(err)}`);
throw new ApiError(meta.errors.failedToResolveRemoteUser);
});
} else {

View file

@ -252,10 +252,10 @@ export class MastodonConverters {
return await this.convertStatus(status, me);
}
public async convertStatus(status: Entity.Status, me: MiLocalUser | null): Promise<MastodonEntity.Status> {
public async convertStatus(status: Entity.Status, me: MiLocalUser | null, hints?: { note?: MiNote, user?: MiUser }): Promise<MastodonEntity.Status> {
const convertedAccount = this.convertAccount(status.account);
const note = await this.mastodonDataService.requireNote(status.id, me);
const noteUser = await this.getUser(status.account.id);
const note = hints?.note ?? await this.mastodonDataService.requireNote(status.id, me);
const noteUser = hints?.user ?? note.user ?? await this.getUser(status.account.id);
const mentionedRemoteUsers = JSON.parse(note.mentionedRemoteUsers);
const emojis = await this.customEmojiService.populateEmojis(note.emojis, noteUser.host ? noteUser.host : this.config.host);

View file

@ -7,8 +7,8 @@ import { Inject, Injectable } from '@nestjs/common';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import { QueryService } from '@/core/QueryService.js';
import type { MiNote, NotesRepository } from '@/models/_.js';
import type { MiLocalUser } from '@/models/User.js';
import type { MiChannel, MiNote, NotesRepository } from '@/models/_.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { ApiError } from '../error.js';
/**
@ -27,8 +27,8 @@ export class MastodonDataService {
/**
* Fetches a note in the context of the current user, and throws an exception if not found.
*/
public async requireNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote> {
const note = await this.getNote(noteId, me);
public async requireNote<Rel extends NoteRelations = NoteRelations>(noteId: string, me: MiLocalUser | null | undefined, relations?: Rel): Promise<NoteWithRelations<Rel>> {
const note = await this.getNote(noteId, me, relations);
if (!note) {
throw new ApiError({
@ -46,12 +46,39 @@ export class MastodonDataService {
/**
* Fetches a note in the context of the current user.
*/
public async getNote(noteId: string, me?: MiLocalUser | null): Promise<MiNote | null> {
public async getNote<Rel extends NoteRelations = NoteRelations>(noteId: string, me: MiLocalUser | null | undefined, relations?: Rel): Promise<NoteWithRelations<Rel> | null> {
// Root query: note + required dependencies
const query = this.notesRepository
.createQueryBuilder('note')
.where('note.id = :noteId', { noteId })
.innerJoinAndSelect('note.user', 'user');
.where('note.id = :noteId', { noteId });
// Load relations
if (relations) {
if (relations.reply) {
query.leftJoinAndSelect('note.reply', 'reply');
if (typeof(relations.reply) === 'object') {
if (relations.reply.reply) query.leftJoinAndSelect('note.reply.reply', 'replyReply');
if (relations.reply.renote) query.leftJoinAndSelect('note.reply.renote', 'replyRenote');
if (relations.reply.user) query.innerJoinAndSelect('note.reply.user', 'replyUser');
if (relations.reply.channel) query.leftJoinAndSelect('note.reply.channel', 'replyChannel');
}
}
if (relations.renote) {
query.leftJoinAndSelect('note.renote', 'renote');
if (typeof(relations.renote) === 'object') {
if (relations.renote.reply) query.leftJoinAndSelect('note.renote.reply', 'renoteReply');
if (relations.renote.renote) query.leftJoinAndSelect('note.renote.renote', 'renoteRenote');
if (relations.renote.user) query.innerJoinAndSelect('note.renote.user', 'renoteUser');
if (relations.renote.channel) query.leftJoinAndSelect('note.renote.channel', 'renoteChannel');
}
}
if (relations.user) {
query.innerJoinAndSelect('note.user', 'user');
}
if (relations.channel) {
query.leftJoinAndSelect('note.channel', 'channel');
}
}
// Restrict visibility
this.queryService.generateVisibilityQuery(query, me);
@ -59,7 +86,7 @@ export class MastodonDataService {
this.queryService.generateBlockedUserQueryForNotes(query, me);
}
return await query.getOne();
return await query.getOne() as NoteWithRelations<Rel> | null;
}
/**
@ -82,3 +109,41 @@ export class MastodonDataService {
});
}
}
interface NoteRelations {
reply?: boolean | {
reply?: boolean;
renote?: boolean;
user?: boolean;
channel?: boolean;
};
renote?: boolean | {
reply?: boolean;
renote?: boolean;
user?: boolean;
channel?: boolean;
};
user?: boolean;
channel?: boolean;
}
type NoteWithRelations<Rel extends NoteRelations> = MiNote & {
reply: Rel extends { reply: false }
? null
: null | (MiNote & {
reply: Rel['reply'] extends { reply: true } ? MiNote | null : null;
renote: Rel['reply'] extends { renote: true } ? MiNote | null : null;
user: Rel['reply'] extends { user: true } ? MiUser : null;
channel: Rel['reply'] extends { channel: true } ? MiChannel | null : null;
});
renote: Rel extends { renote: false }
? null
: null | (MiNote & {
reply: Rel['renote'] extends { reply: true } ? MiNote | null : null;
renote: Rel['renote'] extends { renote: true } ? MiNote | null : null;
user: Rel['renote'] extends { user: true } ? MiUser : null;
channel: Rel['renote'] extends { channel: true } ? MiChannel | null : null;
});
user: Rel extends { user: true } ? MiUser : null;
channel: Rel extends { channel: true } ? MiChannel | null : null;
};

View file

@ -8,6 +8,10 @@ import { Injectable } from '@nestjs/common';
import { emojiRegexAtStartToEnd } from '@/misc/emoji-regex.js';
import { parseTimelineArgs, TimelineArgs, toBoolean, toInt } from '@/server/api/mastodon/argsUtils.js';
import { MastodonClientService } from '@/server/api/mastodon/MastodonClientService.js';
import { MastodonDataService } from '@/server/api/mastodon/MastodonDataService.js';
import { getNoteSummary } from '@/misc/get-note-summary.js';
import type { Packed } from '@/misc/json-schema.js';
import { isPureRenote } from '@/misc/is-renote.js';
import { convertAttachment, convertPoll, MastodonConverters } from '../MastodonConverters.js';
import type { Entity } from 'megalodon';
import type { FastifyInstance } from 'fastify';
@ -22,6 +26,7 @@ export class ApiStatusMastodon {
constructor(
private readonly mastoConverters: MastodonConverters,
private readonly clientService: MastodonClientService,
private readonly mastodonDataService: MastodonDataService,
) {}
public register(fastify: FastifyInstance): void {
@ -29,13 +34,24 @@ export class ApiStatusMastodon {
if (!_request.params.id) return reply.code(400).send({ error: 'BAD_REQUEST', error_description: 'Missing required parameter "id"' });
const { client, me } = await this.clientService.getAuthClient(_request);
const data = await client.getStatus(_request.params.id);
const response = await this.mastoConverters.convertStatus(data.data, me);
const note = await this.mastodonDataService.requireNote(_request.params.id, me, { user: true, renote: { user: true } });
// Unpack renote for Discord, otherwise the preview breaks
const appearNote = (isPureRenote(note) && _request.headers['user-agent']?.match(/\bDiscordbot\//))
? note.renote as NonNullable<typeof note.renote>
: note;
const data = await client.getStatus(appearNote.id);
const response = await this.mastoConverters.convertStatus(data.data, me, { note: appearNote, user: appearNote.user });
// Fixup - Discord ignores CWs and renders the entire post.
if (response.sensitive && _request.headers['user-agent']?.match(/\bDiscordbot\//)) {
response.content = '(preview disabled for sensitive content)';
response.content = getNoteSummary(data.data satisfies Packed<'Note'>);
response.media_attachments = [];
response.in_reply_to_id = null;
response.in_reply_to_account_id = null;
response.reblog = null;
response.quote = null;
}
return reply.send(response);
@ -170,7 +186,7 @@ export class ApiStatusMastodon {
const data = await client.deleteEmojiReaction(id, react);
return reply.send(data.data);
}
if (!body.media_ids) body.media_ids = undefined;
body.media_ids ??= undefined;
if (body.media_ids && !body.media_ids.length) body.media_ids = undefined;
if (body.poll && !body.poll.options) {

View file

@ -61,12 +61,30 @@ export default abstract class Channel {
return this.connection.subscriber;
}
/**
* Checks if a note is visible to the current user *excluding* blocks and mutes.
*/
protected isNoteVisibleToMe(note: Packed<'Note'>): boolean {
if (note.visibility === 'public') return true;
if (note.visibility === 'home') return true;
if (!this.user) return false;
if (this.user.id === note.userId) return true;
if (note.visibility === 'followers') {
return this.following[note.userId] != null;
}
if (!note.visibleUserIds) return false;
return note.visibleUserIds.includes(this.user.id);
}
/*
*
*/
protected isNoteMutedOrBlocked(note: Packed<'Note'>): boolean {
// Ignore notes that require sign-in
if (note.user.requireSigninToViewContents && !this.user) return true;
// 流れてきたNoteがインスタンスミュートしたインスタンスが関わる
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? [])) && !this.following[note.userId]) return true;
if (isInstanceMuted(note, this.userMutedInstances) && !this.following[note.userId]) return true;
// 流れてきたNoteがミュートしているユーザーが関わる
if (isUserRelated(note, this.userIdsWhoMeMuting)) return true;
@ -79,6 +97,15 @@ export default abstract class Channel {
// If it's a boost (pure renote) then we need to check the target as well
if (isPackedPureRenote(note) && note.renote && this.isNoteMutedOrBlocked(note.renote)) return true;
// Hide silenced notes
if (note.user.isSilenced || note.user.instance?.isSilenced) {
if (this.user == null) return true;
if (this.user.id === note.userId) return false;
if (this.following[note.userId] == null) return true;
}
// TODO muted threads
return false;
}

View file

@ -5,13 +5,12 @@
import { Injectable } from '@nestjs/common';
import type { Packed } from '@/misc/json-schema.js';
import { MetaService } from '@/core/MetaService.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { MiMeta } from '@/models/Meta.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { JsonObject } from '@/misc/json-value.js';
import { UtilityService } from '@/core/UtilityService.js';
import Channel, { MiChannelService } from '../channel.js';
class BubbleTimelineChannel extends Channel {
@ -21,11 +20,10 @@ class BubbleTimelineChannel extends Channel {
private withRenotes: boolean;
private withFiles: boolean;
private withBots: boolean;
private instance: MiMeta;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private readonly utilityService: UtilityService,
noteEntityService: NoteEntityService,
id: string,
@ -42,7 +40,6 @@ class BubbleTimelineChannel extends Channel {
this.withRenotes = !!(params.withRenotes ?? true);
this.withFiles = !!(params.withFiles ?? false);
this.withBots = !!(params.withBots ?? true);
this.instance = await this.metaService.fetch();
// Subscribe events
this.subscriber.on('notesStream', this.onNote);
@ -50,21 +47,37 @@ class BubbleTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
if (note.user.host == null) return;
if (!this.instance.bubbleInstances.includes(note.user.host)) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
if (!this.utilityService.isBubbledHost(note.user.host)) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
@ -85,17 +98,17 @@ export class BubbleTimelineChannelService implements MiChannelService<false> {
public readonly kind = BubbleTimelineChannel.kind;
constructor(
private metaService: MetaService,
private roleService: RoleService,
private noteEntityService: NoteEntityService,
private readonly utilityService: UtilityService,
) {
}
@bindThis
public create(id: string, connection: Channel['connection']): BubbleTimelineChannel {
return new BubbleTimelineChannel(
this.metaService,
this.roleService,
this.utilityService,
this.noteEntityService,
id,
connection,

View file

@ -48,20 +48,36 @@ class GlobalTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);

View file

@ -50,37 +50,29 @@ class HomeTimelineChannel extends Channel {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
}
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
if (this.following[note.userId]?.withReplies) {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
} else {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
}
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
if (!this.isNoteVisibleToMe(reply)) return;
}
}
if (this.isNoteMutedOrBlocked(note)) return;
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);

View file

@ -67,34 +67,26 @@ class HybridTimelineChannel extends Channel {
(note.channelId != null && this.followingChannels.has(note.channelId))
)) return;
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!isMe && !note.visibleUserIds!.includes(this.user!.id)) return;
}
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
if ((this.following[note.userId]?.withReplies ?? false) || this.withReplies) {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
} else {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies && !this.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
}
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId) && reply.userId !== this.user!.id) return;
if (!this.isNoteVisibleToMe(reply)) return;
}
}

View file

@ -50,28 +50,37 @@ class LocalTimelineChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user?.id === note.userId;
if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return;
if (!this.withBots && note.user.isBot) return;
if (note.user.host !== null) return;
if (note.visibility !== 'public') return;
if (note.channelId != null) return;
if (note.user.requireSigninToViewContents && this.user == null) return;
if (note.renote && note.renote.user.requireSigninToViewContents && this.user == null) return;
if (note.reply && note.reply.user.requireSigninToViewContents && this.user == null) return;
// 関係ない返信は除外
if (note.reply && this.user && !this.following[note.userId]?.withReplies && !this.withReplies) {
const reply = note.reply;
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user.id && note.userId !== this.user.id && reply.userId !== note.userId) return;
}
if (note.user.isSilenced && !this.following[note.userId] && note.userId !== this.user!.id) return;
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
// 関係ない返信は除外
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);

View file

@ -32,10 +32,12 @@ class MainChannel extends Channel {
switch (data.type) {
case 'notification': {
// Ignore notifications from instances the user has muted
if (isUserFromMutedInstance(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
if (isUserFromMutedInstance(data.body, this.userMutedInstances)) return;
if (data.body.userId && this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (data.body.note && data.body.note.isHidden) {
if (this.isNoteMutedOrBlocked(data.body.note)) return;
if (!this.isNoteVisibleToMe(data.body.id)) return;
const note = await this.noteEntityService.pack(data.body.note.id, this.user, {
detail: true,
});
@ -44,9 +46,7 @@ class MainChannel extends Channel {
break;
}
case 'mention': {
if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
if (this.userIdsWhoMeMuting.has(data.body.userId)) return;
if (this.isNoteMutedOrBlocked(data.body)) return;
if (data.body.isHidden) {
const note = await this.noteEntityService.pack(data.body.id, this.user, {
detail: true,

View file

@ -9,6 +9,7 @@ import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { JsonObject } from '@/misc/json-value.js';
import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js';
import Channel, { type MiChannelService } from '../channel.js';
class RoleTimelineChannel extends Channel {
@ -40,7 +41,9 @@ class RoleTimelineChannel extends Channel {
private async onEvent(data: GlobalEvents['roleTimeline']['payload']) {
if (data.type === 'note') {
const note = data.body;
const isMe = this.user?.id === note.userId;
// TODO this should be cached
if (!(await this.roleservice.isExplorable({ id: this.roleId }))) {
return;
}
@ -48,6 +51,25 @@ class RoleTimelineChannel extends Channel {
if (this.isNoteMutedOrBlocked(note)) return;
if (note.reply) {
const reply = note.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user?.id && !isMe && reply.userId !== note.userId) return;
}
}
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);

View file

@ -16,7 +16,8 @@ import Channel, { type MiChannelService } from '../channel.js';
class UserListChannel extends Channel {
public readonly chName = 'userList';
public static shouldShare = false;
public static requireCredential = false as const;
public static requireCredential = true as const;
public static kind = 'read:account';
private listId: string;
private membershipsMap: Record<string, Pick<MiUserListMembership, 'withReplies'> | undefined> = {};
private listUsersClock: NodeJS.Timeout;
@ -81,7 +82,7 @@ class UserListChannel extends Channel {
@bindThis
private async onNote(note: Packed<'Note'>) {
const isMe = this.user!.id === note.userId;
const isMe = this.user?.id === note.userId;
// チャンネル投稿は無視する
if (note.channelId) return;
@ -90,26 +91,28 @@ class UserListChannel extends Channel {
if (!Object.hasOwn(this.membershipsMap, note.userId)) return;
if (note.visibility === 'followers') {
if (!isMe && !Object.hasOwn(this.following, note.userId)) return;
} else if (note.visibility === 'specified') {
if (!note.visibleUserIds!.includes(this.user!.id)) return;
}
if (this.isNoteMutedOrBlocked(note)) return;
if (!this.isNoteVisibleToMe(note)) return;
if (note.reply) {
const reply = note.reply;
if (this.membershipsMap[note.userId]?.withReplies) {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (reply.visibility === 'followers' && !Object.hasOwn(this.following, reply.userId)) return;
} else {
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信は弾く
if (!this.isNoteVisibleToMe(reply)) return;
if (!this.following[note.userId]?.withReplies) {
// 「チャンネル接続主への返信」でもなければ、「チャンネル接続主が行った返信」でもなければ、「投稿者の投稿者自身への返信」でもない場合
if (reply.userId !== this.user!.id && !isMe && reply.userId !== note.userId) return;
}
}
if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return;
if (this.isNoteMutedOrBlocked(note)) return;
// 純粋なリノート(引用リノートでないリノート)の場合
if (isRenotePacked(note) && !isQuotePacked(note) && note.renote) {
if (!this.withRenotes) return;
if (note.renote.reply) {
const reply = note.renote.reply;
// 自分のフォローしていないユーザーの visibility: followers な投稿への返信のリノートは弾く
if (!this.isNoteVisibleToMe(reply)) return;
}
}
const clonedNote = await this.assignMyReaction(note);
await this.hideNote(clonedNote);
@ -128,7 +131,7 @@ class UserListChannel extends Channel {
}
@Injectable()
export class UserListChannelService implements MiChannelService<false> {
export class UserListChannelService implements MiChannelService<true> {
public readonly shouldShare = UserListChannel.shouldShare;
public readonly requireCredential = UserListChannel.requireCredential;
public readonly kind = UserListChannel.kind;

View file

@ -20,6 +20,7 @@ import { RedisKVCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js';
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
import type { MiAccessToken, NotesRepository } from '@/models/_.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
@ -30,14 +31,19 @@ import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-
import type { MiLocalUser } from '@/models/User.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { isRetryableError } from '@/misc/is-retryable-error.js';
import * as Acct from '@/misc/acct.js';
import { isNote } from '@/core/activitypub/type.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & {
haveNoteLocally?: boolean;
linkAttribution?: {
userId: string,
}
};
// Increment this to invalidate cached previews after a major change.
const cacheFormatVersion = 3;
const cacheFormatVersion = 4;
type PreviewRoute = {
Querystring: {
@ -82,6 +88,7 @@ export class UrlPreviewService {
private readonly utilityService: UtilityService,
private readonly apUtilityService: ApUtilityService,
private readonly apDbResolverService: ApDbResolverService,
private readonly remoteUserResolveService: RemoteUserResolveService,
private readonly apRequestService: ApRequestService,
private readonly systemAccountService: SystemAccountService,
private readonly apNoteService: ApNoteService,
@ -117,18 +124,6 @@ export class UrlPreviewService {
request: FastifyRequest<PreviewRoute>,
reply: FastifyReply,
): Promise<void> {
const url = request.query.url;
if (typeof url !== 'string' || !URL.canParse(url)) {
reply.code(400);
return;
}
const lang = request.query.lang;
if (Array.isArray(lang)) {
reply.code(400);
return;
}
if (!this.meta.urlPreviewEnabled) {
return reply.code(403).send({
error: {
@ -139,13 +134,44 @@ export class UrlPreviewService {
});
}
const url = request.query.url;
if (typeof url !== 'string' || !URL.canParse(url)) {
reply.code(400);
return;
}
// Enforce HTTP(S) for input URLs
const urlScheme = this.utilityService.getUrlScheme(url);
if (urlScheme !== 'http:' && urlScheme !== 'https:') {
reply.code(400);
return;
}
const lang = request.query.lang;
if (Array.isArray(lang)) {
reply.code(400);
return;
}
// Strip out hash (anchor)
const urlObj = new URL(url);
if (urlObj.hash) {
urlObj.hash = '';
const params = new URLSearchParams({ url: urlObj.href });
if (lang) params.set('lang', lang);
const newUrl = `/url?${params.toString()}`;
reply.redirect(newUrl, 301);
return;
}
// 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)) {
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, urlObj.host)) {
return reply.code(403).send({
error: {
message: 'URL is blocked',
@ -160,7 +186,7 @@ export class UrlPreviewService {
return;
}
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
const cacheKey = getCacheKey(url, lang);
if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
return;
}
@ -206,9 +232,23 @@ export class UrlPreviewService {
}
}
await this.validateLinkAttribution(summary);
// Await this to avoid hammering redis when a bunch of URLs are fetched at once
await this.previewCache.set(cacheKey, summary);
// Also cache the response URL in case of redirects
if (summary.url !== url) {
const responseCacheKey = getCacheKey(summary.url, lang);
await this.previewCache.set(responseCacheKey, summary);
}
// Also cache the ActivityPub URL, if different from the others
if (summary.activityPub && summary.activityPub !== summary.url) {
const apCacheKey = getCacheKey(summary.activityPub, lang);
await this.previewCache.set(apCacheKey, 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');
@ -370,7 +410,7 @@ export class UrlPreviewService {
// Finally, attempt a signed GET in case it's a direct link to an instance with authorized fetch.
const instanceActor = await this.systemAccountService.getInstanceActor();
const remoteObject = await this.apRequestService.signedGet(summary.url, instanceActor).catch(() => null);
if (remoteObject && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) {
if (remoteObject && isNote(remoteObject) && this.apUtilityService.haveSameAuthority(remoteObject.id, summary.url)) {
summary.activityPub = remoteObject.id;
return;
}
@ -426,6 +466,30 @@ export class UrlPreviewService {
}
}
private async validateLinkAttribution(summary: LocalSummalyResult) {
if (!summary.fediverseCreator) return;
if (!URL.canParse(summary.url)) return;
const url = URL.parse(summary.url);
const acct = Acct.parse(summary.fediverseCreator);
if (acct.host?.toLowerCase() === this.config.host) {
acct.host = null;
}
try {
const user = await this.remoteUserResolveService.resolveUser(acct.username, acct.host);
const attributionDomains = user.attributionDomains;
if (attributionDomains.some(x => `.${url?.host.toLowerCase()}`.endsWith(`.${x}`))) {
summary.linkAttribution = {
userId: user.id,
};
}
} catch {
this.logger.debug('User not found: ' + summary.fediverseCreator);
}
}
// Adapted from ApiCallService
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
const [user, app] = auth;
@ -501,3 +565,7 @@ export class UrlPreviewService {
return true;
}
}
function getCacheKey(url: string, lang = 'none') {
return `${url}@${lang}@${cacheFormatVersion}`;
}