Merge branch 'develop' into 'feature/dialog-announcement-cap'

# Conflicts:
#   packages/backend/src/core/AnnouncementService.ts
#   packages/backend/src/server/api/endpoints/admin/announcements/create.ts
#   packages/backend/src/server/api/endpoints/admin/announcements/update.ts
This commit is contained in:
bunnybeam 2025-09-04 16:52:49 +01:00
commit 5760c021fe
121 changed files with 2016 additions and 672 deletions

View file

@ -81,7 +81,7 @@ The Atomic Leaky Bucket algorithm is described here, in pseudocode:
# * Delta Timestamp - Difference between current and expected timestamp value
# 0 - Calculations
dripRate = ceil(limit.dripRate ?? 1000);
dripRate = ceil((limit.dripRate ?? 1000) * factor);
dripSize = ceil(limit.dripSize ?? 1);
bucketSize = max(ceil(limit.size / factor), 1);
maxExpiration = max(ceil((dripRate * ceil(bucketSize / dripSize)) / 1000), 1);;

View file

@ -206,7 +206,7 @@ export class SkRateLimiterService {
// 0 - Calculate
const now = this.timeService.now;
const bucketSize = Math.max(Math.ceil(limit.size / factor), 1);
const dripRate = Math.ceil(limit.dripRate ?? 1000);
const dripRate = Math.ceil((limit.dripRate ?? 1000) * factor);
const dripSize = Math.ceil(limit.dripSize ?? 1);
const fullResetMs = dripRate * Math.ceil(bucketSize / dripSize);
const fullResetSec = Math.max(Math.ceil(fullResetMs / 1000), 1);

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import * as argon2 from 'argon2';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta } from '@/models/_.js';
import type { RegistrationTicketsRepository, UsedUsernamesRepository, UserPendingsRepository, UserProfilesRepository, UsersRepository, MiRegistrationTicket, MiMeta, UserIpsRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import { CaptchaService } from '@/core/CaptchaService.js';
import { IdService } from '@/core/IdService.js';
@ -19,11 +19,14 @@ import { FastifyReplyError } from '@/misc/fastify-reply-error.js';
import { bindThis } from '@/decorators.js';
import { L_CHARS, secureRndstr } from '@/misc/secure-rndstr.js';
import { RoleService } from '@/core/RoleService.js';
import Logger from '@/logger.js';
import { LoggerService } from '@/core/LoggerService.js';
import { SigninService } from './SigninService.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
@Injectable()
export class SignupApiService {
private logger: Logger;
constructor(
@Inject(DI.config)
private config: Config,
@ -46,6 +49,9 @@ export class SignupApiService {
@Inject(DI.registrationTicketsRepository)
private registrationTicketsRepository: RegistrationTicketsRepository,
@Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository,
private userEntityService: UserEntityService,
private idService: IdService,
private captchaService: CaptchaService,
@ -53,7 +59,9 @@ export class SignupApiService {
private signinService: SigninService,
private emailService: EmailService,
private roleService: RoleService,
private loggerService: LoggerService,
) {
this.logger = this.loggerService.getLogger('Signup');
}
@bindThis
@ -213,6 +221,7 @@ export class SignupApiService {
username: username,
password: hash,
reason: reason,
requestOriginIp: this.meta.enableIpLogging ? request.ip : null,
});
const link = `${this.config.url}/signup-complete/${code}`;
@ -249,6 +258,10 @@ export class SignupApiService {
});
}
if (this.meta.enableIpLogging) {
this.logIp(request.ip, null, account.id);
}
const moderators = await this.roleService.getModerators();
for (const moderator of moderators) {
@ -282,6 +295,10 @@ export class SignupApiService {
});
}
if (this.meta.enableIpLogging) {
this.logIp(request.ip, null, account.id);
}
return {
...res,
token: secret,
@ -332,6 +349,15 @@ export class SignupApiService {
});
}
if (pendingUser.requestOriginIp) {
this.logIp(pendingUser.requestOriginIp, this.idService.parse(pendingUser.id).date, account.id);
}
// The sign-up request and the confirmation may've come from different addresses: log both
if (this.meta.enableIpLogging) {
this.logIp(request.ip, null, account.id);
}
if (this.meta.approvalRequiredForSignup) {
if (pendingUser.email) {
this.emailService.sendEmail(pendingUser.email, 'Approval pending',
@ -359,4 +385,17 @@ export class SignupApiService {
throw new FastifyReplyError(400, String(err), err);
}
}
@bindThis
private logIp(ip: string, ipDate: Date | null, userId: MiLocalUser['id']) {
try {
this.userIpsRepository.createQueryBuilder().insert().values({
createdAt: ipDate ?? new Date(),
userId,
ip,
}).orIgnore(true).execute();
} catch (err) {
this.logger.error(err as Error);
}
}
}

View file

@ -12,6 +12,7 @@ import { DI } from '@/di-symbols.js';
import type { UsersRepository, MiAccessToken, MiUser, NoteReactionsRepository, NotesRepository, NoteFavoritesRepository } from '@/models/_.js';
import type { Config } from '@/config.js';
import type { Keyed, RateLimit } from '@/misc/rate-limit-utils.js';
import { renderInlineError } from '@/misc/render-inline-error.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js';
@ -20,6 +21,7 @@ import { UserService } from '@/core/UserService.js';
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { getIpHash } from '@/misc/get-ip-hash.js';
import { LoggerService } from '@/core/LoggerService.js';
import type Logger from '@/logger.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import { QueryService } from '@/core/QueryService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
@ -38,6 +40,7 @@ export class StreamingApiServerService implements OnApplicationShutdown {
#connectionsByClient = new Map<string, Set<WebSocket.WebSocket>>(); // key: IP / user ID -> value: connection
#cleanConnectionsIntervalId: NodeJS.Timeout | null = null;
readonly #globalEv = new EventEmitter();
#logger: Logger;
constructor(
@Inject(DI.redisForSub)
@ -69,6 +72,7 @@ export class StreamingApiServerService implements OnApplicationShutdown {
private config: Config,
) {
this.redisForSub.on('message', this.onRedis);
this.#logger = loggerService.getLogger('streaming', 'coral');
}
@bindThis
@ -112,6 +116,7 @@ export class StreamingApiServerService implements OnApplicationShutdown {
let user: MiLocalUser | null = null;
let app: MiAccessToken | null = null;
let dieInstantly: [number, string] | null = null;
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1
// Note that the standard WHATWG WebSocket API does not support setting any headers,
@ -128,21 +133,16 @@ export class StreamingApiServerService implements OnApplicationShutdown {
}
} catch (e) {
if (e instanceof AuthenticationError) {
socket.write([
'HTTP/1.1 401 Unauthorized',
'WWW-Authenticate: Bearer realm="Misskey", error="invalid_token", error_description="Failed to authenticate"',
].join('\r\n') + '\r\n\r\n');
dieInstantly = [4000, 'Failed to authenticate'];
} else {
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
socket.destroy();
return;
}
socket.destroy();
return;
}
if (user?.isSuspended) {
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
socket.destroy();
return;
dieInstantly = [4001, 'User suspended'];
}
// ServerServices sets `trustProxy: true`, which inside fastify/request.js ends up calling `proxyAddr` in this way, so we do the same.
@ -220,8 +220,20 @@ export class StreamingApiServerService implements OnApplicationShutdown {
if (connectionsForClient.size < 1) {
this.#connectionsByClient.delete(limitActor);
}
stream.dispose();
});
ws.once('error', (e) => {
this.#logger.error(`Unhandled error in Streaming Api: ${renderInlineError(e)}`);
ws.terminate();
});
if (dieInstantly !== null) {
ws.close(...dieInstantly);
return;
}
this.#wss.emit('connection', ws, request, {
stream, user, app,
});

