convert Authorized Fetch to a setting and add support for hybrid mode (essential metadata only)
This commit is contained in:
parent
e3d949ced6
commit
a35c2f214b
28 changed files with 517 additions and 103 deletions
|
|
@ -200,6 +200,7 @@ export type Config = {
|
|||
customMOTD: string[] | undefined;
|
||||
signToActivityPubGet: boolean;
|
||||
attachLdSignatureForRelays: boolean;
|
||||
/** @deprecated Use MiMeta.allowUnsignedFetch instead */
|
||||
checkActivityPubGetSignature: boolean | undefined;
|
||||
logging?: {
|
||||
sql?: {
|
||||
|
|
|
|||
|
|
@ -70,3 +70,9 @@ https://github.com/sindresorhus/file-type/blob/main/supported.js
|
|||
https://github.com/sindresorhus/file-type/blob/main/core.js
|
||||
https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Containers
|
||||
*/
|
||||
|
||||
export const instanceUnsignedFetchOptions = ['never', 'always', 'essential'] as const;
|
||||
export type InstanceUnsignedFetchOption = (typeof instanceUnsignedFetchOptions)[number];
|
||||
|
||||
export const userUnsignedFetchOptions = ['never', 'always', 'essential', 'staff'] as const;
|
||||
export type UserUnsignedFetchOption = (typeof userUnsignedFetchOptions)[number];
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
|
|
@ -179,6 +180,13 @@ export class CacheService implements OnApplicationShutdown {
|
|||
return this.userByIdCache.fetch(userId, () => this.usersRepository.findOneByOrFail({ id: userId }));
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async findLocalUserById(userId: MiUser['id']): Promise<MiLocalUser | null> {
|
||||
return await this.localUserByIdCache.fetchMaybe(userId, async () => {
|
||||
return await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null ?? undefined;
|
||||
}) ?? null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public dispose(): void {
|
||||
this.redisForSub.off('message', this.onMessage);
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export class CreateSystemUserService {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async createSystemUser(username: string): Promise<MiUser> {
|
||||
public async createSystemUser(username: string, data?: Partial<MiUser>): Promise<MiUser> {
|
||||
const password = randomUUID();
|
||||
|
||||
// Generate hash of password
|
||||
|
|
@ -63,6 +63,7 @@ export class CreateSystemUserService {
|
|||
isExplorable: false,
|
||||
approved: true,
|
||||
isBot: true,
|
||||
...(data ?? {}),
|
||||
}).then(x => transactionalEntityManager.findOneByOrFail(MiUser, x.identifiers[0]));
|
||||
|
||||
await transactionalEntityManager.insert(MiUserKeypair, {
|
||||
|
|
|
|||
|
|
@ -49,7 +49,15 @@ export class InstanceActorService {
|
|||
this.cache.set(user);
|
||||
return user;
|
||||
} else {
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME) as MiLocalUser;
|
||||
const created = await this.createSystemUserService.createSystemUser(ACTOR_USERNAME, {
|
||||
/* we always allow requests about our instance actor, because when
|
||||
a remote instance needs to check our signature on a request we
|
||||
sent, it will need to fetch information about the user that
|
||||
signed it (which is our instance actor), and if we try to check
|
||||
their signature on *that* request, we'll fetch *their* instance
|
||||
actor... leading to an infinite recursion */
|
||||
allowUnsignedFetch: 'always',
|
||||
}) as MiLocalUser;
|
||||
this.cache.set(created);
|
||||
return created;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -571,6 +571,38 @@ export class ApRendererService {
|
|||
return person;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async renderPersonRedacted(user: MiLocalUser) {
|
||||
const id = this.userEntityService.genLocalUserUri(user.id);
|
||||
const isSystem = user.username.includes('.');
|
||||
|
||||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
return {
|
||||
// Basic federation metadata
|
||||
type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
|
||||
id,
|
||||
inbox: `${id}/inbox`,
|
||||
outbox: `${id}/outbox`,
|
||||
sharedInbox: `${this.config.url}/inbox`,
|
||||
endpoints: { sharedInbox: `${this.config.url}/inbox` },
|
||||
url: `${this.config.url}/@${user.username}`,
|
||||
preferredUsername: user.username,
|
||||
publicKey: this.renderKey(user, keypair, '#main-key'),
|
||||
|
||||
// Privacy settings
|
||||
_misskey_requireSigninToViewContents: user.requireSigninToViewContents,
|
||||
_misskey_makeNotesFollowersOnlyBefore: user.makeNotesFollowersOnlyBefore,
|
||||
_misskey_makeNotesHiddenBefore: user.makeNotesHiddenBefore,
|
||||
manuallyApprovesFollowers: user.isLocked,
|
||||
discoverable: user.isExplorable,
|
||||
hideOnlineStatus: user.hideOnlineStatus,
|
||||
noindex: user.noindex,
|
||||
indexable: !user.noindex,
|
||||
enableRss: user.enableRss,
|
||||
};
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -181,6 +181,7 @@ export class MetaEntityService {
|
|||
serviceWorker: instance.enableServiceWorker,
|
||||
miauth: true,
|
||||
},
|
||||
allowUnsignedFetch: instance.allowUnsignedFetch,
|
||||
};
|
||||
|
||||
return packDetailed;
|
||||
|
|
|
|||
|
|
@ -725,6 +725,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
policies: this.roleService.getUserPolicies(user.id),
|
||||
defaultCW: profile!.defaultCW,
|
||||
defaultCWPriority: profile!.defaultCWPriority,
|
||||
allowUnsignedFetch: user.allowUnsignedFetch,
|
||||
} : {}),
|
||||
|
||||
...(opts.includeSecrets ? {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { type InstanceUnsignedFetchOption, instanceUnsignedFetchOptions } from '@/const.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiUser } from './User.js';
|
||||
|
||||
|
|
@ -749,4 +750,14 @@ export class MiMeta {
|
|||
default: '{}',
|
||||
})
|
||||
public federationHosts: string[];
|
||||
|
||||
/**
|
||||
* In combination with user.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests).
|
||||
* TODO warning if config value is present
|
||||
*/
|
||||
@Column('enum', {
|
||||
enum: instanceUnsignedFetchOptions,
|
||||
default: 'always',
|
||||
})
|
||||
public allowUnsignedFetch: InstanceUnsignedFetchOption;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
*/
|
||||
|
||||
import { Entity, Column, Index, OneToOne, JoinColumn, PrimaryColumn } from 'typeorm';
|
||||
import { type UserUnsignedFetchOption, userUnsignedFetchOptions } from '@/const.js';
|
||||
import { id } from './util/id.js';
|
||||
import { MiDriveFile } from './DriveFile.js';
|
||||
|
||||
|
|
@ -125,7 +126,7 @@ export class MiUser {
|
|||
})
|
||||
public backgroundId: MiDriveFile['id'] | null;
|
||||
|
||||
@OneToOne(type => MiDriveFile, {
|
||||
@OneToOne(() => MiDriveFile, {
|
||||
onDelete: 'SET NULL',
|
||||
})
|
||||
@JoinColumn()
|
||||
|
|
@ -357,6 +358,15 @@ export class MiUser {
|
|||
})
|
||||
public rejectQuotes: boolean;
|
||||
|
||||
/**
|
||||
* In combination with meta.allowUnsignedFetch, controls enforcement of HTTP signatures for inbound ActivityPub fetches (GET requests).
|
||||
*/
|
||||
@Column('enum', {
|
||||
enum: userUnsignedFetchOptions,
|
||||
default: 'staff',
|
||||
})
|
||||
public allowUnsignedFetch: UserUnsignedFetchOption;
|
||||
|
||||
constructor(data: Partial<MiUser>) {
|
||||
if (data == null) return;
|
||||
|
||||
|
|
@ -394,5 +404,5 @@ export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as con
|
|||
export const descriptionSchema = { type: 'string', minLength: 1, maxLength: 1500 } as const;
|
||||
export const followedMessageSchema = { type: 'string', minLength: 1, maxLength: 256 } as const;
|
||||
export const locationSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;
|
||||
export const listenbrainzSchema = { type: "string", minLength: 1, maxLength: 128 } as const;
|
||||
export const listenbrainzSchema = { type: 'string', minLength: 1, maxLength: 128 } as const;
|
||||
export const birthdaySchema = { type: 'string', pattern: /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.toString().slice(1, -1) } as const;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { instanceUnsignedFetchOptions } from '@/const.js';
|
||||
|
||||
export const packedMetaLiteSchema = {
|
||||
type: 'object',
|
||||
optional: false, nullable: false,
|
||||
|
|
@ -397,6 +399,11 @@ export const packedMetaDetailedOnlySchema = {
|
|||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: instanceUnsignedFetchOptions,
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { userUnsignedFetchOptions } from '@/const.js';
|
||||
|
||||
export const notificationRecieveConfig = {
|
||||
type: 'object',
|
||||
oneOf: [
|
||||
|
|
@ -769,6 +771,11 @@ export const packedMeDetailedOnlySchema = {
|
|||
enum: ['default', 'parent', 'defaultParent', 'parentDefault'],
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: userUnsignedFetchOptions,
|
||||
nullable: false, optional: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import accepts from 'accepts';
|
|||
import vary from 'vary';
|
||||
import secureJson from 'secure-json-parse';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository } from '@/models/_.js';
|
||||
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta } from '@/models/_.js';
|
||||
import * as url from '@/misc/prelude/url.js';
|
||||
import type { Config } from '@/config.js';
|
||||
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
|
||||
|
|
@ -22,7 +22,6 @@ import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
|||
import { QueueService } from '@/core/QueueService.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
import { UserKeypairService } from '@/core/UserKeypairService.js';
|
||||
import { InstanceActorService } from '@/core/InstanceActorService.js';
|
||||
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||
import type { MiFollowing } from '@/models/Following.js';
|
||||
import { countIf } from '@/misc/prelude/array.js';
|
||||
|
|
@ -33,9 +32,10 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import type Logger from '@/logger.js';
|
||||
import { LoggerService } from '@/core/LoggerService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { IActivity } from '@/core/activitypub/type.js';
|
||||
import { IActivity, IAnnounce, ICreate } from '@/core/activitypub/type.js';
|
||||
import { isQuote, isRenote } from '@/misc/is-renote.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions, FastifyBodyParser } from 'fastify';
|
||||
import type { FindOptionsWhere } from 'typeorm';
|
||||
|
||||
|
|
@ -51,6 +51,9 @@ export class ActivityPubServerService {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.meta)
|
||||
private meta: MiMeta,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
|
@ -77,13 +80,13 @@ export class ActivityPubServerService {
|
|||
|
||||
private utilityService: UtilityService,
|
||||
private userEntityService: UserEntityService,
|
||||
private instanceActorService: InstanceActorService,
|
||||
private apRendererService: ApRendererService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private queueService: QueueService,
|
||||
private userKeypairService: UserKeypairService,
|
||||
private queryService: QueryService,
|
||||
private loggerService: LoggerService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
//this.createServer = this.createServer.bind(this);
|
||||
this.logger = this.loggerService.getLogger('apserv', 'pink');
|
||||
|
|
@ -106,7 +109,7 @@ export class ActivityPubServerService {
|
|||
* @param author Author of the note
|
||||
*/
|
||||
@bindThis
|
||||
private async packActivity(note: MiNote, author: MiUser): Promise<any> {
|
||||
private async packActivity(note: MiNote, author: MiUser): Promise<ICreate | IAnnounce> {
|
||||
if (isRenote(note) && !isQuote(note)) {
|
||||
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId });
|
||||
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note);
|
||||
|
|
@ -115,10 +118,55 @@ export class ActivityPubServerService {
|
|||
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async shouldRefuseGetRequest(request: FastifyRequest, reply: FastifyReply, userId: string | undefined = undefined): Promise<boolean> {
|
||||
if (!this.config.checkActivityPubGetSignature) return false;
|
||||
/**
|
||||
* Checks Authorized Fetch.
|
||||
* Returns an object with two properties:
|
||||
* * reject - true if the request should be ignored by the caller, false if it should be processed.
|
||||
* * redact - true if the caller should redact response data, false if it should return full data.
|
||||
* When "reject" is true, the HTTP status code will be automatically set to 401 unauthorized.
|
||||
*/
|
||||
private async checkAuthorizedFetch(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
userId?: string,
|
||||
essential?: boolean,
|
||||
): Promise<{ reject: boolean, redact: boolean }> {
|
||||
// Federation disabled => reject
|
||||
if (this.meta.federation === 'none') {
|
||||
reply.code(401);
|
||||
return { reject: true, redact: true };
|
||||
}
|
||||
|
||||
// Auth fetch disabled => accept
|
||||
const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId);
|
||||
if (allowUnsignedFetch === 'always') {
|
||||
return { reject: false, redact: false };
|
||||
}
|
||||
|
||||
// Valid signature => accept
|
||||
const error = await this.checkSignature(request);
|
||||
if (!error) {
|
||||
return { reject: false, redact: false };
|
||||
}
|
||||
|
||||
// Unsigned, but essential => accept redacted
|
||||
if (allowUnsignedFetch === 'essential' && essential) {
|
||||
return { reject: false, redact: true };
|
||||
}
|
||||
|
||||
// Unsigned, not essential => reject
|
||||
this.authlogger.warn(error);
|
||||
reply.code(401);
|
||||
return { reject: true, redact: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies HTTP Signatures for a request.
|
||||
* Returns null of success (valid signature).
|
||||
* Returns a string error on validation failure.
|
||||
*/
|
||||
@bindThis
|
||||
private async checkSignature(request: FastifyRequest): Promise<string | null> {
|
||||
/* this code is inspired from the `inbox` function below, and
|
||||
`queue/processors/InboxProcessorService`
|
||||
|
||||
|
|
@ -129,59 +177,33 @@ export class ActivityPubServerService {
|
|||
this is also inspired by FireFish's `checkFetch`
|
||||
*/
|
||||
|
||||
/* tell any caching proxy that they should not cache these
|
||||
responses: we wouldn't want the proxy to return a 403 to
|
||||
someone presenting a valid signature, or return a cached
|
||||
response body to someone we've blocked!
|
||||
*/
|
||||
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
|
||||
/* we always allow requests about our instance actor, because when
|
||||
a remote instance needs to check our signature on a request we
|
||||
sent, it will need to fetch information about the user that
|
||||
signed it (which is our instance actor), and if we try to check
|
||||
their signature on *that* request, we'll fetch *their* instance
|
||||
actor... leading to an infinite recursion */
|
||||
if (userId) {
|
||||
const instanceActor = await this.instanceActorService.getInstanceActor();
|
||||
|
||||
if (userId === instanceActor.id || userId === instanceActor.username) {
|
||||
this.authlogger.debug(`${request.id} ${request.url} request to instance.actor, letting through`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let signature;
|
||||
|
||||
try {
|
||||
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||
signature = httpSignature.parseRequest(request.raw, {
|
||||
headers: ['(request-target)', 'host', 'date'],
|
||||
authorizationHeaderName: 'signature',
|
||||
});
|
||||
} catch (e) {
|
||||
// not signed, or malformed signature: refuse
|
||||
this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`);
|
||||
reply.code(401);
|
||||
return true;
|
||||
return `${request.id} ${request.url} not signed, or malformed signature: refuse`;
|
||||
}
|
||||
|
||||
const keyId = new URL(signature.keyId);
|
||||
const keyHost = this.utilityService.toPuny(keyId.hostname);
|
||||
|
||||
const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) apparently from ${keyHost}:`;
|
||||
const logPrefix = `${request.id} ${request.url} (by ${request.headers['user-agent']}) claims to be from ${keyHost}:`;
|
||||
|
||||
if (signature.params.headers.indexOf('host') === -1
|
||||
|| request.headers.host !== this.config.host) {
|
||||
if (signature.params.headers.indexOf('host') === -1 || request.headers.host !== this.config.host) {
|
||||
// no destination host, or not us: refuse
|
||||
this.authlogger.warn(`${logPrefix} no destination host, or not us: refuse`);
|
||||
reply.code(401);
|
||||
return true;
|
||||
return `${logPrefix} no destination host, or not us: refuse`;
|
||||
}
|
||||
|
||||
if (!this.utilityService.isFederationAllowedHost(keyHost)) {
|
||||
/* blocked instance: refuse (we don't care if the signature is
|
||||
good, if they even pretend to be from a blocked instance,
|
||||
they're out) */
|
||||
this.authlogger.warn(`${logPrefix} instance is blocked: refuse`);
|
||||
reply.code(401);
|
||||
return true;
|
||||
return `${logPrefix} instance is blocked: refuse`;
|
||||
}
|
||||
|
||||
// do we know the signer already?
|
||||
|
|
@ -200,14 +222,18 @@ export class ActivityPubServerService {
|
|||
|
||||
if (authUser?.key == null) {
|
||||
// we can't figure out who the signer is, or we can't get their key: refuse
|
||||
this.authlogger.warn(`${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`);
|
||||
reply.code(401);
|
||||
return true;
|
||||
return `${logPrefix} we can't figure out who the signer is, or we can't get their key: refuse`;
|
||||
}
|
||||
|
||||
if (authUser.user.isSuspended) {
|
||||
// Signer is suspended locally
|
||||
return `${logPrefix} signer is suspended: refuse`;
|
||||
}
|
||||
|
||||
let httpSignatureValidated = httpSignature.verifySignature(signature, authUser.key.keyPem);
|
||||
|
||||
// maybe they changed their key? refetch it
|
||||
// TODO rate-limit this using lastFetchedAt
|
||||
if (!httpSignatureValidated) {
|
||||
authUser.key = await this.apDbResolverService.refetchPublicKeyForApId(authUser.user);
|
||||
if (authUser.key != null) {
|
||||
|
|
@ -217,13 +243,11 @@ export class ActivityPubServerService {
|
|||
|
||||
if (!httpSignatureValidated) {
|
||||
// bad signature: refuse
|
||||
this.authlogger.info(`${logPrefix} failed to validate signature: refuse`);
|
||||
reply.code(401);
|
||||
return true;
|
||||
return `${logPrefix} failed to validate signature: refuse`;
|
||||
}
|
||||
|
||||
// all good, don't refuse
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -299,7 +323,8 @@ export class ActivityPubServerService {
|
|||
request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
|
||||
if (reject) return;
|
||||
|
||||
const userId = request.params.user;
|
||||
|
||||
|
|
@ -326,11 +351,9 @@ export class ActivityPubServerService {
|
|||
|
||||
if (profile.followersVisibility === 'private') {
|
||||
reply.code(403);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
|
||||
return;
|
||||
} else if (profile.followersVisibility === 'followers') {
|
||||
reply.code(403);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
|
@ -382,7 +405,6 @@ export class ActivityPubServerService {
|
|||
user.followersCount,
|
||||
`${partOf}?page=true`,
|
||||
);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(rendered));
|
||||
}
|
||||
|
|
@ -393,7 +415,8 @@ export class ActivityPubServerService {
|
|||
request: FastifyRequest<{ Params: { user: string; }; Querystring: { cursor?: string; page?: string; }; }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
|
||||
if (reject) return;
|
||||
|
||||
const userId = request.params.user;
|
||||
|
||||
|
|
@ -420,11 +443,9 @@ export class ActivityPubServerService {
|
|||
|
||||
if (profile.followingVisibility === 'private') {
|
||||
reply.code(403);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
|
||||
return;
|
||||
} else if (profile.followingVisibility === 'followers') {
|
||||
reply.code(403);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=30');
|
||||
return;
|
||||
}
|
||||
//#endregion
|
||||
|
|
@ -476,7 +497,6 @@ export class ActivityPubServerService {
|
|||
user.followingCount,
|
||||
`${partOf}?page=true`,
|
||||
);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(rendered));
|
||||
}
|
||||
|
|
@ -484,7 +504,8 @@ export class ActivityPubServerService {
|
|||
|
||||
@bindThis
|
||||
private async featured(request: FastifyRequest<{ Params: { user: string; }; }>, reply: FastifyReply) {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
|
||||
if (reject) return;
|
||||
|
||||
const userId = request.params.user;
|
||||
|
||||
|
|
@ -517,7 +538,6 @@ export class ActivityPubServerService {
|
|||
renderedNotes,
|
||||
);
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(rendered));
|
||||
}
|
||||
|
|
@ -530,7 +550,8 @@ export class ActivityPubServerService {
|
|||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user);
|
||||
if (reject) return;
|
||||
|
||||
const userId = request.params.user;
|
||||
|
||||
|
|
@ -608,14 +629,13 @@ export class ActivityPubServerService {
|
|||
`${partOf}?page=true`,
|
||||
`${partOf}?page=true&since_id=000000000000000000000000`,
|
||||
);
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(rendered));
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null) {
|
||||
private async userInfo(request: FastifyRequest, reply: FastifyReply, user: MiUser | null, redact = false) {
|
||||
if (user == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
|
|
@ -631,10 +651,12 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(await this.apRendererService.renderPerson(user as MiLocalUser)));
|
||||
|
||||
const person = redact
|
||||
? await this.apRendererService.renderPersonRedacted(user as MiLocalUser)
|
||||
: await this.apRendererService.renderPerson(user as MiLocalUser);
|
||||
return this.apRendererService.addContext(person);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
@ -687,6 +709,13 @@ export class ActivityPubServerService {
|
|||
reply.header('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
reply.header('Access-Control-Allow-Origin', '*');
|
||||
reply.header('Access-Control-Expose-Headers', 'Vary');
|
||||
|
||||
/* tell any caching proxy that they should not cache these
|
||||
responses: we wouldn't want the proxy to return a 403 to
|
||||
someone presenting a valid signature, or return a cached
|
||||
response body to someone we've blocked!
|
||||
*/
|
||||
reply.header('Cache-Control', 'private, max-age=0, must-revalidate');
|
||||
done();
|
||||
});
|
||||
|
||||
|
|
@ -697,8 +726,6 @@ export class ActivityPubServerService {
|
|||
|
||||
// note
|
||||
fastify.get<{ Params: { note: string; } }>('/notes/:note', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply)) return;
|
||||
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
|
|
@ -707,6 +734,9 @@ export class ActivityPubServerService {
|
|||
localOnly: false,
|
||||
});
|
||||
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId);
|
||||
if (reject) return;
|
||||
|
||||
if (note == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
|
|
@ -722,7 +752,6 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
|
||||
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
||||
|
|
@ -731,8 +760,6 @@ export class ActivityPubServerService {
|
|||
|
||||
// note activity
|
||||
fastify.get<{ Params: { note: string; } }>('/notes/:note/activity', async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply)) return;
|
||||
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
const note = await this.notesRepository.findOneBy({
|
||||
|
|
@ -742,12 +769,14 @@ export class ActivityPubServerService {
|
|||
localOnly: false,
|
||||
});
|
||||
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, note?.userId);
|
||||
if (reject) return;
|
||||
|
||||
if (note == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
|
||||
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
|
||||
|
|
@ -777,7 +806,8 @@ export class ActivityPubServerService {
|
|||
|
||||
// publickey
|
||||
fastify.get<{ Params: { user: string; } }>('/users/:user/publickey', async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.user, true);
|
||||
if (reject) return;
|
||||
|
||||
const userId = request.params.user;
|
||||
|
||||
|
|
@ -794,7 +824,6 @@ export class ActivityPubServerService {
|
|||
const keypair = await this.userKeypairService.getUserKeypair(user.id);
|
||||
|
||||
if (this.userEntityService.isLocalUser(user)) {
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(this.apRendererService.renderKey(user, keypair)));
|
||||
} else {
|
||||
|
|
@ -804,7 +833,8 @@ export class ActivityPubServerService {
|
|||
});
|
||||
|
||||
fastify.get<{ Params: { user: string; } }>('/users/:user', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.user)) return;
|
||||
const { reject, redact } = await this.checkAuthorizedFetch(request, reply, request.params.user, true);
|
||||
if (reject) return;
|
||||
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
|
|
@ -815,12 +845,10 @@ export class ActivityPubServerService {
|
|||
isSuspended: false,
|
||||
});
|
||||
|
||||
return await this.userInfo(request, reply, user);
|
||||
return await this.userInfo(request, reply, user, redact);
|
||||
});
|
||||
|
||||
fastify.get<{ Params: { acct: string; } }>('/@:acct', { constraints: { apOrHtml: 'ap' } }, async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply, request.params.acct)) return;
|
||||
|
||||
vary(reply.raw, 'Accept');
|
||||
|
||||
const acct = Acct.parse(request.params.acct);
|
||||
|
|
@ -831,13 +859,17 @@ export class ActivityPubServerService {
|
|||
isSuspended: false,
|
||||
});
|
||||
|
||||
return await this.userInfo(request, reply, user);
|
||||
const { reject, redact } = await this.checkAuthorizedFetch(request, reply, user?.id, true);
|
||||
if (reject) return;
|
||||
|
||||
return await this.userInfo(request, reply, user, redact);
|
||||
});
|
||||
//#endregion
|
||||
|
||||
// emoji
|
||||
fastify.get<{ Params: { emoji: string; } }>('/emojis/:emoji', async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply);
|
||||
if (reject) return;
|
||||
|
||||
const emoji = await this.emojisRepository.findOneBy({
|
||||
host: IsNull(),
|
||||
|
|
@ -849,17 +881,17 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji)));
|
||||
});
|
||||
|
||||
// like
|
||||
fastify.get<{ Params: { like: string; } }>('/likes/:like', async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply)) return;
|
||||
|
||||
const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like });
|
||||
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId);
|
||||
if (reject) return;
|
||||
|
||||
if (reaction == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
|
|
@ -872,14 +904,14 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, note)));
|
||||
});
|
||||
|
||||
// follow
|
||||
fastify.get<{ Params: { follower: string; followee: string; } }>('/follows/:follower/:followee', async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply)) return;
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, request.params.follower);
|
||||
if (reject) return;
|
||||
|
||||
// This may be used before the follow is completed, so we do not
|
||||
// check if the following exists.
|
||||
|
|
@ -900,15 +932,12 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
|
||||
});
|
||||
|
||||
// follow
|
||||
fastify.get<{ Params: { followRequestId: string ; } }>('/follows/:followRequestId', async (request, reply) => {
|
||||
if (await this.shouldRefuseGetRequest(request, reply)) return;
|
||||
|
||||
// This may be used before the follow is completed, so we do not
|
||||
// check if the following exists and only check if the follow request exists.
|
||||
|
||||
|
|
@ -916,6 +945,9 @@ export class ActivityPubServerService {
|
|||
id: request.params.followRequestId,
|
||||
});
|
||||
|
||||
const { reject } = await this.checkAuthorizedFetch(request, reply, followRequest?.followerId);
|
||||
if (reject) return;
|
||||
|
||||
if (followRequest == null) {
|
||||
reply.code(404);
|
||||
return;
|
||||
|
|
@ -937,11 +969,21 @@ export class ActivityPubServerService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (!this.config.checkActivityPubGetSignature) reply.header('Cache-Control', 'public, max-age=180');
|
||||
this.setResponseType(request, reply);
|
||||
return (this.apRendererService.addContext(this.apRendererService.renderFollow(follower, followee)));
|
||||
});
|
||||
|
||||
done();
|
||||
}
|
||||
|
||||
private async getUnsignedFetchAllowance(userId: string | undefined) {
|
||||
const user = userId ? await this.cacheService.findLocalUserById(userId) : null;
|
||||
|
||||
// User system value if there is no user, or if user has deferred the choice.
|
||||
if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') {
|
||||
return this.meta.allowUnsignedFetch;
|
||||
}
|
||||
|
||||
return user.allowUnsignedFetch;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { MetaService } from '@/core/MetaService.js';
|
|||
import type { Config } from '@/config.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
|
||||
import { instanceUnsignedFetchOptions } from '@/const.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['meta'],
|
||||
|
|
@ -589,6 +590,15 @@ export const meta = {
|
|||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
hasLegacyAuthFetchSetting: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: instanceUnsignedFetchOptions,
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -745,6 +755,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
trustedLinkUrlPatterns: instance.trustedLinkUrlPatterns,
|
||||
federation: instance.federation,
|
||||
federationHosts: instance.federationHosts,
|
||||
hasLegacyAuthFetchSetting: config.checkActivityPubGetSignature != null,
|
||||
allowUnsignedFetch: instance.allowUnsignedFetch,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type { MiMeta } from '@/models/Meta.js';
|
|||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { Endpoint } from '@/server/api/endpoint-base.js';
|
||||
import { MetaService } from '@/core/MetaService.js';
|
||||
import { instanceUnsignedFetchOptions } from '@/const.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['admin'],
|
||||
|
|
@ -205,6 +206,11 @@ export const paramDef = {
|
|||
type: 'string',
|
||||
},
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: instanceUnsignedFetchOptions,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
} as const;
|
||||
|
|
@ -753,6 +759,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
set.federationHosts = ps.federationHosts.filter(Boolean).map(x => x.toLowerCase());
|
||||
}
|
||||
|
||||
if (ps.allowUnsignedFetch !== undefined) {
|
||||
set.allowUnsignedFetch = ps.allowUnsignedFetch;
|
||||
}
|
||||
|
||||
const before = await this.metaService.fetch(true);
|
||||
|
||||
await this.metaService.update(set);
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import type { Config } from '@/config.js';
|
|||
import { safeForSql } from '@/misc/safe-for-sql.js';
|
||||
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
|
||||
import { notificationRecieveConfig } from '@/models/json-schema/user.js';
|
||||
import { userUnsignedFetchOptions } from '@/const.js';
|
||||
import { ApiLoggerService } from '../../ApiLoggerService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
|
|
@ -255,6 +256,11 @@ export const paramDef = {
|
|||
enum: ['default', 'parent', 'defaultParent', 'parentDefault'],
|
||||
nullable: false,
|
||||
},
|
||||
allowUnsignedFetch: {
|
||||
type: 'string',
|
||||
enum: userUnsignedFetchOptions,
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
|
@ -519,6 +525,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
profileUpdates.defaultCWPriority = ps.defaultCWPriority;
|
||||
}
|
||||
|
||||
if (ps.allowUnsignedFetch !== undefined) {
|
||||
updates.allowUnsignedFetch = ps.allowUnsignedFetch;
|
||||
}
|
||||
|
||||
//#region emojis/tags
|
||||
|
||||
let emojis = [] as string[];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue