Compare commits

...
Sign in to create a new pull request.

25 commits

Author SHA1 Message Date
Hazelnoot
89c38a54f3 check tenants before showing "remote user" warning 2025-05-07 12:45:28 -04:00
Hazelnoot
0c2e4f139b support tenants in users/show, i/move, and i/update 2025-05-07 12:45:27 -04:00
Hazelnoot
28b04a84aa support tenants in renderPersonRedacted 2025-05-07 12:45:27 -04:00
Hazelnoot
51c55c2431 simplify URI logic in ApRendererService.ts 2025-05-07 12:45:27 -04:00
Hazelnoot
af9d5cea33 reflect tenant in WellKnownServerService.ts 2025-05-07 12:45:25 -04:00
Hazelnoot
f45cbb56bb return tenant URL in api/meta 2025-05-07 12:45:02 -04:00
Hazelnoot
230556c17a expose request hostname to API endpoints 2025-05-07 12:44:58 -04:00
Hazelnoot
fad9a54912 add utility to get self tenant 2025-05-07 12:43:01 -04:00
Hazelnoot
cd4caffc83 rename alt utilities to avoid ambiguity 2025-05-07 12:43:01 -04:00
Hazelnoot
5cf9753edd check alts in ClientServerService.ts 2025-05-07 12:43:01 -04:00
Hazelnoot
74e3a52cc5 move tenant utility functions to UtilityService 2025-05-07 12:43:01 -04:00
Hazelnoot
234282c804 don't serve remote Like objects 2025-05-07 12:43:01 -04:00
Hazelnoot
d1e7af596b check alts in additional note fetch 2025-05-07 12:43:01 -04:00
Hazelnoot
4850a2450e check alts in follow requests 2025-05-07 12:43:01 -04:00
Hazelnoot
62b42426dd check alts in emoji fetch 2025-05-07 12:43:01 -04:00
Hazelnoot
fcfa36c4f1 check alts in user lookup 2025-05-07 12:43:01 -04:00
Hazelnoot
4e73fdecbf check alts in notes and replies collection 2025-05-07 12:43:01 -04:00
Hazelnoot
84062c4aa3 check alts in following, followers, featured, and outbox collections 2025-05-07 12:43:01 -04:00
Hazelnoot
bb84c3fb80 check alts in inbox 2025-05-07 12:43:01 -04:00
Hazelnoot
5fc3b8d120 *actually* check alts in authorized fetch 2025-05-07 12:43:00 -04:00
Hazelnoot
4365a5391b fix lint errors in ActivityPubServerService.ts 2025-05-07 12:43:00 -04:00
Hazelnoot
7ae5275fdf check altUrls for unsigned fetch 2025-05-07 12:43:00 -04:00
Hazelnoot
a642936d43 add configuration option "altUrls" 2025-05-07 12:43:00 -04:00
Hazelnoot
ee26579083 fix type error in ApRendererService.ts 2025-05-07 12:43:00 -04:00
Hazelnoot
c2f479c39d render remote URIs 2025-05-07 12:43:00 -04:00
37 changed files with 463 additions and 215 deletions

View file

@ -79,6 +79,14 @@
# Final accessible URL seen by a user.
url: http://misskey.local
# Other public URLs that are served by this instance.
# If you've migrated to a new domain using an existing database, then add your previous URL here to continue serving those posts and profiles via federation.
# Requires reverse-proxy configuration to forward requests with HOST header preserved.
# For advanced usage only!
#altUrls:
# - https://example.com/
# - https://legacy.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!

View file

@ -21,6 +21,14 @@ setupPassword: example_password_please_change_this_or_you_will_get_hacked
# Final accessible URL seen by a user.
url: 'http://misskey.local'
# Other public URLs that are served by this instance.
# If you've migrated to a new domain using an existing database, then add your previous URL here to continue serving those posts and profiles via federation.
# Requires reverse-proxy configuration to forward requests with HOST header preserved.
# For advanced usage only!
#altUrls:
# - https://example.com/
# - https://legacy.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!

View file

@ -80,6 +80,14 @@
# You can set url from an environment variable instead.
url: https://example.tld/
# Other public URLs that are served by this instance.
# If you've migrated to a new domain using an existing database, then add your previous URL here to continue serving those posts and profiles via federation.
# Requires reverse-proxy configuration to forward requests with HOST header preserved.
# For advanced usage only!
#altUrls:
# - https://example.com/
# - https://legacy.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!

View file

@ -79,6 +79,14 @@
# Final accessible URL seen by a user.
url: https://example.tld/
# Other public URLs that are served by this instance.
# If you've migrated to a new domain using an existing database, then add your previous URL here to continue serving those posts and profiles via federation.
# Requires reverse-proxy configuration to forward requests with HOST header preserved.
# For advanced usage only!
#altUrls:
# - https://example.com/
# - https://legacy.tld/
# ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT!

View file