View file

@ -69,6 +69,7 @@ export const paramDef = {
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'], default: 'info' },
display: { type: 'string', enum: ['normal', 'banner', 'dialog'], default: 'normal' },
forExistingUsers: { type: 'boolean', default: false },
forRoles: { type: 'array', default: [], items: { type: 'string', nullable: false, format: 'misskey:id' }, },
silence: { type: 'boolean', default: false },
needConfirmationToRead: { type: 'boolean', default: false },
confetti: { type: 'boolean', default: false },
@ -93,11 +94,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
icon: ps.icon,
display: ps.display,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,
userId: ps.userId,
}, me);
return packed;
} catch (e) {
if (e instanceof IdentifiableError) {

View file

@ -57,6 +57,15 @@ export const meta = {
type: 'number',
optional: false, nullable: false,
},
forRoles: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: false,
format: 'misskey:id'
}
},
},
},
},
@ -122,6 +131,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
display: announcement.display,
isActive: announcement.isActive,
forExistingUsers: announcement.forExistingUsers,
forRoles: announcement.forRoles,
silence: announcement.silence,
needConfirmationToRead: announcement.needConfirmationToRead,
confetti: announcement.confetti,

View file

@ -42,6 +42,7 @@ export const paramDef = {
icon: { type: 'string', enum: ['info', 'warning', 'error', 'success'] },
display: { type: 'string', enum: ['normal', 'banner', 'dialog'] },
forExistingUsers: { type: 'boolean' },
forRoles: { type: 'array', default: [], items: { type: 'string', nullable: false, format: 'misskey:id' }, },
silence: { type: 'boolean' },
needConfirmationToRead: { type: 'boolean' },
confetti: { type: 'boolean' },
@ -73,6 +74,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
display: ps.display,
icon: ps.icon,
forExistingUsers: ps.forExistingUsers,
forRoles: ps.forRoles,
silence: ps.silence,
needConfirmationToRead: ps.needConfirmationToRead,
confetti: ps.confetti,

View file

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
'Your Account has been declined!');
}
await this.usedUsernamesRepository.delete({ username: user.username });
await this.usedUsernamesRepository.delete({ username: user.username.toLowerCase() });
await this.deleteAccountService.deleteAccount(user);

