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. # Final accessible URL seen by a user.
url: http://misskey.local 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 # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT! # 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. # Final accessible URL seen by a user.
url: 'http://misskey.local' 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 # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT! # URL SETTINGS AFTER THAT!

View file

@ -80,6 +80,14 @@
# You can set url from an environment variable instead. # You can set url from an environment variable instead.
url: https://example.tld/ 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 # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT! # URL SETTINGS AFTER THAT!

View file

@ -79,6 +79,14 @@
# Final accessible URL seen by a user. # Final accessible URL seen by a user.
url: https://example.tld/ 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 # ONCE YOU HAVE STARTED THE INSTANCE, DO NOT CHANGE THE
# URL SETTINGS AFTER THAT! # URL SETTINGS AFTER THAT!

View file

@ -27,6 +27,7 @@ type RedisOptionsSource = Partial<RedisOptions> & {
*/ */
type Source = { type Source = {
url?: string; url?: string;
altUrls?: string[];
port?: number; port?: number;
address?: string; address?: string;
socket?: string; socket?: string;
@ -151,7 +152,7 @@ type Source = {
} }
}; };
export type Config = { export type Config = Tenant & {
url: string; url: string;
port: number; port: number;
address: string; address: string;
@ -273,6 +274,8 @@ export type Config = {
maxAge: number; maxAge: number;
}; };
alts: Tenant[];
websocketCompression?: boolean; websocketCompression?: boolean;
customHtml: { 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'; export type FulltextSearchProvider = 'sqlLike' | 'sqlPgroonga' | 'meilisearch' | 'sqlTsvector';
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
@ -345,6 +360,24 @@ export function loadConfig(): Config {
const internalMediaProxy = `${scheme}://${host}/proxy`; const internalMediaProxy = `${scheme}://${host}/proxy`;
const redis = convertRedisOptions(config.redis, host); 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 { return {
version, version,
publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl, publishTarballInsteadOfProvideRepositoryUrl: !!config.publishTarballInsteadOfProvideRepositoryUrl,
@ -427,6 +460,7 @@ export function loadConfig(): Config {
preSave: config.activityLogging?.preSave ?? false, preSave: config.activityLogging?.preSave ?? false,
maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30), maxAge: config.activityLogging?.maxAge ?? (1000 * 60 * 60 * 24 * 30),
}, },
alts,
websocketCompression: config.websocketCompression ?? false, websocketCompression: config.websocketCompression ?? false,
customHtml: { customHtml: {
head: config.customHtml?.head ?? '', head: config.customHtml?.head ?? '',

View file

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

View file

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

View file

@ -875,8 +875,8 @@ export class NoteCreateService implements OnApplicationShutdown {
if (data.localOnly) return null; if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data) 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.renderCreate(await this.apRendererService.renderNote(note, user, false), note); : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note, user);
return this.apRendererService.addContext(content); return this.apRendererService.addContext(content);
} }

View file

@ -5,7 +5,7 @@
import { Brackets, In, IsNull, Not } from 'typeorm'; import { Brackets, In, IsNull, Not } from 'typeorm';
import { Injectable, Inject } from '@nestjs/common'; 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 { MiNote, IMentionedRemoteUsers } from '@/models/Note.js';
import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js'; import type { InstancesRepository, MiMeta, NotesRepository, UsersRepository } from '@/models/_.js';
import { RelayService } from '@/core/RelayService.js'; import { RelayService } from '@/core/RelayService.js';
@ -71,7 +71,7 @@ export class NoteDeleteService {
* @param user 稿 * @param user 稿
* @param note 稿 * @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 deletedAt = new Date();
const cascadingNotes = await this.findCascadingNotes(note); const cascadingNotes = await this.findCascadingNotes(note);
@ -103,7 +103,7 @@ export class NoteDeleteService {
} }
const content = this.apRendererService.addContext(renote 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.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${note.id}`), user));
this.deliverToConcerned(user, note, content); this.deliverToConcerned(user, note, content);

View file

@ -770,7 +770,7 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.localOnly) return null; if (data.localOnly) return null;
const content = this.isRenote(data) && !this.isQuote(data) 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); : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
return this.apRendererService.addContext(content); return this.apRendererService.addContext(content);

View file

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, NoteThreadMutingsRepository, MiMeta } from '@/models/_.js'; import type { EmojisRepository, NoteReactionsRepository, UsersRepository, NotesRepository, NoteThreadMutingsRepository, MiMeta } from '@/models/_.js';
import { IdentifiableError } from '@/misc/identifiable-error.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 type { MiNote } from '@/models/Note.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import type { MiNoteReaction } from '@/models/NoteReaction.js'; import type { MiNoteReaction } from '@/models/NoteReaction.js';
@ -106,7 +106,7 @@ export class ReactionService {
} }
@bindThis @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 // Check blocking
if (note.userId !== user.id) { if (note.userId !== user.id) {
const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id); const blocked = await this.userBlockingService.checkBlocked(note.userId, user.id);
@ -276,7 +276,7 @@ export class ReactionService {
//#region 配信 //#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) { 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); const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) { if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId }); const reactee = await this.usersRepository.findOneBy({ id: note.userId });
@ -337,7 +337,7 @@ export class ReactionService {
//#region 配信 //#region 配信
if (this.userEntityService.isLocalUser(user) && !note.localOnly) { 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); const dm = this.apDeliverManagerService.createDeliverManager(user, content);
if (note.userHost !== null) { if (note.userHost !== null) {
const reactee = await this.usersRepository.findOneBy({ id: note.userId }); const reactee = await this.usersRepository.findOneBy({ id: note.userId });

View file

@ -40,12 +40,14 @@ export class RemoteUserResolveService {
} }
@bindThis @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 usernameLower = username.toLowerCase();
const selfHost = requestHostname ? this.utilityService.getSelfQueryHost(requestHostname) : IsNull();
const tenant = requestHostname ? this.utilityService.getSelfTenant(requestHostname) : this.config;
if (host == null) { if (host == null) {
this.logger.info(`return local user: ${usernameLower}`); 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) { if (u == null) {
throw new Error('user not found'); throw new Error('user not found');
} else { } else {
@ -56,9 +58,9 @@ export class RemoteUserResolveService {
host = this.utilityService.toPuny(host); 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}`); 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) { if (u == null) {
throw new Error('user not found'); throw new Error('user not found');
} else { } else {

View file

@ -73,7 +73,7 @@ export class UserBlockingService implements OnModuleInit {
blockerId: blocker.id, blockerId: blocker.id,
blockee, blockee,
blockeeId: blockee.id, blockeeId: blockee.id,
} as MiBlocking; } satisfies MiBlocking;
await this.blockingsRepository.insert(blocking); await this.blockingsRepository.insert(blocking);
@ -178,7 +178,7 @@ export class UserBlockingService implements OnModuleInit {
// deliver if remote bloking // deliver if remote bloking
if (this.userEntityService.isLocalUser(blocker) && this.userEntityService.isRemoteUser(blockee)) { 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); 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 { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2'; import RE2 from 're2';
import psl from 'psl'; import psl from 'psl';
import { IsNull } from 'typeorm';
import { DI } from '@/di-symbols.js'; 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 { bindThis } from '@/decorators.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
@ -176,4 +177,23 @@ export class UtilityService {
const host = this.extractDbHost(uri); const host = this.extractDbHost(uri);
return this.isFederationAllowedHost(host); 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 }; return { local: false, uri: apId };
} }
// TODO support alt-local
const [, type, id, ...rest] = uri.pathname.split(separator); const [, type, id, ...rest] = uri.pathname.split(separator);
return { return {
local: true, local: true,

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import { SystemAccountService } from '@/core/SystemAccountService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { DEFAULT_POLICIES } from '@/core/RoleService.js'; import { DEFAULT_POLICIES } from '@/core/RoleService.js';
import { UtilityService } from '@/core/UtilityService.js';
@Injectable() @Injectable()
export class MetaEntityService { export class MetaEntityService {
@ -28,10 +29,11 @@ export class MetaEntityService {
private adsRepository: AdsRepository, private adsRepository: AdsRepository,
private systemAccountService: SystemAccountService, private systemAccountService: SystemAccountService,
private readonly utilityService: UtilityService,
) { } ) { }
@bindThis @bindThis
public async pack(meta?: MiMeta): Promise<Packed<'MetaLite'>> { public async pack(meta?: MiMeta, requestHostname?: string): Promise<Packed<'MetaLite'>> {
let instance = meta; let instance = meta;
if (!instance) { if (!instance) {
@ -64,6 +66,8 @@ export class MetaEntityService {
} }
} }
const tenant = requestHostname ? this.utilityService.getSelfTenant(requestHostname) : this.config;
const packed: Packed<'MetaLite'> = { const packed: Packed<'MetaLite'> = {
maintainerName: instance.maintainerName, maintainerName: instance.maintainerName,
maintainerEmail: instance.maintainerEmail, maintainerEmail: instance.maintainerEmail,
@ -73,7 +77,7 @@ export class MetaEntityService {
name: instance.name, name: instance.name,
shortName: instance.shortName, shortName: instance.shortName,
uri: this.config.url, uri: tenant.url,
description: instance.description, description: instance.description,
langs: instance.langs, langs: instance.langs,
tosUrl: instance.termsOfServiceUrl, tosUrl: instance.termsOfServiceUrl,
@ -151,14 +155,14 @@ export class MetaEntityService {
} }
@bindThis @bindThis
public async packDetailed(meta?: MiMeta): Promise<Packed<'MetaDetailed'>> { public async packDetailed(meta?: MiMeta, requestHostname?: string): Promise<Packed<'MetaDetailed'>> {
let instance = meta; let instance = meta;
if (!instance) { if (!instance) {
instance = this.meta; instance = this.meta;
} }
const packed = await this.pack(instance); const packed = await this.pack(instance, requestHostname);
const proxyAccount = await this.systemAccountService.fetch('proxy'); const proxyAccount = await this.systemAccountService.fetch('proxy');
@ -181,6 +185,10 @@ export class MetaEntityService {
miauth: true, miauth: true,
}, },
allowUnsignedFetch: instance.allowUnsignedFetch, allowUnsignedFetch: instance.allowUnsignedFetch,
localHosts: [
null,
...this.config.alts.map(alt => this.utilityService.toPuny(alt.host)),
],
}; };
return packDetailed; 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 type { Promiseable } from '@/misc/prelude/await-all.js';
import { awaitAll } 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 { 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 { import {
birthdaySchema, birthdaySchema,
descriptionSchema, descriptionSchema,
@ -460,9 +460,8 @@ export class UserEntityService implements OnModuleInit {
} }
@bindThis @bindThis
public getUserUri(user: MiLocalUser | MiPartialLocalUser | MiRemoteUser | MiPartialRemoteUser): string { public getUserUri(user: MiLocalUser | MiPartialLocalUser | MiRemoteUser | MiPartialRemoteUser | MiPartialUser | MiUser): string {
return this.isRemoteUser(user) return user.uri ?? this.genLocalUserUri(user.id);
? user.uri : this.genLocalUserUri(user.id);
} }
@bindThis @bindThis

View file

@ -407,6 +407,12 @@ export type MiPartialRemoteUser = Partial<MiUser> & {
uri: string; 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 localUsernameSchema = { type: 'string', pattern: /^\w{1,20}$/.toString().slice(1, -1) } as const;
export const passwordSchema = { type: 'string', minLength: 1 } as const; export const passwordSchema = { type: 'string', minLength: 1 } as const;
export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const; export const nameSchema = { type: 'string', minLength: 1, maxLength: 50 } as const;

View file

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

View file

@ -14,7 +14,7 @@ import accepts from 'accepts';
import vary from 'vary'; import vary from 'vary';
import secureJson from 'secure-json-parse'; import secureJson from 'secure-json-parse';
import { DI } from '@/di-symbols.js'; 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 * as url from '@/misc/prelude/url.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.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> { private async packActivity(note: MiNote, author: MiUser): Promise<ICreate | IAnnounce> {
if (isRenote(note) && !isQuote(note)) { if (isRenote(note) && !isQuote(note)) {
const renote = await this.notesRepository.findOneByOrFail({ id: note.renoteId }); 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 // Auth fetch disabled => accept
const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId); const allowUnsignedFetch = await this.getUnsignedFetchAllowance(userId, request.hostname);
if (allowUnsignedFetch === 'always') { if (allowUnsignedFetch === 'always') {
return { reject: false, redact: false }; return { reject: false, redact: false };
} }
@ -186,7 +186,7 @@ export class ActivityPubServerService {
headers: ['(request-target)', 'host', 'date'], headers: ['(request-target)', 'host', 'date'],
authorizationHeaderName: 'signature', authorizationHeaderName: 'signature',
}); });
} catch (e) { } catch {
// not signed, or malformed signature: refuse // not signed, or malformed signature: refuse
return `${request.id} ${request.url} 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}:`; 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) {
// no destination host, or not us: refuse return `${logPrefix} no destination host: refuse`;
return `${logPrefix} no destination host, or not us: 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)) { if (!this.utilityService.isFederationAllowedHost(keyHost)) {
@ -291,9 +294,14 @@ export class ActivityPubServerService {
return; return;
} }
if (signature.params.headers.indexOf('host') === -1 if (signature.params.headers.indexOf('host') === -1) {
|| request.headers.host !== this.config.host) { // no destination host: refuse
// Host not specified or not match. 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); reply.code(401);
return; return;
} }
@ -373,7 +381,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) { if (user == null) {
@ -470,7 +478,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) { if (user == null) {
@ -556,7 +564,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) { if (user == null) {
@ -626,7 +634,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) { 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)) { if (user.uri == null || this.utilityService.isSelfHost(user.host)) {
reply.code(500); reply.code(500);
return; return;
@ -735,7 +743,7 @@ export class ActivityPubServerService {
this.setResponseType(request, reply); this.setResponseType(request, reply);
const person = redact const person = redact
? await this.apRendererService.renderPersonRedacted(user as MiLocalUser) ? await this.apRendererService.renderPersonRedacted(user)
: await this.apRendererService.renderPerson(user as MiLocalUser); : await this.apRendererService.renderPerson(user as MiLocalUser);
return this.apRendererService.addContext(person); 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)) { if (note.uri == null || this.utilityService.isSelfHost(note.userHost)) {
reply.code(500); reply.code(500);
return; return;
@ -855,7 +863,7 @@ export class ActivityPubServerService {
const note = await this.notesRepository.findOneBy({ const note = await this.notesRepository.findOneBy({
id: request.params.note, id: request.params.note,
userHost: IsNull(), userHost: this.utilityService.getSelfQueryHost(request.hostname),
visibility: In(['public', 'home']), visibility: In(['public', 'home']),
localOnly: false, localOnly: false,
}); });
@ -887,7 +895,7 @@ export class ActivityPubServerService {
.createQueryBuilder('note') .createQueryBuilder('note')
.andWhere({ .andWhere({
id: request.params.note, id: request.params.note,
userHost: IsNull(), userHost: this.utilityService.getSelfQueryHost(request.hostname),
visibility: In(['public', 'home']), visibility: In(['public', 'home']),
localOnly: false, localOnly: false,
}) })
@ -955,7 +963,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: userId, id: userId,
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) { if (user == null) {
@ -1007,7 +1015,7 @@ export class ActivityPubServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
usernameLower: acct.username.toLowerCase(), usernameLower: acct.username.toLowerCase(),
host: acct.host ?? IsNull(), host: acct.host ?? this.utilityService.getSelfQueryHost(request.hostname),
isSuspended: false, isSuspended: false,
}); });
@ -1029,7 +1037,7 @@ export class ActivityPubServerService {
if (reject) return; if (reject) return;
const emoji = await this.emojisRepository.findOneBy({ const emoji = await this.emojisRepository.findOneBy({
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
name: request.params.emoji, name: request.params.emoji,
}); });
@ -1039,7 +1047,7 @@ export class ActivityPubServerService {
} }
this.setResponseType(request, reply); this.setResponseType(request, reply);
return (this.apRendererService.addContext(await this.apRendererService.renderEmoji(emoji))); return (this.apRendererService.addContext(this.apRendererService.renderEmoji(emoji)));
}); });
// like // like
@ -1049,7 +1057,7 @@ export class ActivityPubServerService {
return; 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); const { reject } = await this.checkAuthorizedFetch(request, reply, reaction?.userId);
if (reject) return; if (reject) return;
@ -1059,6 +1067,11 @@ export class ActivityPubServerService {
return; return;
} }
if (reaction.user.host !== this.utilityService.getSelfDbHost(request.hostname)) {
reply.code(404);
return;
}
const note = await this.notesRepository.findOneBy({ id: reaction.noteId }); const note = await this.notesRepository.findOneBy({ id: reaction.noteId });
if (note == null) { if (note == null) {
@ -1083,14 +1096,15 @@ export class ActivityPubServerService {
// This may be used before the follow is completed, so we do not // This may be used before the follow is completed, so we do not
// check if the following exists. // check if the following exists.
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
const [follower, followee] = await Promise.all([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({ this.usersRepository.findOneBy({
id: request.params.follower, id: request.params.follower,
host: IsNull(), host: queryHost,
}), }),
this.usersRepository.findOneBy({ this.usersRepository.findOneBy({
id: request.params.followee, id: request.params.followee,
host: Not(IsNull()), host: Not(queryHost),
}), }),
]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null];
@ -1125,14 +1139,15 @@ export class ActivityPubServerService {
return; return;
} }
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
const [follower, followee] = await Promise.all([ const [follower, followee] = await Promise.all([
this.usersRepository.findOneBy({ this.usersRepository.findOneBy({
id: followRequest.followerId, id: followRequest.followerId,
host: IsNull(), host: queryHost,
}), }),
this.usersRepository.findOneBy({ this.usersRepository.findOneBy({
id: followRequest.followeeId, id: followRequest.followeeId,
host: Not(IsNull()), host: Not(queryHost),
}), }),
]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null]; ]) as [MiLocalUser | MiRemoteUser | null, MiLocalUser | MiRemoteUser | null];
@ -1148,11 +1163,15 @@ export class ActivityPubServerService {
done(); done();
} }
private async getUnsignedFetchAllowance(userId: string | undefined) { private async getUnsignedFetchAllowance(userId: string | undefined, hostname: string) {
const user = userId ? await this.cacheService.findLocalUserById(userId) : null; 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 && user.host !== this.utilityService.getSelfDbHost(hostname)) {
if (!user?.allowUnsignedFetch || user.allowUnsignedFetch === 'staff') { 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; 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 * as Acct from '@/misc/acct.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { UtilityService } from '@/core/UtilityService.js';
import { NodeinfoServerService } from './NodeinfoServerService.js'; import { NodeinfoServerService } from './NodeinfoServerService.js';
import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js'; import { OAuth2ProviderService } from './oauth/OAuth2ProviderService.js';
import type { FindOptionsWhere } from 'typeorm'; import type { FindOptionsWhere } from 'typeorm';
@ -35,6 +36,7 @@ export class WellKnownServerService {
private nodeinfoServerService: NodeinfoServerService, private nodeinfoServerService: NodeinfoServerService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private oauth2ProviderService: OAuth2ProviderService, private oauth2ProviderService: OAuth2ProviderService,
private readonly utilityService: UtilityService,
) { ) {
//this.createServer = this.createServer.bind(this); //this.createServer = this.createServer.bind(this);
} }
@ -74,11 +76,13 @@ export class WellKnownServerService {
return; return;
} }
const tenant = this.utilityService.getSelfTenant(request.hostname);
reply.header('Content-Type', xrd); reply.header('Content-Type', xrd);
return XRD({ element: 'Link', attributes: { return XRD({ element: 'Link', attributes: {
rel: 'lrdd', rel: 'lrdd',
type: xrd, type: xrd,
template: `${this.config.url}${webFingerPath}?resource={uri}`, template: `${tenant.url}${webFingerPath}?resource={uri}`,
} }); } });
}); });
@ -88,12 +92,14 @@ export class WellKnownServerService {
return; return;
} }
const tenant = this.utilityService.getSelfTenant(request.hostname);
reply.header('Content-Type', 'application/json'); reply.header('Content-Type', 'application/json');
return { return {
links: [{ links: [{
rel: 'lrdd', rel: 'lrdd',
type: jrd, 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; return;
} }
const tenant = this.utilityService.getSelfTenant(request.hostname);
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
const fromId = (id: MiUser['id']): FindOptionsWhere<MiUser> => ({ const fromId = (id: MiUser['id']): FindOptionsWhere<MiUser> => ({
id, id,
host: IsNull(), host: queryHost,
isSuspended: false, isSuspended: false,
}); });
const generateQuery = (resource: string): FindOptionsWhere<MiUser> | number => const generateQuery = (resource: string): FindOptionsWhere<MiUser> | number =>
resource.startsWith(`${this.config.url.toLowerCase()}/users/`) ? resource.startsWith(`${tenant.url.toLowerCase()}/users/`) ?
fromId(resource.split('/').pop()!) : fromId(resource.split('/').pop()!) :
fromAcct(Acct.parse( 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.startsWith('acct:') ? resource.slice('acct:'.length) :
resource)); resource));
const fromAcct = (acct: Acct.Acct): FindOptionsWhere<MiUser> | number => 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(), usernameLower: acct.username.toLowerCase(),
host: IsNull(), host: queryHost,
isSuspended: false, isSuspended: false,
} : 422; } : 422;
@ -162,12 +171,12 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
return; return;
} }
const subject = `acct:${user.username}@${this.config.host}`; const subject = `acct:${user.username}@${tenant.host}`;
const profileLink = `${this.config.url}/@${user.username}`; const profileLink = `${tenant.url}/@${user.username}`;
const self = { const self = {
rel: 'self', rel: 'self',
type: 'application/activity+json', type: 'application/activity+json',
href: this.userEntityService.genLocalUserUri(user.id), href: this.userEntityService.getUserUri(user),
}; };
const profilePage = { const profilePage = {
rel: 'http://webfinger.net/rel/profile-page', rel: 'http://webfinger.net/rel/profile-page',
@ -176,7 +185,7 @@ fastify.get('/.well-known/change-password', async (request, reply) => {
}; };
const subscribe = { const subscribe = {
rel: 'http://ostatus.org/schema/1.0/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'); 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 type { Config } from '@/config.js';
import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js'; import { sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js'; import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
import type { PublicExecutor } from '@/server/api/endpoint-base.js';
import { ApiError } from './error.js'; import { ApiError } from './error.js';
import { ApiLoggerService } from './ApiLoggerService.js'; import { ApiLoggerService } from './ApiLoggerService.js';
import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js';
@ -145,7 +146,7 @@ export class ApiCallService implements OnApplicationShutdown {
@bindThis @bindThis
public handleRequest( public handleRequest(
endpoint: IEndpoint & { exec: any }, endpoint: IEndpoint & { exec: PublicExecutor },
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
reply: FastifyReply, reply: FastifyReply,
): void { ): void {
@ -181,7 +182,7 @@ export class ApiCallService implements OnApplicationShutdown {
@bindThis @bindThis
public async handleMultipartRequest( public async handleMultipartRequest(
endpoint: IEndpoint & { exec: any }, endpoint: IEndpoint & { exec: PublicExecutor },
request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown>, Querystring: Record<string, unknown> }>,
reply: FastifyReply, reply: FastifyReply,
): Promise<void> { ): Promise<void> {
@ -274,9 +275,9 @@ export class ApiCallService implements OnApplicationShutdown {
@bindThis @bindThis
private async call( private async call(
ep: IEndpoint & { exec: any }, ep: IEndpoint & { exec: PublicExecutor },
user: MiLocalUser | null | undefined, user: MiLocalUser | null,
token: MiAccessToken | null | undefined, token: MiAccessToken | null,
data: any, data: any,
multipartFile: MultipartFile | null, multipartFile: MultipartFile | null,
request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>, request: FastifyRequest<{ Body: Record<string, unknown> | undefined, Querystring: Record<string, unknown> }>,
@ -430,17 +431,23 @@ export class ApiCallService implements OnApplicationShutdown {
cleanup = result.cleanup; cleanup = result.cleanup;
} }
const requestData = {
ip: request.ip,
headers: request.headers,
hostname: request.hostname,
};
// API invoking // API invoking
if (this.config.sentryForBackend) { if (this.config.sentryForBackend) {
return await Sentry.startSpan({ return await Sentry.startSpan({
name: 'API: ' + ep.name, 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)) .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup()); .finally(() => cleanup());
}); });
} else { } 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)) .catch((err: Error) => this.#onExecError(ep, data, err, user?.id))
.finally(() => cleanup()); .finally(() => cleanup());
} }

View file

@ -19,6 +19,7 @@ import { SignupApiService } from './SignupApiService.js';
import { SigninApiService } from './SigninApiService.js'; import { SigninApiService } from './SigninApiService.js';
import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js'; import { SigninWithPasskeyApiService } from './SigninWithPasskeyApiService.js';
import type { FastifyInstance, FastifyPluginOptions } from 'fastify'; import type { FastifyInstance, FastifyPluginOptions } from 'fastify';
import type { PublicExecutor } from './endpoint-base.js';
@Injectable() @Injectable()
export class ApiServerService { export class ApiServerService {
@ -67,7 +68,7 @@ export class ApiServerService {
name: endpoint.name, name: endpoint.name,
meta: endpoint.meta, meta: endpoint.meta,
params: endpoint.params, 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) { if (endpoint.meta.requireFile) {

View file

@ -26,18 +26,40 @@ export type AttachmentFile = {
path: string; path: string;
}; };
interface Request {
ip: string | null;
headers: {
[Name in string]?: string | string[]
};
hostname: string;
}
// TODO: paramsの型をT['params']のスキーマ定義から推論する // TODO: paramsの型をT['params']のスキーマ定義から推論する
type Executor<T extends IEndpointMeta, Ps extends Schema> = 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> { 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>) { constructor(meta: T, paramDef: Ps, cb: Executor<T, Ps>) {
const validate = ajv.compile(paramDef); 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; let cleanup: undefined | (() => void) = undefined;
if (meta.requireFile) { if (meta.requireFile) {
@ -54,21 +76,21 @@ export abstract class Endpoint<T extends IEndpointMeta, Ps extends Schema> {
const valid = validate(params); const valid = validate(params);
if (!valid) { if (!valid) {
if (file) cleanup!(); if (file && cleanup) cleanup();
const errors = validate.errors!; const errors = validate.errors;
const err = new ApiError({ const err = new ApiError({
message: 'Invalid param.', message: 'Invalid param.',
code: 'INVALID_PARAM', code: 'INVALID_PARAM',
id: '3d81ceae-475f-4600-b2a8-2bc116157532', id: '3d81ceae-475f-4600-b2a8-2bc116157532',
}, { }, errors?.length ? {
param: errors[0].schemaPath, param: errors[0].schemaPath,
reason: errors[0].message, reason: errors[0].message,
}); } : undefined);
return Promise.reject(err); 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 driveFileEntityService: DriveFileEntityService,
private driveService: DriveService, 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 // Get 'name' parameter
let name = ps.name ?? file!.name ?? null; let name = ps.name ?? file!.name ?? null;
if (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 driveService: DriveService,
private globalEventService: GlobalEventService, 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) { if (ps.comment && ps.comment.length > this.config.maxAltTextLength) {
throw new ApiError(meta.errors.commentTooLong); 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 apPersonService: ApPersonService,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me, _at, _file, _cleanup, { hostname }) => {
// check parameter // check parameter
if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser); if (!ps.moveToAccount) throw new ApiError(meta.errors.noSuchUser);
// abort if user is the root // 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 // parse user's input into the destination account
const { username, host } = Acct.parse(ps.moveToAccount); const { username, host } = Acct.parse(ps.moveToAccount);
// retrieve the destination account // 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}`); this.apiLoggerService.logger.warn(`failed to resolve remote user: ${e}`);
throw new ApiError(meta.errors.noSuchUser); 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 avatarDecorationService: AvatarDecorationService,
private utilityService: UtilityService, 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 user = await this.usersRepository.findOneByOrFail({ id: _user.id }) as MiLocalUser;
const isSecure = token == null; const isSecure = token == null;
@ -501,7 +501,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
const { username, host } = Acct.parse(line); const { username, host } = Acct.parse(line);
// Retrieve the old account // 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}`); this.apiLoggerService.logger.warn(`failed to resolve dstination user: ${e}`);
throw new ApiError(meta.errors.noSuchUser); throw new ApiError(meta.errors.noSuchUser);
}); });

View file

@ -40,8 +40,10 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
private metaEntityService: MetaEntityService, private metaEntityService: MetaEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, _me, _at, _file, _cleanup, { hostname }) => {
return ps.detail ? await this.metaEntityService.packDetailed() : await this.metaEntityService.pack(); 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 perUserPvChart: PerUserPvChart,
private apiLoggerService: ApiLoggerService, 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; let user;
const isModerator = await this.roleService.isModerator(me); const isModerator = await this.roleService.isModerator(me);
@ -130,7 +130,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} else { } else {
// Lookup user // Lookup user
if (typeof ps.host === 'string' && typeof ps.username === 'string') { 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}`); this.apiLoggerService.logger.warn(`failed to resolve remote user: ${err}`);
throw new ApiError(meta.errors.failedToResolveRemoteUser); 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 { RoleService } from '@/core/RoleService.js';
import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js'; import { ReversiGameEntityService } from '@/core/entities/ReversiGameEntityService.js';
import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js'; import { AnnouncementEntityService } from '@/core/entities/AnnouncementEntityService.js';
import { UtilityService } from '@/core/UtilityService.js';
import { FeedService } from './FeedService.js'; import { FeedService } from './FeedService.js';
import { UrlPreviewService } from './UrlPreviewService.js'; import { UrlPreviewService } from './UrlPreviewService.js';
import { ClientLoggerService } from './ClientLoggerService.js'; import { ClientLoggerService } from './ClientLoggerService.js';
@ -130,6 +131,7 @@ export class ClientServerService {
private feedService: FeedService, private feedService: FeedService,
private roleService: RoleService, private roleService: RoleService,
private clientLoggerService: ClientLoggerService, private clientLoggerService: ClientLoggerService,
private readonly utilityService: UtilityService,
@Inject('queue:system') public systemQueue: SystemQueue, @Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue, @Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@ -146,14 +148,14 @@ export class ClientServerService {
} }
@bindThis @bindThis
private async manifestHandler(reply: FastifyReply) { private async manifestHandler(reply: FastifyReply, hostname: string) {
let manifest = { let manifest = {
// 空文字列の場合右辺を使いたいため // 空文字列の場合右辺を使いたいため
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing // 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 // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
'name': this.meta.name || this.config.host, 'name': this.meta.name || hostname,
'start_url': '/', 'start_url': '/',
'display': 'standalone', 'display': 'standalone',
'background_color': '#313a42', 'background_color': '#313a42',
@ -415,7 +417,7 @@ export class ClientServerService {
}); });
// Manifest // 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 // Embed Javascript
fastify.get('/embed.js', async (request, reply) => { fastify.get('/embed.js', async (request, reply) => {
@ -467,11 +469,11 @@ export class ClientServerService {
// URL preview endpoint // URL preview endpoint
fastify.get<{ Querystring: { url: string; lang: string; } }>('/url', (request, reply) => this.urlPreviewService.handle(request, reply)); 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 { username, host } = Acct.parse(acct);
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: host ?? IsNull(), host: host ?? this.utilityService.getSelfQueryHost(hostname),
isSuspended: false, isSuspended: false,
enableRss: true, enableRss: true,
requireSigninToViewContents: false, requireSigninToViewContents: false,
@ -484,7 +486,7 @@ export class ClientServerService {
fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => { fastify.get<{ Params: { user?: string; } }>('/@:user.atom', async (request, reply) => {
if (request.params.user == null) return await renderBase(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) { if (feed) {
reply.header('Content-Type', 'application/atom+xml; charset=utf-8'); 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) => { fastify.get<{ Params: { user?: string; } }>('/@:user.rss', async (request, reply) => {
if (request.params.user == null) return await renderBase(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) { if (feed) {
reply.header('Content-Type', 'application/rss+xml; charset=utf-8'); 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) => { fastify.get<{ Params: { user?: string; } }>('/@:user.json', async (request, reply) => {
if (request.params.user == null) return await renderBase(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) { if (feed) {
reply.header('Content-Type', 'application/json; charset=utf-8'); 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 { username, host } = Acct.parse(request.params.user);
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: host ?? IsNull(), host: host ?? this.utilityService.getSelfQueryHost(request.hostname),
isSuspended: false, isSuspended: false,
}); });
@ -575,7 +577,7 @@ export class ClientServerService {
fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => { fastify.get<{ Params: { user: string; } }>('/users/:user', async (request, reply) => {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: request.params.user, id: request.params.user,
host: IsNull(), host: this.utilityService.getSelfQueryHost(request.hostname),
isSuspended: false, isSuspended: false,
}); });
@ -586,7 +588,8 @@ export class ClientServerService {
vary(reply.raw, 'Accept'); 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 // Note
@ -630,7 +633,7 @@ export class ClientServerService {
const { username, host } = Acct.parse(request.params.user); const { username, host } = Acct.parse(request.params.user);
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
usernameLower: username.toLowerCase(), usernameLower: username.toLowerCase(),
host: host ?? IsNull(), host: host ?? this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) return; if (user == null) return;
@ -813,10 +816,10 @@ export class ClientServerService {
const user = await this.usersRepository.findOneBy({ const user = await this.usersRepository.findOneBy({
id: request.params.user, id: request.params.user,
host: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (user == null) return; if (user == null) return;
if (user.host != null) return;
const _user = await this.userEntityService.pack(user); const _user = await this.userEntityService.pack(user);
@ -835,11 +838,11 @@ export class ClientServerService {
const note = await this.notesRepository.findOneBy({ const note = await this.notesRepository.findOneBy({
id: request.params.note, id: request.params.note,
userHost: this.utilityService.getSelfQueryHost(request.hostname),
}); });
if (note == null) return; if (note == null) return;
if (['specified', 'followers'].includes(note.visibility)) return; if (['specified', 'followers'].includes(note.visibility)) return;
if (note.userHost != null) return;
const _note = await this.noteEntityService.pack(note, null, { detail: true }); const _note = await this.noteEntityService.pack(note, null, { detail: true });
@ -887,12 +890,13 @@ export class ClientServerService {
fastify.get('/_info_card_', async (request, reply) => { fastify.get('/_info_card_', async (request, reply) => {
reply.removeHeader('X-Frame-Options'); reply.removeHeader('X-Frame-Options');
const queryHost = this.utilityService.getSelfQueryHost(request.hostname);
return await reply.view('info-card', { return await reply.view('info-card', {
version: this.config.version, version: this.config.version,
host: this.config.host, host: request.hostname,
meta: this.meta, meta: this.meta,
originalUsersCount: await this.usersRepository.countBy({ host: IsNull() }), originalUsersCount: await this.usersRepository.countBy({ host: queryHost }),
originalNotesCount: await this.notesRepository.countBy({ userHost: IsNull() }), originalNotesCount: await this.notesRepository.countBy({ userHost: queryHost }),
}); });
}); });
//#endregion //#endregion

View file

@ -10,7 +10,7 @@ import { Test } from '@nestjs/testing';
import { jest } from '@jest/globals'; import { jest } from '@jest/globals';
import type { Config } from '@/config.js'; 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 { ApImageService } from '@/core/activitypub/models/ApImageService.js';
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
@ -276,7 +276,11 @@ describe('ActivityPub', () => {
rendererService.renderAnnounce('https://example.com/notes/00example', { rendererService.renderAnnounce('https://example.com/notes/00example', {
id: genAidx(Date.now()), id: genAidx(Date.now()),
visibility: 'followers', 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> <MkButton rounded :class="$style.loadButton" @click="showNext = 'user'"><i class="ti ti-chevron-up"></i> <i class="ti ti-user"></i></MkButton>
</div> </div>
<div class="_margin _gaps_s"> <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"/> <SkErrorList :errors="note.processErrors"/>
<DynamicNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/> <DynamicNoteDetailed :key="note.id" v-model:note="note" :initialTab="initialTab" :class="$style.note" :expandAllCws="expandAllCws"/>
</div> </div>
@ -67,6 +67,7 @@ import { pleaseLogin } from '@/utility/please-login.js';
import { getAppearNote } from '@/utility/get-appear-note.js'; import { getAppearNote } from '@/utility/get-appear-note.js';
import { serverContext, assertServerContext } from '@/server-context.js'; import { serverContext, assertServerContext } from '@/server-context.js';
import { $i } from '@/i.js'; import { $i } from '@/i.js';
import { instance } from '@/instance.js';
// context // context
const CTX_NOTE = !$i && assertServerContext(serverContext, 'note') ? serverContext.note : null; 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"> <div class="profile _gaps">
<MkAccountMoved v-if="user.movedTo" :movedTo="user.movedTo"/> <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> <MkInfo v-if="user.host == null && user.username.includes('.')">{{ i18n.ts.isSystemAccount }}</MkInfo>
<div :key="user.id" class="main _panel"> <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 { isFollowingVisibleForMe, isFollowersVisibleForMe } from '@/utility/isFfVisibleForMe.js';
import { useRouter } from '@/router.js'; import { useRouter } from '@/router.js';
import { getStaticImageUrl } from '@/utility/media-proxy.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 MkSparkle from '@/components/MkSparkle.vue';
import { prefer } from '@/preferences.js'; import { prefer } from '@/preferences.js';
import DynamicNote from '@/components/DynamicNote.vue'; import DynamicNote from '@/components/DynamicNote.vue';

View file

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