@ -27,6 +27,7 @@ type RedisOptionsSource = Partial<RedisOptions> & {
*/
type Source = {
url?: string;
altUrls?: string[];
port?: number;
address?: string;
socket?: string;
@ -151,7 +152,7 @@ type Source = {
}
};
export type Config = {
export type Config = Tenant & {
url: string;
port: number;
address: string;
@ -273,6 +274,8 @@ export type Config = {
maxAge: number;
};
alts: Tenant[];
websocketCompression?: boolean;
customHtml: {
@ -280,6 +283,18 @@ export type Config = {
}
};
export type Tenant = {
url: string;
host: string;
hostname: string;
scheme: string;
wsScheme: string;
apiUrl: string;
wsUrl: string;
authUrl: string;
driveUrl: string;
};
export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch' | 'sqlTsvector';
const _filename = fileURLToPath(import.meta.url);
@ -345,6 +360,24 @@ export function loadConfig(): Config {
const internalMediaProxy = `${scheme}://${host}/proxy`;
const redis = convertRedisOptions(config.redis, host);
const alts: Tenant[] = config.altUrls?.map(alt => {
const altUrl = tryCreateUrl(alt);
const altHost = altUrl.host;
const altScheme = altUrl.protocol.replace(/:$/, '');
const altWsScheme = altScheme.replace('http', 'ws');
return {
url: alt,
host: altHost,
hostname: altUrl.hostname,
scheme: altScheme,
wsScheme: altWsScheme,
wsUrl: `${altWsScheme}://${altHost}`,
apiUrl: `${altWsScheme}://${altHost}/api`,
authUrl: `${altWsScheme}://${altHost}/auth`,
driveUrl: `${altWsScheme}://${altHost}/files`,
};
}) ?? [];
return {
version,
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
@ -427,6 +460,7 @@ export function loadConfig(): Config {
preSave: config.activityLogging?.preSave ?? false,
maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30),
},
alts,
websocketCompression: config.websocketCompression ?? false,
customHtml: {
head: config.customHtml?.head ?? '',

View file

@ -15,6 +15,7 @@ import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
import type { MiPartialUser } from '@/models/User.js';
@Injectable()
export class DeleteAccountService {
@ -38,10 +39,7 @@ export class DeleteAccountService {
}
@bindThis
public async deleteAccount(user: {
id: string;
host: string | null;
}, moderator?: MiUser): Promise<void> {
public async deleteAccount(user: MiPartialUser, moderator?: MiUser): Promise<void> {
if (this.meta.rootUserId === user.id) throw new Error('cannot delete a root account');
const _user = await this.usersRepository.findOneByOrFail({ id: user.id });

View file

@ -71,7 +71,9 @@ type AddFileArgs = {
ext?: string | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
requestHeaders?: {
[Name in string]?: string | string[]
} | null;
};
type UploadFromUrlArgs = {
@ -84,7 +86,9 @@ type UploadFromUrlArgs = {
isLink?: boolean;
comment?: string | null;
requestIp?: string | null;
requestHeaders?: Record<string, string> | null;
requestHeaders?: {
[Name in string]?: string | string[]
} | null;
};
@Injectable()
@ -583,6 +587,17 @@ export class DriveService {
const folder = await fetchFolder();
const headers: Record<string, string> = {};
if (requestHeaders) {
for (const [name, value] of Object.entries(requestHeaders)) {
if (Array.isArray(value)) {
headers[name] = value.join();
} else if (value) {
headers[name] = value;
}
}
}
let file = new MiDriveFile();
file.id = this.idService.gen();
file.userId = user ? user.id : null;
@ -593,7 +608,7 @@ export class DriveService {
file.blurhash = info.blurhash ?? null;
file.isLink = isLink;
file.requestIp = requestIp;
file.requestHeaders = requestHeaders;
file.requestHeaders = headers;
file.maybeSensitive = info.sensitive;
file.maybePorn = info.porn;
file.isSensitive = user

View file

@ -875,8 +875,8 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note, user)
: this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note, user);
return this.apRendererService.addContext(content);
}

View file

@ -5,7 +5,7 @@
import { Brackets, In, IsNull, Not } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common';
import type { MiUser, MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiUser, MiLocalUser, MiRemoteUser, MiPartialUser } from '@/models/User.js';
import { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js';
@ -71,7 +71,7 @@ export class NoteDeleteService {
* @param user 稿
* @param note 稿
*/
async delete(user: { id: MiUser['id']; uri: MiUser['uri']; host: MiUser['host']; isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) {
async delete(user: MiPartialUser & { isBot: MiUser['isBot']; }, note: MiNote, quiet = false, deleter?: MiUser) {
const deletedAt = new Date();
const cascadingNotes = await this.findCascadingNotes(note);
@ -103,7 +103,7 @@ export class NoteDeleteService {
}
const content = this.apRendererService.addContext(renote
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note), user)
? this.apRendererService.renderUndo(this.apRendererService.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note, user), user)
: this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
this.deliverToConcerned(user, note, content);

View file

@ -770,7 +770,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note, user)
: this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
return this.apRendererService.addContext(content);

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, NoteThreadMutingsRepository, MiMeta } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import type { MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiPartialUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiNote } from '@/models/Note.js';
import { IdService } from '@/core/IdService.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js';
@ -106,7 +106,7 @@ export class ReactionService {
}
@bindThis
public async create(user: { id: MiUser['id']; host: MiUser['host']; isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) {
public async create(user: MiPartialUser & { isBot: MiUser['isBot'] }, note: MiNote, _reaction?: string | null) {
// Check blocking
if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@ -276,7 +276,7 @@ export class ReactionService {
//#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note));
const content = this.apRendererService.addContext(await this.apRendererService.renderLike(record, note, user));
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId });
@ -337,7 +337,7 @@ export class ReactionService {
//#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) {
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note), user));
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(await this.apRendererService.renderLike(exist, note, user), user));
const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId });

View file

@ -40,12 +40,14 @@ export class RemoteUserResolveService {
}
@bindThis
public async resolveUser(username: string, host: string | null): Promise<MiLocalUser | MiRemoteUser> {
public async resolveUser(username: string, host: string | null, requestHostname?: string): Promise<MiLocalUser | MiRemoteUser> {
const usernameLower = username.toLowerCase();
const selfHost = requestHostname ? this.utilityService.getSelfQueryHost(requestHostname) : IsNull();
const tenant = requestHostname ? this.utilityService.getSelfTenant(requestHostname) : this.config;
if (host == null) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
return await this.usersRepository.findOneBy({ usernameLower, host: selfHost }).then(u => {
if (u == null) {
throw new Error('user not found');
} else {
@ -56,9 +58,9 @@ export class RemoteUserResolveService {
host = this.utilityService.toPuny(host);
if (host === this.utilityService.toPuny(this.config.host)) {
if (host === this.utilityService.toPuny(tenant.host)) {
this.logger.info(`return local user: ${usernameLower}`);
return await this.usersRepository.findOneBy({ usernameLower, host: IsNull() }).then(u => {
return await this.usersRepository.findOneBy({ usernameLower, host: selfHost }).then(u => {
if (u == null) {
throw new Error('user not found');
} else {

View file

@ -73,7 +73,7 @@ export class UserBlockingService implements OnModuleInit {
blockerId: blocker.id,
blockee,
blockeeId: blockee.id,
} as MiBlocking;
} satisfies MiBlocking;
await this.blockingsRepository.insert(blocking);
@ -178,7 +178,7 @@ export class UserBlockingService implements OnModuleInit {
// deliver if remote bloking
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) {
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking), blocker));
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderBlock(blocking as MiBlocking & { blocker: MiUser, blockee: MiUser }), blocker));
this.queueService.deliver(blocker, content, blockee.inbox, false);
}
}

View file

@ -7,8 +7,9 @@ import { URL, domainToASCII } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2';
import psl from 'psl';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { Config, Tenant } from '@/config.js';
import { bindThis } from '@/decorators.js';
import { MiMeta } from '@/models/Meta.js';
@ -176,4 +177,23 @@ export class UtilityService {
const host = this.extractDbHost(uri);
return this.isFederationAllowedHost(host);
}
@bindThis
public getSelfQueryHost(requestHostname: string) {
return this.getSelfDbHost(requestHostname) ?? IsNull();
}
@bindThis
public getSelfDbHost(requestHostname: string): string | null {
if (this.config.alts.some(alt => alt.hostname === requestHostname)) {
return this.punyHost(requestHostname);
}
return null;
}
@bindThis
public getSelfTenant(requestHostname: string): Tenant {
return this.config.alts.find(alt => alt.hostname === requestHostname) ?? this.config;
}
}

View file

@ -68,6 +68,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
return { local: false, uri: apId };
}
// TODO support alt-local
const [, type, id, ...rest] = uri.pathname.split(separator);
return {
local: true,

View file

@ -10,7 +10,7 @@ import * as mfm from '@transfem-org/sfm-js';
import { UnrecoverableError } from 'bullmq';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser, MiPartialUser } from '@/models/User.js';
import type { IMentionedRemoteUsers, MiNote } from '@/models/Note.js';
import type { MiBlocking } from '@/models/Blocking.js';
import type { MiRelay } from '@/models/Relay.js';
@ -34,7 +34,7 @@ import { UtilityService } from '@/core/UtilityService.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
import { getApId, IOrderedCollection, IOrderedCollectionPage } from './type.js';
import { getApId, getNullableApId, IOrderedCollection, IOrderedCollectionPage } from './type.js';
import type { IAccept, IActivity, IAdd, IAnnounce, IApDocument, IApEmoji, IApHashtag, IApImage, IApMention, IBlock, ICreate, IDelete, IFlag, IFollow, IKey, ILike, IMove, IObject, IPost, IQuestion, IReject, IRemove, ITombstone, IUndo, IUpdate } from './type.js';
@Injectable()
@ -78,27 +78,30 @@ export class ApRendererService {
}
@bindThis
public renderAccept(object: string | IObject, user: { id: MiUser['id']; host: null }): IAccept {
public renderAccept(object: string | IObject, user: MiPartialUser): IAccept {
return {
type: 'Accept',
actor: this.userEntityService.genLocalUserUri(user.id),
actor: this.userEntityService.getUserUri(user),
object,
};
}
@bindThis
public renderAdd(user: MiLocalUser, target: string | IObject | undefined, object: string | IObject): IAdd {
public renderAdd(user: MiPartialUser, target: string | IObject | undefined, object: string | IObject): IAdd {
return {
type: 'Add',
actor: this.userEntityService.genLocalUserUri(user.id),
actor: this.userEntityService.getUserUri(user),
target,
object,
};
}
public renderAnnounce(object: string | IObject, note: MiNote & { user: MiUser }): IAnnounce;
public renderAnnounce(object: string | IObject, note: MiNote, author: MiPartialUser): IAnnounce;
@bindThis
public renderAnnounce(object: string | IObject, note: MiNote): IAnnounce {
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
public renderAnnounce(object: string | IObject, note: MiNote, author?: MiPartialUser): IAnnounce {
const user = author ?? note.user as MiPartialUser;
const attributedTo = this.userEntityService.getUserUri(user);
let to: string[] = [];
let cc: string[] = [];
@ -116,9 +119,13 @@ export class ApRendererService {
throw new UnrecoverableError(`renderAnnounce: cannot render non-public note: ${getApId(object)}`);
}
const id = note.uri
? `${note.uri}/activity`
: `${this.config.url}/notes/${note.id}/activity`;
return {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: this.userEntityService.genLocalUserUri(note.userId),
id,
actor: attributedTo,
type: 'Announce',
published: this.idService.parse(note.id).date.toISOString(),
to,
@ -130,27 +137,35 @@ export class ApRendererService {
/**
* Renders a block into its ActivityPub representation.
*
* @param block The block to be rendered. The blockee relation must be loaded.
* @param block The block to be rendered. The blockee and blocker relation must be loaded.
*/
@bindThis
public renderBlock(block: MiBlocking): IBlock {
if (block.blockee?.uri == null) {
throw new Error('renderBlock: missing blockee uri');
}
public renderBlock(block: MiBlocking & { blockee: MiUser, blocker: MiUser }): IBlock {
const id = block.blocker.host
? `https://${block.blocker.host}/blocks/${block.id}`
: `${this.config.url}/blocks/${block.id}`;
return {
type: 'Block',
id: `${this.config.url}/blocks/${block.id}`,
actor: this.userEntityService.genLocalUserUri(block.blockerId),
object: block.blockee.uri,
id,
actor: this.userEntityService.getUserUri(block.blocker),
object: this.userEntityService.getUserUri(block.blockee),
};
}
public renderCreate(object: IObject, note: MiNote & { user: MiUser }): ICreate;
public renderCreate(object: IObject, note: MiNote, author: MiPartialUser): ICreate;
@bindThis
public renderCreate(object: IObject, note: MiNote): ICreate {
public renderCreate(object: IObject, note: MiNote, author?: MiPartialUser): ICreate {
const id = note.uri
? `${note.uri}/activity`
: `${this.config.url}/notes/${note.id}/activity`;
const user = author ?? note.user as MiPartialUser;
const activity: ICreate = {
id: `${this.config.url}/notes/${note.id}/activity`,
actor: this.userEntityService.genLocalUserUri(note.userId),
id,
actor: this.userEntityService.getUserUri(user),
type: 'Create',
published: this.idService.parse(note.id).date.toISOString(),
object,
@ -163,10 +178,10 @@ export class ApRendererService {
}
@bindThis
public renderDelete(object: IObject | string, user: { id: MiUser['id']; host: null }): IDelete {
public renderDelete(object: IObject | string, user: MiPartialUser): IDelete {
return {
type: 'Delete',
actor: this.userEntityService.genLocalUserUri(user.id),
actor: this.userEntityService.getUserUri(user),
object,
published: new Date().toISOString(),
};
@ -187,7 +202,7 @@ export class ApRendererService {
@bindThis
public renderEmoji(emoji: MiEmoji): IApEmoji {
return {
id: `${this.config.url}/emojis/${emoji.name}`,
id: emoji.uri ?? `${this.config.url}/emojis/${emoji.name}`,
type: 'Emoji',
name: `:${emoji.name}:`,
updated: emoji.updatedAt != null ? emoji.updatedAt.toISOString() : new Date().toISOString(),
@ -205,10 +220,10 @@ export class ApRendererService {
// to anonymise reporters, the reporting actor must be a system user
@bindThis
public renderFlag(user: MiLocalUser, object: IObject | string, content: string): IFlag {
public renderFlag(user: MiUser, object: IObject | string, content: string): IFlag {
return {
type: 'Flag',
actor: this.userEntityService.genLocalUserUri(user.id),
actor: this.userEntityService.getUserUri(user),
content,
// This MUST be an array for Pleroma compatibility: https://activitypub.software/TransFem-org/Sharkey/-/issues/641#note_7301
object: [object],
@ -216,11 +231,15 @@ export class ApRendererService {
}
@bindThis
public renderFollowRelay(relay: MiRelay, relayActor: MiLocalUser): IFollow {
public renderFollowRelay(relay: MiRelay, relayActor: MiUser): IFollow {
const id = relayActor.host
? `https://${relayActor.host}/activities/follow-relay/${relay.id}`
: `${this.config.url}/activities/follow-relay/${relay.id}`;
return {
id: `${this.config.url}/activities/follow-relay/${relay.id}`,
id,
type: 'Follow',
actor: this.userEntityService.genLocalUserUri(relayActor.id),
actor: this.userEntityService.getUserUri(relayActor),
object: 'https://www.w3.org/ns/activitystreams#Public',
};
}
@ -269,7 +288,7 @@ export class ApRendererService {
}
@bindThis
public renderIdenticon(user: MiLocalUser): IApImage {
public renderIdenticon(user: MiUser): IApImage {
return {
type: 'Image',
url: this.userEntityService.getIdenticonUrl(user),
@ -279,7 +298,7 @@ export class ApRendererService {
}
@bindThis
public renderSystemAvatar(user: MiLocalUser): IApImage {
public renderSystemAvatar(user: MiUser): IApImage {
if (this.meta.iconUrl == null) return this.renderIdenticon(user);
return {
type: 'Image',
@ -312,11 +331,15 @@ export class ApRendererService {
}
@bindThis
public renderKey(user: MiLocalUser, key: MiUserKeypair, postfix?: string): IKey {
public renderKey(user: MiUser, key: MiUserKeypair, postfix?: string): IKey {
const id = user.uri
? `${user.uri}${postfix ?? '/publickey'}`
: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`;
return {
id: `${this.config.url}/users/${user.id}${postfix ?? '/publickey'}`,
id,
type: 'Key',
owner: this.userEntityService.genLocalUserUri(user.id),
owner: this.userEntityService.getUserUri(user),
publicKeyPem: createPublicKey(key.publicKey).export({
type: 'spki',
format: 'pem',
@ -324,11 +347,15 @@ export class ApRendererService {
};
}
public async renderLike(noteReaction: MiNoteReaction & { user: MiUser }, note: { uri: string | null }): Promise<ILike>;
public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }, reactUser: MiPartialUser): Promise<ILike>;
@bindThis
public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }): Promise<ILike> {
public async renderLike(noteReaction: MiNoteReaction, note: { uri: string | null }, reactUser?: MiPartialUser): Promise<ILike> {
const reaction = noteReaction.reaction;
let isMastodon = false;
const user = reactUser ?? noteReaction.user as MiPartialUser;
if (this.meta.defaultLike && reaction.replaceAll(':', '') === this.meta.defaultLike.replaceAll(':', '')) {
const note = await this.notesRepository.findOneBy({ id: noteReaction.noteId });
@ -342,10 +369,14 @@ export class ApRendererService {
}
}
const id = user.host
? `https://${user.host}/likes/${noteReaction.id}`
: `${this.config.url}/likes/${noteReaction.id}`;
const object: ILike = {
type: 'Like',
id: `${this.config.url}/likes/${noteReaction.id}`,
actor: `${this.config.url}/users/${noteReaction.userId}`,
id,
actor: user.uri ?? `${this.config.url}/users/${noteReaction.userId}`,
object: note.uri ? note.uri : `${this.config.url}/notes/${noteReaction.noteId}`,
content: isMastodon ? undefined : reaction,
_misskey_reaction: isMastodon ? undefined : reaction,
@ -365,7 +396,7 @@ export class ApRendererService {
public renderMention(mention: MiPartialLocalUser | MiPartialRemoteUser): IApMention {
return {
type: 'Mention',
href: this.userEntityService.getUserUri(mention),
href: mention.uri ?? this.userEntityService.getUserUri(mention),
name: this.userEntityService.isRemoteUser(mention) ? `@${mention.username}@${mention.host}` : `@${(mention as MiLocalUser).username}`,
};
}
@ -377,8 +408,13 @@ export class ApRendererService {
): IMove {
const actor = this.userEntityService.getUserUri(src);
const target = this.userEntityService.getUserUri(dst);
const id = src.host
? `https://${src.host}/moves/${src.id}/${dst.id}`
: `${this.config.url}/moves/${src.id}/${dst.id}`;
return {
id: `${this.config.url}/moves/${src.id}/${dst.id}`,
id,
actor,
type: 'Move',
object: actor,
@ -429,7 +465,7 @@ export class ApRendererService {
}
}
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
const attributedTo = this.userEntityService.getUserUri(author);
const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
@ -524,7 +560,7 @@ export class ApRendererService {
const replies = isPublic ? await this.renderRepliesCollection(note.id) : undefined;
return {
id: `${this.config.url}/notes/${note.id}`,
id: note.uri ?? `${this.config.url}/notes/${note.id}`,
type: 'Note',
attributedTo,
summary: summary ?? undefined,
@ -551,8 +587,8 @@ export class ApRendererService {
// if you change this, also change `server/api/endpoints/i/update.ts`
@bindThis
public async renderPerson(user: MiLocalUser) {
const id = this.userEntityService.genLocalUserUri(user.id);
public async renderPerson(user: MiUser) {
const id = this.userEntityService.getUserUri(user);
const isSystem = user.username.includes('.');
const [avatar, banner, background, profile] = await Promise.all([
@ -583,14 +619,14 @@ export class ApRendererService {
const person: any = {
type: isSystem ? 'Application' : user.isBot ? 'Service' : 'Person',
id,
inbox: `${id}/inbox`,
outbox: `${id}/outbox`,
followers: `${id}/followers`,
following: `${id}/following`,
featured: `${id}/collections/featured`,
sharedInbox: `${this.config.url}/inbox`,
endpoints: { sharedInbox: `${this.config.url}/inbox` },
url: `${this.config.url}/@${user.username}`,
inbox: user.inbox ?? user.uri ? `${user.uri}/inbox` : `${id}/inbox`,
outbox: user.uri ? `${user.uri}/followers` : `${id}/outbox`,
followers: user.followersUri ?? user.uri ? `${user.uri}/followers` : `${id}/followers`,
following: user.uri ? `${user.uri}/following` : `${id}/following`,
featured: user.featured ?? user.uri ? `${user.uri}/features` : `${id}/collections/featured`,
sharedInbox: user.sharedInbox ?? user.host ? `${user.host}/inbox` : `${this.config.url}/inbox`,
endpoints: { sharedInbox: user.sharedInbox ?? user.host ? `${user.host}/inbox` : `${this.config.url}/inbox` },
url: profile.url ?? user.uri ?? `${this.config.url}/@${user.username}`,
preferredUsername: user.username,
name: user.name,
summary: profile.description ? this.mfmService.toHtml(mfm.parse(profile.description)) : null,
@ -639,21 +675,22 @@ export class ApRendererService {
}
@bindThis
public async renderPersonRedacted(user: MiLocalUser) {
const id = this.userEntityService.genLocalUserUri(user.id);
public async renderPersonRedacted(user: MiUser) {
const id = this.userEntityService.getUserUri(user);
const isSystem = user.username.includes('.');
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const profile = await this.userProfilesRepository.findOneByOrFail({ userId: 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}`,
inbox: user.inbox ?? user.uri ? `${user.uri}/inbox` : `${id}/inbox`,
outbox: user.uri ? `${user.uri}/followers` : `${id}/outbox`,
sharedInbox: user.sharedInbox ?? user.host ? `${user.host}/inbox` : `${this.config.url}/inbox`,
endpoints: { sharedInbox: user.sharedInbox ?? user.host ? `${user.host}/inbox` : `${this.config.url}/inbox` },
url: profile.url ?? user.uri ?? `${this.config.url}/@${user.username}`,
preferredUsername: user.username,
publicKey: this.renderKey(user, keypair, '#main-key'),
@ -671,11 +708,11 @@ export class ApRendererService {
}
@bindThis
public renderQuestion(user: { id: MiUser['id'] }, note: MiNote, poll: MiPoll): IQuestion {
public renderQuestion(user: MiPartialUser, note: MiNote, poll: MiPoll): IQuestion {
return {
type: 'Question',
id: `${this.config.url}/questions/${note.id}`,
actor: this.userEntityService.genLocalUserUri(user.id),
id: note.uri ?? `${this.config.url}/questions/${note.id}`,
actor: this.userEntityService.getUserUri(user),
content: note.text ?? '',
[poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
name: text,
@ -689,19 +726,19 @@ export class ApRendererService {
}
@bindThis
public renderReject(object: string | IObject, user: { id: MiUser['id'] }): IReject {
public renderReject(object: string | IObject, user: MiPartialUser): IReject {
return {
type: 'Reject',
actor: this.userEntityService.genLocalUserUri(user.id),
actor: this.userEntityService.getUserUri(user),
object,
};
}
@bindThis
public renderRemove(user: { id: MiUser['id'] }, target: string | IObject | undefined, object: string | IObject): IRemove {
public renderRemove(user: MiPartialUser, target: string | IObject | undefined, object: string | IObject): IRemove {
return {
type: 'Remove',
actor: this.userEntityService.genLocalUserUri(user.id),
actor: this.userEntityService.getUserUri(user),
target,
object,
};
@ -716,23 +753,30 @@ export class ApRendererService {
}
@bindThis
public renderUndo(object: string | IObject, user: { id: MiUser['id'] }): IUndo {
const id = typeof object !== 'string' && typeof object.id === 'string' && this.utilityService.isUriLocal(object.id) ? `${object.id}/undo` : undefined;
public renderUndo(object: string | IObject, user: MiPartialUser): IUndo {
const objectId = getNullableApId(object);
const id = objectId
? `${objectId}/undo`
: undefined;
return {
type: 'Undo',
...(id ? { id } : {}),
actor: this.userEntityService.genLocalUserUri(user.id),
id,
actor: this.userEntityService.getUserUri(user),
object,
published: new Date().toISOString(),
};
}
@bindThis
public renderUpdate(object: string | IObject, user: { id: MiUser['id'] }): IUpdate {
public renderUpdate(object: string | IObject, user: MiPartialUser): IUpdate {
const id = user.uri
? `${user.uri}#updates/${new Date().getTime()}`
: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`;
return {
id: `${this.config.url}/users/${user.id}#updates/${new Date().getTime()}`,
actor: this.userEntityService.genLocalUserUri(user.id),
id,
actor: this.userEntityService.getUserUri(user),
type: 'Update',
to: ['https://www.w3.org/ns/activitystreams#Public'],
object,
@ -783,7 +827,7 @@ export class ApRendererService {
}
}
const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
const attributedTo = this.userEntityService.getUserUri(author);
const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
@ -871,7 +915,7 @@ export class ApRendererService {
} as const : {};
return {
id: `${this.config.url}/notes/${note.id}`,
id: note.uri ?? `${this.config.url}/notes/${note.id}`,
type: 'Note',
attributedTo,
summary: summary ?? undefined,
@ -897,17 +941,24 @@ export class ApRendererService {
}
@bindThis
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
public renderVote(user: MiPartialUser, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
const voteId = user.uri
? `${user.uri}#votes/${vote.id}`
: `${this.config.url}/users/${user.id}#votes/${vote.id}`;
const activityId = `${voteId}/activity`;
return {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}/activity`,
actor: this.userEntityService.genLocalUserUri(user.id),
id: activityId,
actor: this.userEntityService.getUserUri(user),
type: 'Create',
to: [pollOwner.uri],
published: new Date().toISOString(),
object: {
id: `${this.config.url}/users/${user.id}#votes/${vote.id}`,
id: voteId,
type: 'Note',
attributedTo: this.userEntityService.genLocalUserUri(user.id),
attributedTo: this.userEntityService.getUserUri(user),
// TODO what about local poll?
to: [pollOwner.uri],
inReplyTo: note.uri,
name: poll.choices[vote.choice],
@ -925,7 +976,7 @@ export class ApRendererService {
}
@bindThis
public async attachLdSignature(activity: any, user: { id: MiUser['id']; host: null; }): Promise<IActivity> {
public async attachLdSignature(activity: any, user: MiPartialUser): Promise<IActivity> {
// Linked Data signatures are cryptographic signatures attached to each activity to provide proof of authenticity.
// When using authorized fetch, this is often undesired as any signed activity can be forwarded to a blocked instance by relays and other instances.
// This setting allows admins to disable LD signatures for increased privacy, at the expense of fewer relayed activities and additional inbound fetch (GET) requests.
@ -933,11 +984,15 @@ export class ApRendererService {
return activity;
}
const keyId = user.uri
? `${user.uri}#main-key`
: `${this.config.url}/users/${user.id}#main-key`;
const keypair = await this.userKeypairService.getUserKeypair(user.id);
const jsonLd = this.jsonLdService.use();
jsonLd.debug = false;
activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, `${this.config.url}/users/${user.id}#main-key`);
activity = await jsonLd.signRsaSignature2017(activity, keypair.privateKey, keyId);
return activity;
}

View file

@ -5,8 +5,8 @@
import { Inject, Injectable } from '@nestjs/common';
import { IsNull, Not } from 'typeorm';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog } from '@/models/_.js';
import type { MiLocalUser, MiPartialUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { NotesRepository, PollsRepository, NoteReactionsRepository, UsersRepository, FollowRequestsRepository, MiMeta, SkApFetchLog, MiNoteReaction } from '@/models/_.js';
import type { Config } from '@/config.js';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { DI } from '@/di-symbols.js';
@ -238,7 +238,7 @@ export class Resolver {
const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
if (parsed.rest === 'activity') {
// this refers to the create activity and not the note itself
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note, author));
} else {
return this.apRendererService.renderNote(note, author);
}
@ -249,13 +249,13 @@ export class Resolver {
case 'questions':
// Polls are indexed by the note they are attached to.
return Promise.all([
this.notesRepository.findOneByOrFail({ id: parsed.id }),
this.notesRepository.findOneOrFail({ where: { id: parsed.id }, relations: ['user'] }),
this.pollsRepository.findOneByOrFail({ noteId: parsed.id }),
])
.then(([note, poll]) => this.apRendererService.renderQuestion({ id: note.userId }, note, poll)) as Promise<IObjectWithId>;
.then(([note, poll]) => this.apRendererService.renderQuestion(note.user as MiPartialUser, note, poll)) as Promise<IObjectWithId>;
case 'likes':
return this.noteReactionsRepository.findOneByOrFail({ id: parsed.id }).then(async reaction =>
this.apRendererService.addContext(await this.apRendererService.renderLike(reaction, { uri: null })));
return this.noteReactionsRepository.findOneOrFail({ where: { id: parsed.id }, relations: ['user'] }).then(async reaction =>
this.apRendererService.addContext(await this.apRendererService.renderLike(reaction as MiNoteReaction & { user: MiUser }, { uri: null })));
case 'follows':
return this.followRequestsRepository.findOneBy({ id: parsed.id })
.then(async followRequest => {

View file

@ -14,6 +14,7 @@ import { SystemAccountService } from '@/core/SystemAccountService.js';
import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { UtilityService } from '@/core/UtilityService.js';
@Injectable()
export class MetaEntityService {
@ -28,10 +29,11 @@ export class MetaEntityService {
private adsRepository: AdsRepository,
private systemAccountService: SystemAccountService,
private readonly utilityService: UtilityService,
) { }
@bindThis
public async pack(meta?: MiMeta): Promise<Packed<'MetaLite'>> {
public async pack(meta?: MiMeta, requestHostname?: string): Promise<Packed<'MetaLite'>> {
let instance = meta;
if (!instance) {
@ -64,6 +66,8 @@ export class MetaEntityService {
}
}
const tenant = requestHostname ? this.utilityService.getSelfTenant(requestHostname) : this.config;
const packed: Packed<'MetaLite'> = {
maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail,
@ -73,7 +77,7 @@ export class MetaEntityService {
name: instance.name,
shortName: instance.shortName,
uri: this.config.url,
uri: tenant.url,
description: instance.description,
langs: instance.langs,
tosUrl: instance.termsOfServiceUrl,
@ -151,14 +155,14 @@ export class MetaEntityService {
}
@bindThis
public async packDetailed(meta?: MiMeta): Promise<Packed<'MetaDetailed'>> {
public async packDetailed(meta?: MiMeta, requestHostname?: string): Promise<Packed<'MetaDetailed'>> {
let instance = meta;
if (!instance) {
instance = this.meta;
}
const packed = await this.pack(instance);
const packed = await this.pack(instance, requestHostname);
const proxyAccount = await this.systemAccountService.fetch('proxy');
@ -181,6 +185,10 @@ export class MetaEntityService {
miauth: true,
},
allowUnsignedFetch: instance.allowUnsignedFetch,
localHosts: [
null,
...this.config.alts.map(alt => this.utilityService.toPuny(alt.host)),
],
};
return packDetailed;

View file

@ -14,7 +14,7 @@ import type { Packed } from '@/misc/json-schema.js';
import type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiPartialUser, MiRemoteUser, MiUser } from '@/models/User.js';
import {
birthdaySchema,
descriptionSchema,
@ -460,9 +460,8 @@ export class UserEntityService implements OnModuleInit {
}
@bindThis
public getUserUri(user: MiLocalUser | MiPartialLocalUser | MiRemoteUser | MiPartialRemoteUser): string {
return this.isRemoteUser(user)
? user.uri : this.genLocalUserUri(user.id);
public getUserUri(user: MiLocalUser | MiPartialLocalUser | MiRemoteUser | MiPartialRemoteUser | MiPartialUser | MiUser): string {
return user.uri ?? this.genLocalUserUri(user.id);
}
@bindThis

View file

@ -407,6 +407,12 @@ export type MiPartialRemoteUser = Partial<MiUser> & {
uri: string;
};
export interface MiPartialUser extends Partial<MiUser> {
id: MiUser['id'];
host: MiUser['host'];
uri?: MiUser['uri'];
}
export const localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const;
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;

View file

@ -436,6 +436,14 @@ export const packedMetaDetailedOnlySchema = {
enum: instanceUnsignedFetchOptions,
optional: false, nullable: false,
},
localHosts: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'string',
optional: false, nullable: true,
},
},
},
} as const;

View file

@ -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, MiMeta } from '@/models/_.js';
import type { FollowingsRepository, NotesRepository, EmojisRepository, NoteReactionsRepository, UserProfilesRepository, UserNotePiningsRepository, UsersRepository, FollowRequestsRepository, MiMeta, MiNoteReaction } from '@/models/_.js';
import * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
@ -114,10 +114,10 @@ export class ActivityPubServerService {
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);
return this.apRendererService.renderAnnounce(renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`, note, author);
}
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note);
return this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author, false), note, author);
}
/**
@ -140,7 +140,7 @@ export class ActivityPubServerService {
}
// Auth fetch disabled => accept
const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId);
const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId, request.hostname);
if (allowUnsignedFetch === 'always') {
return { reject: false, redact: false };
}
@ -186,7 +186,7 @@ export class ActivityPubServerService {
headers: ['(request-target)', 'host', 'date'],
authorizationHeaderName: 'signature',
});
} catch (e) {
} catch {
// not signed, or malformed signature: refuse
return `${request.id} ${request.url} not signed, or malformed signature: refuse`;
}
@ -196,9 +196,12 @@ export class ActivityPubServerService {
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) {
// no destination host, or not us: refuse
return `${logPrefix} no destination host, or not us: refuse`;
if (signature.params.headers.indexOf('host') === -1) {
return `${logPrefix} no destination host: refuse`;
}
if (request.hostname !== this.config.host && !this.config.alts.some(alt => alt.hostname === request.hostname)) {
return `${logPrefix} destination not us: refuse`;
}
if (!this.utilityService.isFederationAllowedHost(keyHost)) {
@ -291,9 +294,14 @@ export class ActivityPubServerService {
return;
}
if (signature.params.headers.indexOf('host') === -1
|| request.headers.host !== this.config.host) {
// Host not specified or not match.
if (signature.params.headers.indexOf('host') === -1) {
// no destination host: refuse
reply.code(401);
return;
}
if (request.hostname !== this.config.host && !this.config.alts.some(alt => alt.hostname === request.hostname)) {
// destination not us: refuse
reply.code(401);
return;
}
@ -373,7 +381,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) {
@ -470,7 +478,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) {
@ -556,7 +564,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) {
@ -626,7 +634,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) {
@ -723,7 +731,7 @@ export class ActivityPubServerService {
}
// リモートだったらリダイレクト
if (user.host != null) {
if (user.host !== this.utilityService.getSelfDbHost(request.hostname)) {
if (user.uri == null || this.utilityService.isSelfHost(user.host)) {
reply.code(500);
return;
@ -735,7 +743,7 @@ export class ActivityPubServerService {
this.setResponseType(request, reply);
const person = redact
? await this.apRendererService.renderPersonRedacted(user as MiLocalUser)
? await this.apRendererService.renderPersonRedacted(user)
: await this.apRendererService.renderPerson(user as MiLocalUser);
return this.apRendererService.addContext(person);
}
@ -829,7 +837,7 @@ export class ActivityPubServerService {
}
// リモートだったらリダイレクト
if (note.userHost != null) {
if (note.userHost !== this.utilityService.getSelfDbHost(request.hostname)) {
if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) {
reply.code(500);
return;
@ -855,7 +863,7 @@ export class ActivityPubServerService {
const note = await this.notesRepository.findOneBy({
id: request.params.note,
userHost: IsNull(),
userHost: this.utilityService.getSelfQueryHost(request.hostname),
visibility: In(['public', 'home']),
localOnly: false,
});
@ -887,7 +895,7 @@ export class ActivityPubServerService {
.createQueryBuilder('note')
.andWhere({
id: request.params.note,
userHost: IsNull(),
userHost: this.utilityService.getSelfQueryHost(request.hostname),
visibility: In(['public', 'home']),
localOnly: false,
})
@ -955,7 +963,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({
id: userId,
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) {
@ -1007,7 +1015,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({
usernameLower: acct.username.toLowerCase(),
host: acct.host ?? IsNull(),
host: acct.host ?? this.utilityService.getSelfQueryHost(request.hostname),
isSuspended: false,
});
@ -1029,7 +1037,7 @@ export class ActivityPubServerService {
if (reject) return;
const emoji = await this.emojisRepository.findOneBy({
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
name: request.params.emoji,
});
@ -1039,7 +1047,7 @@ export class ActivityPubServerService {
}
this.setResponseType(request, reply);
return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji)));
return (this.apRendererService.addContext(this.apRendererService.renderEmoji(emoji)));
});
// like
@ -1049,7 +1057,7 @@ export class ActivityPubServerService {
return;
}
const reaction = await this.noteReactionsRepository.findOneBy({ id: request.params.like });
const reaction = await this.noteReactionsRepository.findOne({ where: { id: request.params.like }, relations: ['user'] }) as MiNoteReaction & { user: MiUser } | null;
const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId);
if (reject) return;
@ -1059,6 +1067,11 @@ export class ActivityPubServerService {
return;
}
if (reaction.user.host !== this.utilityService.getSelfDbHost(request.hostname)) {
reply.code(404);
return;
}
const note = await this.notesRepository.findOneBy({ id: reaction.noteId });
if (note == null) {
@ -1083,14 +1096,15 @@ export class ActivityPubServerService {
// This may be used before the follow is completed, so we do not
// check if the following exists.
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({
id: request.params.follower,
host: IsNull(),
host: queryHost,
}),
this.usersRepository.findOneBy({
id: request.params.followee,
host: Not(IsNull()),
host: Not(queryHost),
}),
]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null];
@ -1125,14 +1139,15 @@ export class ActivityPubServerService {
return;
}
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({
id: followRequest.followerId,
host: IsNull(),
host: queryHost,
}),
this.usersRepository.findOneBy({
id: followRequest.followeeId,
host: Not(IsNull()),
host: Not(queryHost),
}),
]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null];
@ -1148,11 +1163,15 @@ export class ActivityPubServerService {
done();
}
private async getUnsignedFetchAllowance(userId: string | undefined) {
const user = userId ? await this.cacheService.findLocalUserById(userId) : null;
private async getUnsignedFetchAllowance(userId: string | undefined, hostname: string) {
let user = userId ? await this.cacheService.findUserById(userId) : null;
// User system value if there is no user, or if user has deferred the choice.
if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') {
if (user && user.host !== this.utilityService.getSelfDbHost(hostname)) {
user = null;
}
// Use system value if there is no user, or if user has deferred the choice.
if (!user || user.allowUnsignedFetch === 'staff') {
return this.meta.allowUnsignedFetch;
}

View file

@ -15,6 +15,7 @@ import type { MiUser } from '@/models/User.js';
import * as Acct from '@/misc/acct.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import type { FindOptionsWhere } from 'typeorm';
@ -35,6 +36,7 @@ export class WellKnownServerService {
private nodeinfoServerService: NodeinfoServerService,
private userEntityService: UserEntityService,
private oauth2ProviderService: OAuth2ProviderService,
private readonly utilityService: UtilityService,
) {
//this.createServer = this.createServer.bind(this);
}
@ -74,11 +76,13 @@ export class WellKnownServerService {
return;
}
const tenant = this.utilityService.getSelfTenant(request.hostname);
reply.header('Content-Type', xrd);
return XRD({ element: 'Link', attributes: {
rel: 'lrdd',
type: xrd,
template: `${this.config.url}${webFingerPath}?resource={uri}`,
template: `${tenant.url}${webFingerPath}?resource={uri}`,
} });
});
@ -88,12 +92,14 @@ export class WellKnownServerService {
return;
}
const tenant = this.utilityService.getSelfTenant(request.hostname);
reply.header('Content-Type', 'application/json');
return {
links: [{
rel: 'lrdd',
type: jrd,
template: `${this.config.url}${webFingerPath}?resource={uri}`,
template: `${tenant.url}${webFingerPath}?resource={uri}`,
}],
};
});
@ -122,24 +128,27 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
return;
}
const tenant = this.utilityService.getSelfTenant(request.hostname);
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
const fromId = (id: MiUser['id']): FindOptionsWhere<MiUser> => ({
id,
host: IsNull(),
host: queryHost,
isSuspended: false,
});
const generateQuery = (resource: string): FindOptionsWhere<MiUser> | number =>
resource.startsWith(`${this.config.url.toLowerCase()}/users/`) ?
resource.startsWith(`${tenant.url.toLowerCase()}/users/`) ?
fromId(resource.split('/').pop()!) :
fromAcct(Acct.parse(
resource.startsWith(`${this.config.url.toLowerCase()}/@`) ? resource.split('/').pop()! :
resource.startsWith(`${tenant.url.toLowerCase()}/@`) ? resource.split('/').pop()! :
resource.startsWith('acct:') ? resource.slice('acct:'.length) :
resource));
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number =>
!acct.host || acct.host === this.config.host.toLowerCase() ? {
!acct.host || acct.host === tenant.host.toLowerCase() ? {
usernameLower: acct.username.toLowerCase(),
host: IsNull(),
host: queryHost,
isSuspended: false,
} : 422;
@ -162,12 +171,12 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
return;
}
const subject = `acct:${user.username}@${this.config.host}`;
const profileLink = `${this.config.url}/@${user.username}`;
const subject = `acct:${user.username}@${tenant.host}`;
const profileLink = `${tenant.url}/@${user.username}`;
const self = {
rel: 'self',
type: 'application/activity+json',
href: this.userEntityService.genLocalUserUri(user.id),
href: this.userEntityService.getUserUri(user),
};
const profilePage = {
rel: 'http://webfinger.net/rel/profile-page',
@ -176,7 +185,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
};
const subscribe = {
rel: 'http://ostatus.org/schema/1.0/subscribe',
template: `${this.config.url}/authorize-follow?acct={uri}`,
template: `${tenant.url}/authorize-follow?acct={uri}`,
};
vary(reply.raw, 'Accept');

View file

@ -23,6 +23,7 @@ import { type RolePolicies, 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 type { PublicExecutor } from '@/server/api/endpoint-base.js';
import { ApiError } from './error.js';
import { ApiLoggerService } from './ApiLoggerService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
@ -145,7 +146,7 @@ export class ApiCallService implements OnApplicationShutdown {
@bindThis
public handleRequest(
endpoint: IEndpoint & { exec: any },
endpoint: IEndpoint & { exec: PublicExecutor },
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
): void {
@ -181,7 +182,7 @@ export class ApiCallService implements OnApplicationShutdown {
@bindThis
public async handleMultipartRequest(
endpoint: IEndpoint & { exec: any },
endpoint: IEndpoint & { exec: PublicExecutor },
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply,
): Promise<void> {
@ -274,9 +275,9 @@ export class ApiCallService implements OnApplicationShutdown {
@bindThis
private async call(
ep: IEndpoint & { exec: any },
user: MiLocalUser | null | undefined,
token: MiAccessToken | null | undefined,
ep: IEndpoint & { exec: PublicExecutor },
user: MiLocalUser | null,
token: MiAccessToken | null,
data: any,
multipartFile: MultipartFile | null,
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
@ -430,17 +431,23 @@ export class ApiCallService implements OnApplicationShutdown {
cleanup = result.cleanup;
}
const requestData = {
ip: request.ip,
headers: request.headers,
hostname: request.hostname,
};
// API invoking
if (this.config.sentryForBackend) {
return await Sentry.startSpan({
name: 'API: ' + ep.name,
}, () => {
return ep.exec(data, user, token, attachmentFile, request.ip, request.headers)
return ep.exec(data, user, token, attachmentFile, requestData)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup());
});
} else {
return await ep.exec(data, user, token, attachmentFile, request.ip, request.headers)
return await ep.exec(data, user, token, attachmentFile, requestData)
.catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup());
}

View file

@ -19,6 +19,7 @@ import { SignupApiService } from './SignupApiService.js';
import { SigninApiService } from './SigninApiService.js';
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
import type { PublicExecutor } from './endpoint-base.js';
@Injectable()
export class ApiServerService {
@ -67,7 +68,7 @@ export class ApiServerService {
name: endpoint.name,
meta: endpoint.meta,
params: endpoint.params,
exec: this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec,
exec: this.moduleRef.get('ep:' + endpoint.name, { strict: false }).exec as PublicExecutor,
};
if (endpoint.meta.requireFile) {

View file

@ -26,18 +26,40 @@ export type AttachmentFile = {
path: string;
};
interface Request {
ip: string | null;
headers: {
[Name in string]?: string | string[]
};
hostname: string;
}
// TODO: paramsの型をT['params']のスキーマ定義から推論する
type Executor<T extends IEndpointMeta, Ps extends Schema> =
(params: SchemaType<Ps>, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, cleanup?: () => any, ip?: string | null, headers?: Record<string, string> | null) =>
Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
(
params: SchemaType<Ps>,
user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null,
token: MiAccessToken | null,
file: AttachmentFile | null,
cleanup: (() => void) | undefined,
request: Request,
) => Promise<T['res'] extends undefined ? Response : SchemaType<NonNullable<T['res']>>>;
export type PublicExecutor<T extends IEndpointMeta = IEndpointMeta> = (
params: any,
user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null,
token: MiAccessToken | null,
file: AttachmentFile | null,
request: Request,
) => Promise<any>;
export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
public exec: (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => Promise<any>;
public exec: PublicExecutor<T>;
constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
const validate = ajv.compile(paramDef);
this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, ip?: string | null, headers?: Record<string, string> | null) => {
this.exec = (params: any, user: T['requireCredential'] extends true ? MiLocalUser : MiLocalUser | null, token: MiAccessToken | null, file?: AttachmentFile, request: Request) => {
let cleanup: undefined | (() => void) = undefined;
if (meta.requireFile) {
@ -54,21 +76,21 @@ export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
const valid = validate(params);
if (!valid) {
if (file) cleanup!();
if (file && cleanup) cleanup();
const errors = validate.errors!;
const errors = validate.errors;
const err = new ApiError({
message: 'Invalid param.',
code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532',
}, {
}, errors?.length ? {
param: errors[0].schemaPath,
reason: errors[0].message,
});
} : undefined);
return Promise.reject(err);
}
return cb(params as SchemaType<Ps>, user, token, file, cleanup, ip, headers);
return cb(params as SchemaType<Ps>, user, token, file, cleanup, request);
};
}
}

View file

@ -95,7 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private driveFileEntityService: DriveFileEntityService,
private driveService: DriveService,
) {
super(meta, paramDef, async (ps, me, _, file, cleanup, ip, headers) => {
super(meta, paramDef, async (ps, me, _, file, cleanup, { ip, headers }) => {
// Get 'name' parameter
let name = ps.name ?? file!.name ?? null;
if (name != null) {

View file

@ -61,7 +61,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private driveService: DriveService,
private globalEventService: GlobalEventService,
) {
super(meta, paramDef, async (ps, user, _1, _2, _3, ip, headers) => {
super(meta, paramDef, async (ps, user, _1, _2, _3, { ip, headers }) => {
if (ps.comment && ps.comment.length > this.config.maxAltTextLength) {
throw new ApiError(meta.errors.commentTooLong);
}

View file

@ -93,7 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private apPersonService: ApPersonService,
private userEntityService: UserEntityService,
) {
super(meta, paramDef, async (ps, me) => {
super(meta, paramDef, async (ps, me, _at, _file, _cleanup, { hostname }) => {
// check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
// abort if user is the root
@ -104,7 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
// parse user's input into the destination account
const { username, host } = Acct.parse(ps.moveToAccount);
// retrieve the destination account
let moveTo = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
let moveTo = await this.remoteUserResolveService.resolveUser(username, host, hostname).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});

View file

@ -302,7 +302,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private avatarDecorationService: AvatarDecorationService,
private utilityService: UtilityService,
) {
super(meta, paramDef, async (ps, _user, token) => {
super(meta, paramDef, async (ps, _user, token, _file, _cleanup, { hostname }) => {
const user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
const isSecure = token == null;
@ -501,7 +501,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const { username, host } = Acct.parse(line);
// Retrieve the old account
const knownAs = await this.remoteUserResolveService.resolveUser(username, host).catch((e) => {
const knownAs = await this.remoteUserResolveService.resolveUser(username, host, hostname).catch((e) => {
this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`);
throw new ApiError(meta.errors.noSuchUser);
});

View file

@ -40,8 +40,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor(
private metaEntityService: MetaEntityService,
) {
super(meta, paramDef, async (ps, me) => {
return ps.detail ? await this.metaEntityService.packDetailed() : await this.metaEntityService.pack();
super(meta, paramDef, async (ps, _me, _at, _file, _cleanup, { hostname }) => {
return ps.detail
? await this.metaEntityService.packDetailed(undefined, hostname)
: await this.metaEntityService.pack(undefined, hostname);
});
}
}

View file

@ -98,7 +98,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private perUserPvChart: PerUserPvChart,
private apiLoggerService: ApiLoggerService,
) {
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
super(meta, paramDef, async (ps, me, _1, _2, _3, { ip, hostname }) => {
let user;
const isModerator = await this.roleService.isModerator(me);
@ -130,7 +130,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else {
// Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => {
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host, hostname).catch(err => {
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
throw new ApiError(meta.errors.failedToResolveRemoteUser);
});

View file

@ -59,6 +59,7 @@ import { FlashEntityService } from '@/core/entities/FlashEntityService.js';
import { RoleService } from '@/core/RoleService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js';
@ -130,6 +131,7 @@ export class ClientServerService {
private feedService: FeedService,
private roleService: RoleService,
private clientLoggerService: ClientLoggerService,
private readonly utilityService: UtilityService,
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@ -146,14 +148,14 @@ export class ClientServerService {
}
@bindThis
private async manifestHandler(reply: FastifyReply) {
private async manifestHandler(reply: FastifyReply, hostname: string) {
let manifest = {
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'short_name': this.meta.shortName || this.meta.name || this.config.host,
'short_name': this.meta.shortName || this.meta.name || hostname,
// 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'name': this.meta.name || this.config.host,
'name': this.meta.name || hostname,
'start_url': '/',
'display': 'standalone',
'background_color': '#313a42',
@ -415,7 +417,7 @@ export class ClientServerService {
});
// Manifest
fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply));
fastify.get('/manifest.json', async (request, reply) => await this.manifestHandler(reply, request.hostname));
// Embed Javascript
fastify.get('/embed.js', async (request, reply) => {
@ -467,11 +469,11 @@ export class ClientServerService {
// URL preview endpoint
fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply));
const getFeed = async (acct: string) => {
const getFeed = async (acct: string, hostname: string) => {
const { username, host } = Acct.parse(acct);
const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
host: host ?? this.utilityService.getSelfQueryHost(hostname),
isSuspended: false,
enableRss: true,
requireSigninToViewContents: false,
@ -484,7 +486,7 @@ export class ClientServerService {
fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
const feed = await getFeed(request.params.user, request.hostname);
if (feed) {
reply.header('Content-Type', 'application/atom+xml; charset=utf-8');
@ -499,7 +501,7 @@ export class ClientServerService {
fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
const feed = await getFeed(request.params.user, request.hostname);
if (feed) {
reply.header('Content-Type', 'application/rss+xml; charset=utf-8');
@ -514,7 +516,7 @@ export class ClientServerService {
fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => {
if (request.params.user == null) return await renderBase(reply);
const feed = await getFeed(request.params.user);
const feed = await getFeed(request.params.user, request.hostname);
if (feed) {
reply.header('Content-Type', 'application/json; charset=utf-8');
@ -531,7 +533,7 @@ export class ClientServerService {
const { username, host } = Acct.parse(request.params.user);
const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
host: host ?? this.utilityService.getSelfQueryHost(request.hostname),
isSuspended: false,
});
@ -575,7 +577,7 @@ export class ClientServerService {
fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => {
const user = await this.usersRepository.findOneBy({
id: request.params.user,
host: IsNull(),
host: this.utilityService.getSelfQueryHost(request.hostname),
isSuspended: false,
});
@ -586,7 +588,8 @@ export class ClientServerService {
vary(reply.raw, 'Accept');
reply.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`);
const dbHost = this.utilityService.getSelfDbHost(request.hostname);
reply.redirect(`/@${user.username}${ user.host === dbHost ? '' : '@' + user.host}`);
});
// Note
@ -630,7 +633,7 @@ export class ClientServerService {
const { username, host } = Acct.parse(request.params.user);
const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(),
host: host ?? IsNull(),
host: host ?? this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) return;
@ -813,10 +816,10 @@ export class ClientServerService {
const user = await this.usersRepository.findOneBy({
id: request.params.user,
host: this.utilityService.getSelfQueryHost(request.hostname),
});
if (user == null) return;
if (user.host != null) return;
const _user = await this.userEntityService.pack(user);
@ -835,11 +838,11 @@ export class ClientServerService {
const note = await this.notesRepository.findOneBy({
id: request.params.note,
userHost: this.utilityService.getSelfQueryHost(request.hostname),
});
if (note == null) return;
if (['specified', 'followers'].includes(note.visibility)) return;
if (note.userHost != null) return;
const _note = await this.noteEntityService.pack(note, null, { detail: true });
@ -887,12 +890,13 @@ export class ClientServerService {
fastify.get('/_info_card_', async (request, reply) => {
reply.removeHeader('X-Frame-Options');
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
return await reply.view('info-card', {
version: this.config.version,
host: this.config.host,
host: request.hostname,
meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }),
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }),
originalUsersCount: await this.usersRepository.countBy({ host: queryHost }),
originalNotesCount: await this.notesRepository.countBy({ userHost: queryHost }),
});
});
//#endregion

View file

@ -10,7 +10,7 @@ import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals';
import type { Config } from '@/config.js';
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
import type { MiLocalUser, MiPartialUser, MiRemoteUser } from '@/models/User.js';
import { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
@ -276,7 +276,11 @@ describe('ActivityPub', () => {
rendererService.renderAnnounce('https://example.com/notes/00example', {
id: genAidx(Date.now()),
visibility: 'followers',
} as MiNote);
} as MiNote, {
id: genAidx(Date.now()),
host: null,
uri: null,
} as MiPartialUser);
});
});

View file

@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton>
</div>
<div class="_margin _gaps_s">
<MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/>
<MkRemoteCaution v-if="!instance.localHosts.includes(note.user.host)" :href="note.url ?? note.uri"/>
<SkErrorList :errors="note.processErrors"/>
<DynamicNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/>
</div>
@ -67,6 +67,7 @@ import { pleaseLogin } from '@/utility/please-login.js';
import { getAppearNote } from '@/utility/get-appear-note.js';
import { serverContext, assertServerContext } from '@/server-context.js';
import { $i } from '@/i.js';
import { instance } from '@/instance.js';
// context
const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null;

View file

@ -12,7 +12,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/>
<MkRemoteCaution v-if="user.host != null" :href="user.url ?? user.uri!"/>
<MkRemoteCaution v-if="!instance.localHosts.includes(user.host)" :href="user.url ?? user.uri!"/>
<MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
<div :key="user.id" class="main _panel">
@ -220,7 +220,7 @@ import { misskeyApi } from '@/utility/misskey-api.js';
import { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
import { useRouter } from '@/router.js';
import { getStaticImageUrl } from '@/utility/media-proxy.js';
import { infoImageUrl } from '@/instance.js';
import { infoImageUrl, instance } from '@/instance.js';
import MkSparkle from '@/components/MkSparkle.vue';
import { prefer } from '@/preferences.js';
import DynamicNote from '@/components/DynamicNote.vue';

View file

@ -5709,6 +5709,7 @@ export type components = {
cacheRemoteSensitiveFiles: boolean;
/** @enum {string} */
allowUnsignedFetch: 'never' | 'always' | 'essential';
localHosts: (string | null)[];
};
MetaDetailed: components['schemas']['MetaLite'] & components['schemas']['MetaDetailedOnly'];
SystemWebhook: {