View file

@ -50,7 +50,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
});
for (const file of files) {
this.driveService.deleteFile(file, false, me);
this.driveService.deleteFile(file);
}
});
}

View file

@ -664,6 +664,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
shortName: instance.shortName,
uri: this.config.url,
description: instance.description,
about: instance.about,
langs: instance.langs,
tosUrl: instance.termsOfServiceUrl,
repositoryUrl: instance.repositoryUrl,

View file

@ -35,7 +35,7 @@ export const meta = {
properties: {
id: { type: 'string', format: 'misskey:id' },
createdAt: { type: 'string', format: 'date-time' },
user: { ref: 'UserDetailed' },
user: { ref: 'User' },
expiresAt: { type: 'string', format: 'date-time', nullable: true },
},
required: ['id', 'createdAt', 'user'],
@ -50,6 +50,11 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['roleId'],
} as const;
@ -90,12 +95,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.getMany();
const _users = assigns.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
createdAt: this.idService.parse(assign.id).date.toISOString(),
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }),
expiresAt: assign.expiresAt?.toISOString() ?? null,
})));
});

View file

@ -24,7 +24,7 @@ export const meta = {
items: {
type: 'object',
nullable: false, optional: false,
ref: 'UserDetailed',
ref: 'User',
},
},
} as const;
@ -44,6 +44,11 @@ export const paramDef = {
default: null,
description: 'The local host is represented with `null`.',
},
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -115,7 +120,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const users = await query.getMany();
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -67,6 +67,7 @@ export const paramDef = {
name: { type: 'string', nullable: true },
shortName: { type: 'string', nullable: true },
description: { type: 'string', nullable: true },
about: { type: 'string', nullable: true },
defaultLightTheme: { type: 'string', nullable: true },
defaultDarkTheme: { type: 'string', nullable: true },
defaultLike: { type: 'string' },
@ -340,6 +341,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
set.description = ps.description;
}
if (ps.about !== undefined) {
set.about = ps.about;
}
if (ps.defaultLightTheme !== undefined) {
set.defaultLightTheme = ps.defaultLightTheme;
}

View file

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets } from 'typeorm';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js';
import { RoleService } from '@/core/RoleService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { DI } from '@/di-symbols.js';
import type { AnnouncementsRepository } from '@/models/_.js';
@ -51,14 +52,20 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private announcementsRepository: AnnouncementsRepository,
private queryService: QueryService,
private roleService: RoleService,
private announcementEntityService: AnnouncementEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const roles = me ? await this.roleService.getUserRoles(me) : [];
const query = this.queryService.makePaginationQuery(this.announcementsRepository.createQueryBuilder('announcement'), ps.sinceId, ps.untilId)
.andWhere('announcement.isActive = :isActive', { isActive: ps.isActive })
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.userId = :meId', { meId: me.id });
qb.orWhere('announcement.userId IS NULL');
}))
.andWhere(new Brackets(qb => {
if (me) qb.orWhere('announcement.forRoles && :roles', { roles: roles.map((r) => r.id) });
qb.orWhere('announcement.forRoles = \'{}\'');
}));
const announcements = await query.limit(ps.limit).getMany();

View file

@ -11,6 +11,7 @@ import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import type { DriveFilesRepository, MiUser } from '@/models/_.js';
import type { Config } from '@/config.js';
export const meta = {
tags: ['chat'],
@ -21,9 +22,11 @@ export const meta = {
kind: 'write:chat',
// Up to 10 message burst, then 2/second
limit: {
duration: ms('1hour'),
max: 500,
type: 'bucket',
size: 10,
dripRate: 500,
},
res: {
@ -50,13 +53,19 @@ export const meta = {
code: 'CONTENT_REQUIRED',
id: '340517b7-6d04-42c0-bac1-37ee804e3594',
},
maxLength: {
message: 'You tried posting a message which is too long.',
code: 'MAX_LENGTH',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
text: { type: 'string', nullable: true, minLength: 1 },
fileId: { type: 'string', format: 'misskey:id' },
toRoomId: { type: 'string', format: 'misskey:id' },
},
@ -69,12 +78,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.config)
private config: Config,
private getterService: GetterService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
if (ps.text && ps.text.length > this.config.maxNoteLength) {
throw new ApiError(meta.errors.maxLength);
}
const room = await this.chatService.findRoomById(ps.toRoomId);
if (room == null) {
throw new ApiError(meta.errors.noSuchRoom);

View file

@ -11,6 +11,7 @@ import { DI } from '@/di-symbols.js';
import { ApiError } from '@/server/api/error.js';
import { ChatService } from '@/core/ChatService.js';
import type { DriveFilesRepository, MiUser } from '@/models/_.js';
import type { Config } from '@/config.js';
export const meta = {
tags: ['chat'],
@ -21,9 +22,11 @@ export const meta = {
kind: 'write:chat',
// Up to 10 message burst, then 2/second
limit: {
duration: ms('1hour'),
max: 500,
type: 'bucket',
size: 10,
dripRate: 500,
},
res: {
@ -62,13 +65,19 @@ export const meta = {
code: 'YOU_HAVE_BEEN_BLOCKED',
id: 'c15a5199-7422-4968-941a-2a462c478f7d',
},
maxLength: {
message: 'You tried posting a message which is too long.',
code: 'MAX_LENGTH',
id: '3ac74a84-8fd5-4bb0-870f-01804f82ce16',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
text: { type: 'string', nullable: true, maxLength: 2000 },
text: { type: 'string', nullable: true, minLength: 1 },
fileId: { type: 'string', format: 'misskey:id' },
toUserId: { type: 'string', format: 'misskey:id' },
},
@ -81,12 +90,19 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.driveFilesRepository)
private driveFilesRepository: DriveFilesRepository,
@Inject(DI.config)
private config: Config,
private getterService: GetterService,
private chatService: ChatService,
) {
super(meta, paramDef, async (ps, me) => {
await this.chatService.checkChatAvailability(me.id, 'write');
if (ps.text && ps.text.length > this.config.maxNoteLength) {
throw new ApiError(meta.errors.maxLength);
}
let file = null;
if (ps.fileId != null) {
file = await this.driveFilesRepository.findOneBy({

View file

@ -7,12 +7,22 @@ import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
import { GetterService } from '@/server/api/GetterService.js';
import { CacheService } from '@/core/CacheService.js';
import { ApiError } from '@/server/api/error.js';
export const meta = {
tags: ['federation'],
requireCredential: false,
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '558ea170-f653-4700-94d0-5a818371d0df',
},
},
// Up to 10 calls, then 4 / second.
// This allows for reliable automation.
limit: {
@ -35,9 +45,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
private getterService: GetterService,
private apPersonService: ApPersonService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps) => {
const user = await this.getterService.getRemoteUser(ps.userId);
const user = await this.cacheService.findRemoteUserById(ps.userId);
if (!user) {
throw new ApiError(meta.errors.noSuchUser);
}
await this.apPersonService.updatePerson(user.uri!);
});
}

View file

@ -23,7 +23,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -43,6 +43,11 @@ export const paramDef = {
state: { type: 'string', enum: ['all', 'alive'], default: 'all' },
origin: { type: 'string', enum: ['combined', 'local', 'remote'], default: 'local' },
trending: { type: 'boolean', default: false },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['tag', 'sort'],
} as const;
@ -96,7 +101,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.map(([u]) => u);
}
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import type { AntennasRepository, DriveFilesRepository, UsersRepository, MiAntenna as _Antenna } from '@/models/_.js';
@ -19,9 +18,11 @@ export const meta = {
requiredRolePolicy: 'canImportAntennas',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {
noSuchFile: {
@ -82,7 +83,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (currentAntennasCount + antennas.length >= (await this.roleService.getUserPolicies(me.id)).antennaLimit) {
throw new ApiError(meta.errors.tooManyAntennas);
}
this.queueService.createImportAntennasJob(me, antennas);
await this.queueService.createImportAntennasJob(me, antennas, file.id);
});
}
}

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
@ -18,9 +17,11 @@ export const meta = {
requiredRolePolicy: 'canImportBlocking',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -17,9 +17,12 @@ export const meta = {
requireCredential: true,
requiredRolePolicy: 'canImportFollowing',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
@ -18,9 +17,11 @@ export const meta = {
requiredRolePolicy: 'canImportMuting',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import type { DriveFilesRepository } from '@/models/_.js';
@ -16,9 +15,12 @@ export const meta = {
secure: true,
requireCredential: true,
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 2,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -4,7 +4,6 @@
*/
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueueService } from '@/core/QueueService.js';
import { AccountMoveService } from '@/core/AccountMoveService.js';
@ -17,9 +16,12 @@ export const meta = {
requireCredential: true,
requiredRolePolicy: 'canImportUserLists',
prohibitMoved: true,
// 1 per minute
limit: {
duration: ms('1hour'),
max: 1,
type: 'bucket',
size: 1,
dripRate: 1000 * 60,
},
errors: {

View file

@ -116,7 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// user has many notifications, the pagination will break the
// groups
// scan `notifications` newest-to-oldest
// scan `notifications` newest-to-oldest (unless we have sinceId && !untilId, in which case it's oldest-to-newest)
for (let i = 0; i < notifications.length; i++) {
const notification = notifications[i];
@ -135,7 +135,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (prevReaction.type !== 'reaction:grouped') {
prevReaction = groupedNotifications[reactionIdx] = {
type: 'reaction:grouped',
id: prevReaction.id, // this will be the newest id in this group
id: '',
createdAt: prevReaction.createdAt,
noteId: prevReaction.noteId!,
reactions: [{
@ -149,6 +149,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
userId: notification.notifierId!,
reaction: notification.reaction!,
});
prevReaction.id = notification.id; // this will be the *oldest* id in this group (newest if sinceId && !untilId)
continue;
}
@ -167,7 +168,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (prevRenote.type !== 'renote:grouped') {
prevRenote = groupedNotifications[renoteIdx] = {
type: 'renote:grouped',
id: prevRenote.id, // this will be the newest id in this group
id: '',
createdAt: prevRenote.createdAt,
noteId: prevRenote.noteId!,
userIds: [prevRenote.notifierId!],
@ -175,6 +176,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
// add this new renote to the existing group
(prevRenote as FilterUnionByProperty<MiGroupedNotification, 'type', 'renote:grouped'>).userIds.push(notification.notifierId!);
prevRenote.id = notification.id; // this will be the *oldest* id in this group (newest if sinceId && !untilId)
continue;
}
@ -182,10 +184,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
groupedNotifications.push(notification);
}
// sort the groups by their id, newest first
// sort the groups by their id
groupedNotifications.sort(
(a, b) => a.id < b.id ? 1 : a.id > b.id ? -1 : 0,
);
// this matches the logic in NotificationService and it's what MkPagination expects
if (ps.sinceId && !ps.untilId) groupedNotifications.reverse();
return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id);
});

View file

@ -615,11 +615,15 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.globalEventService.publishInternalEvent('localUserUpdated', { id: user.id });
}
const verified_links = await verifyFieldLinks(newFields, `${this.config.url}/@${user.username}`, this.httpRequestService);
const profileUrls = [
this.userEntityService.genLocalUserUri(user.id),
`${this.config.url}/@${user.username}`,
];
const verifiedLinks = await verifyFieldLinks(newFields, profileUrls, this.httpRequestService);
await this.userProfilesRepository.update(user.id, {
...profileUpdates,
verifiedLinks: verified_links,
verifiedLinks,
});
const iObj = await this.userEntityService.pack(user.id, user, {

View file

@ -27,10 +27,11 @@ export const meta = {
prohibitMoved: true,
// Up to 10 post burst, then 4/second
limit: {
duration: ms('1hour'),
max: 300,
minInterval: ms('1sec'),
type: 'bucket',
size: 10,
dripRate: 250,
},
kind: 'write:notes',

View file

@ -1,4 +1,8 @@
import ms from 'ms';
/*
* SPDX-FileCopyrightText: marie and other Sharkey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { In } from 'typeorm';
import { Inject, Injectable } from '@nestjs/common';
import type { MiUser } from '@/models/User.js';
@ -22,9 +26,11 @@ export const meta = {
prohibitMoved: true,
// Up to 10 post burst, then 2/second
limit: {
duration: ms('1hour'),
max: 300,
type: 'bucket',
size: 10,
dripRate: 500,
},
kind: 'write:notes',

View file

@ -23,7 +23,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -36,7 +36,13 @@ export const meta = {
export const paramDef = {
type: 'object',
properties: {},
properties: {
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -57,7 +63,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
host: acct.host ?? IsNull(),
})));
return await this.userEntityService.packMany(users.filter(x => x != null), me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users.filter(x => x != null), me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -37,7 +37,7 @@ export const meta = {
},
user: {
type: 'object',
ref: 'UserDetailed',
ref: 'User',
},
},
required: ['id', 'user'],
@ -58,6 +58,11 @@ export const paramDef = {
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['roleId'],
} as const;
@ -99,11 +104,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.getMany();
const _users = assigns.map(({ user, userId }) => user ?? userId);
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
return await Promise.all(assigns.map(async assign => ({
id: assign.id,
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: 'UserDetailed' }),
user: _userMap.get(assign.userId) ?? await this.userEntityService.pack(assign.user!, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }),
})));
});
}

View file

@ -24,7 +24,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -50,6 +50,11 @@ export const paramDef = {
default: null,
description: 'The local host is represented with `null`.',
},
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -111,7 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
.filter(([,p]) => p.canTrend)
.map(([u]) => u);
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}

View file

@ -30,7 +30,7 @@ export const meta = {
user: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
weight: {
type: 'number',
@ -60,6 +60,11 @@ export const paramDef = {
properties: {
userId: { type: 'string', format: 'misskey:id' },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: ['userId'],
} as const;
@ -127,10 +132,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const topRepliedUserIds = repliedUsersSorted.slice(0, ps.limit);
// Make replies object (includes weights)
const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(topRepliedUserIds, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
const repliesObj = await Promise.all(topRepliedUserIds.map(async (userId) => ({
user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: 'UserDetailed' }),
user: _userMap.get(userId) ?? await this.userEntityService.pack(userId, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' }),
weight: repliedUsers[userId] / peak,
})));

View file

@ -26,7 +26,7 @@ export const meta = {
items: {
type: 'object',
optional: false, nullable: false,
ref: 'UserDetailed',
ref: 'User',
},
},
@ -42,6 +42,11 @@ export const paramDef = {
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer', default: 0 },
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
required: [],
} as const;
@ -83,7 +88,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const users = await query.limit(ps.limit).offset(ps.offset).getMany();
return await this.userEntityService.packMany(users, me, { schema: 'UserDetailed' });
return await this.userEntityService.packMany(users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' });
});
}
}

View file

@ -9,6 +9,7 @@ import { GetterService } from '@/server/api/GetterService.js';
import { RoleService } from '@/core/RoleService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { ApiError } from '../../error.js';
import { CacheService } from '@/core/CacheService.js';
export const meta = {
tags: ['users'],
@ -60,13 +61,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private getterService: GetterService,
private roleService: RoleService,
private abuseReportService: AbuseReportService,
private readonly cacheService: CacheService,
) {
super(meta, paramDef, async (ps, me) => {
// Lookup user
const targetUser = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
const targetUser = await this.cacheService.findOptionalUserById(ps.userId);
if (!targetUser) {
throw new ApiError(meta.errors.noSuchUser);
}
if (targetUser.id === me.id) {
throw new ApiError(meta.errors.cannotReportYourself);

View file

@ -30,13 +30,13 @@ export const meta = {
oneOf: [
{
type: 'object',
ref: 'UserDetailed',
ref: 'User',
},
{
type: 'array',
items: {
type: 'object',
ref: 'UserDetailed',
ref: 'User',
},
},
],
@ -79,6 +79,11 @@ export const paramDef = {
nullable: true,
description: 'The local host is represented with `null`.',
},
detail: {
type: 'boolean',
nullable: false,
default: true,
},
},
anyOf: [
{ required: ['userId'] },
@ -125,7 +130,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (user != null) _users.push(user);
}
const _userMap = await this.userEntityService.packMany(_users, me, { schema: 'UserDetailed' })
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
.then(users => new Map(users.map(u => [u.id, u])));
return _users.map(u => _userMap.get(u.id)!);
} else {
@ -156,7 +161,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
}
return await this.userEntityService.pack(user, me, {
schema: 'UserDetailed',
schema: ps.detail ? 'UserDetailed' : 'UserLite',
});
}
});

View file

@ -59,7 +59,7 @@ export class MastodonDataService {
if (typeof(relations.reply) === 'object') {
if (relations.reply.reply) query.leftJoinAndSelect('reply.reply', 'replyReply');
if (relations.reply.renote) query.leftJoinAndSelect('reply.renote', 'replyRenote');
if (relations.reply.user) query.innerJoinAndSelect('reply.user', 'replyUser');
if (relations.reply.user) query.leftJoinAndSelect('reply.user', 'replyUser');
if (relations.reply.channel) query.leftJoinAndSelect('reply.channel', 'replyChannel');
}
}
@ -68,7 +68,7 @@ export class MastodonDataService {
if (typeof(relations.renote) === 'object') {
if (relations.renote.reply) query.leftJoinAndSelect('renote.reply', 'renoteReply');
if (relations.renote.renote) query.leftJoinAndSelect('renote.renote', 'renoteRenote');
if (relations.renote.user) query.innerJoinAndSelect('renote.user', 'renoteUser');
if (relations.renote.user) query.leftJoinAndSelect('renote.user', 'renoteUser');
if (relations.renote.channel) query.leftJoinAndSelect('renote.channel', 'renoteChannel');
}
}

View file

@ -41,7 +41,8 @@ export class ApiInstanceMastodon {
const response: MastodonEntity.Instance = {
uri: this.config.host,
title: this.meta.name || 'Sharkey',
description: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
shortDescription: this.meta.description || 'This is a vanilla Sharkey Instance. It doesn\'t seem to have a description.',
description: this.meta.about || 'This is a vanilla Sharkey Instance.',
email: instance.email || '',
version: `3.0.0 (compatible; Sharkey ${this.config.version}; like Akkoma)`,
urls: instance.urls,

View file

@ -33,6 +33,7 @@ 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 { renderInlineError } from '@/misc/render-inline-error.js';
import type { FastifyRequest, FastifyReply } from 'fastify';
export type LocalSummalyResult = SummalyResult & {
@ -260,7 +261,7 @@ export class UrlPreviewService {
return reply.code(200).send(summary);
} catch (err) {
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${renderInlineError(err)}`);
reply.header('Cache-Control', 'max-age=3600');
return reply.code(422).send({

View file

@ -129,7 +129,12 @@
const fontSize = localStorage.getItem('fontSize');
if (fontSize) {
document.documentElement.classList.add('f-' + fontSize);
if (fontSize === "custom") {
const customFontSize = localStorage.getItem('customFontSize');
document.documentElement.style.setProperty('font-size', `${customFontSize}px`);
} else {
document.documentElement.classList.add('f-' + fontSize);
}
}
const cornerRadius = localStorage.getItem('cornerRadius');

View file

@ -21,6 +21,7 @@ block og
meta(property='og:url' content= url)
if videos.length
each video in videos
meta(property='og:video' content= video.url)
meta(property='og:video:url' content= video.url)
meta(property='og:video:secure_url' content= video.url)
meta(property='og:video:type' content= video.type)