merge: Fix error when viewing a deleted remote user (!1242)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1242 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
58751020c8
6 changed files with 188 additions and 150 deletions
|
|
@ -17,12 +17,18 @@ import { RemoteLoggerService } from '@/core/RemoteLoggerService.js';
|
|||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||
import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js';
|
||||
import { TimeService } from '@/global/TimeService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { InternalEventService } from '@/global/InternalEventService.js';
|
||||
import * as Acct from '@/misc/acct.js';
|
||||
import { isRemoteUser } from '@/models/User.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
|
||||
@Injectable()
|
||||
export class RemoteUserResolveService {
|
||||
private logger: Logger;
|
||||
private readonly selfHost: string;
|
||||
|
||||
constructor(
|
||||
@Inject(DI.config)
|
||||
|
|
@ -36,88 +42,112 @@ export class RemoteUserResolveService {
|
|||
private remoteLoggerService: RemoteLoggerService,
|
||||
private apDbResolverService: ApDbResolverService,
|
||||
private apPersonService: ApPersonService,
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly internalEventService: InternalEventService,
|
||||
private readonly timeService: TimeService,
|
||||
) {
|
||||
this.logger = this.remoteLoggerService.logger.createSubLogger('resolve-user');
|
||||
this.selfHost = this.utilityService.toPuny(this.config.host);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async resolveUser(username: string, host: string | null): Promise<MiLocalUser | MiRemoteUser> {
|
||||
const usernameLower = username.toLowerCase();
|
||||
// Normalize inputs
|
||||
username = username.toLowerCase();
|
||||
host = host ? this.utilityService.toPuny(host) : null; // unicode -> punycode
|
||||
host = host !== this.selfHost ? host : null; // self-host -> null
|
||||
const acct = Acct.toString({ username, host }); // username+host -> acct (handle)
|
||||
|
||||
if (host == null) {
|
||||
return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser;
|
||||
// Try fetch from DB
|
||||
let user = await this.cacheService.findUserByAcct(acct).catch(() => null); // Error is expected if the user doesn't exist yet
|
||||
|
||||
// Opportunistically update remote users
|
||||
if (user != null && isRemoteUser(user)) {
|
||||
user = await this.tryUpdateUser(user, acct);
|
||||
}
|
||||
|
||||
host = this.utilityService.toPuny(host);
|
||||
|
||||
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||
return await this.usersRepository.findOneByOrFail({ usernameLower, host: IsNull() }) as MiLocalUser;
|
||||
// Try resolve from AP
|
||||
if (user == null && host != null) {
|
||||
user = await this.tryCreateUser(acct);
|
||||
}
|
||||
|
||||
const user = await this.usersRepository.findOneBy({ usernameLower, host }) as MiRemoteUser | null;
|
||||
|
||||
const acctLower = `${usernameLower}@${host}`;
|
||||
|
||||
// Failed to fetch or resolve
|
||||
if (user == null) {
|
||||
const self = await this.resolveSelf(acctLower);
|
||||
throw new IdentifiableError('15348ddd-432d-49c2-8a5a-8069753becff', `Could not resolve user ${acct}`);
|
||||
}
|
||||
|
||||
return user as MiLocalUser | MiRemoteUser;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async tryCreateUser(acct: string): Promise<MiRemoteUser | null> {
|
||||
try {
|
||||
const self = await this.resolveSelf(acct);
|
||||
|
||||
if (this.utilityService.isUriLocal(self.href)) {
|
||||
const local = this.apDbResolverService.parseUri(self.href);
|
||||
if (local.local && local.type === 'users') {
|
||||
// the LR points to local
|
||||
return (await this.apDbResolverService
|
||||
.getUserFromApId(self.href)
|
||||
.then((u) => {
|
||||
if (u == null) {
|
||||
throw new Error(`local user not found: ${self.href}`);
|
||||
} else {
|
||||
return u;
|
||||
}
|
||||
})) as MiLocalUser;
|
||||
}
|
||||
this.logger.warn(`Ignoring WebFinger response for ${chalk.magenta(acct)}: remote URI points to a local user.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
this.logger.info(`Fetching new remote user ${chalk.magenta(acctLower)} from ${self.href}`);
|
||||
this.logger.info(`Fetching new remote user ${chalk.magenta(acct)} from ${self.href}`);
|
||||
return await this.apPersonService.createPerson(self.href);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to resolve user ${acct}: ${renderInlineError(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async tryUpdateUser(user: MiRemoteUser, acctLower: string): Promise<MiRemoteUser> {
|
||||
// Don't update unless the user is at least 24 hours outdated.
|
||||
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
|
||||
if (user.lastFetchedAt != null && this.timeService.now - user.lastFetchedAt.getTime() <= 1000 * 60 * 60 * 24) {
|
||||
return user;
|
||||
}
|
||||
|
||||
// ユーザー情報が古い場合は、WebFingerからやりなおして返す
|
||||
if (user.lastFetchedAt == null || this.timeService.now - user.lastFetchedAt.getTime() > 1000 * 60 * 60 * 24) {
|
||||
try {
|
||||
// Resolve via webfinger
|
||||
const self = await this.resolveSelf(acctLower);
|
||||
|
||||
// Update the user
|
||||
await this.tryUpdateUri(user, acctLower, self.href);
|
||||
await this.apPersonService.updatePerson(self.href);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Could not update user ${acctLower}; will continue with outdated local copy: ${renderInlineError(err)}`);
|
||||
} finally {
|
||||
// Always mark as updated so we don't get stuck here for missing remote users.
|
||||
// 繋がらないインスタンスに何回も試行するのを防ぐ, 後続の同様処理の連続試行を防ぐ ため 試行前にも更新する
|
||||
await this.usersRepository.update(user.id, {
|
||||
lastFetchedAt: this.timeService.date,
|
||||
});
|
||||
|
||||
const self = await this.resolveSelf(acctLower);
|
||||
|
||||
if (user.uri !== self.href) {
|
||||
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
|
||||
this.logger.warn(`Detected URI mismatch for ${acctLower}`);
|
||||
|
||||
// validate uri
|
||||
const uriHost = this.utilityService.extractDbHost(self.href);
|
||||
if (uriHost !== host) {
|
||||
throw new Error(`Failed to correct URI for ${acctLower}: new URI ${self.href} has different host from previous URI ${user.uri}`);
|
||||
}
|
||||
|
||||
await this.usersRepository.update({
|
||||
usernameLower,
|
||||
host: host,
|
||||
}, {
|
||||
uri: self.href,
|
||||
});
|
||||
await this.apPersonService.uriPersonCache.delete(user.uri); // Unmap the old URI
|
||||
}
|
||||
|
||||
this.logger.info(`Corrected URI for ${acctLower} from ${user.uri} to ${self.href}`);
|
||||
|
||||
await this.apPersonService.updatePerson(self.href);
|
||||
|
||||
return await this.usersRepository.findOneByOrFail({ uri: self.href }) as MiLocalUser | MiRemoteUser;
|
||||
}
|
||||
|
||||
return user;
|
||||
// Reload user
|
||||
return await this.cacheService.findRemoteUserById(user.id);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async tryUpdateUri(user: MiRemoteUser, acct: string, href: string): Promise<void> {
|
||||
// Only update if there's actually a mismatch
|
||||
if (user.uri === href) {
|
||||
return;
|
||||
}
|
||||
|
||||
// if uri mismatch, Fix (user@host <=> AP's Person id(RemoteUser.uri)) mapping.
|
||||
this.logger.warn(`Detected URI mismatch for ${acct}`);
|
||||
|
||||
// validate uri
|
||||
const uriHost = this.utilityService.extractDbHost(href);
|
||||
if (uriHost !== user.host) {
|
||||
throw new Error(`Failed to correct URI for ${acct}: new URI ${href} has different host from previous URI ${user.uri}`);
|
||||
}
|
||||
|
||||
// Update URI
|
||||
await this.usersRepository.update({ id: user.id }, { uri: href }); // Update the user
|
||||
await this.apPersonService.uriPersonCache.delete(user.uri); // Unmap the old URI
|
||||
await this.internalEventService.emit('remoteUserUpdated', { id: user.id }); // Update caches
|
||||
|
||||
this.logger.info(`Corrected URI for ${acct} from ${user.uri} to ${href}`);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
|||
|
|
@ -15,6 +15,23 @@ import type { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
|
|||
import type { MiInstance } from '@/models/Instance.js';
|
||||
import { IdentifiableError } from '@/misc/identifiable-error.js';
|
||||
import { EnvService } from '@/global/EnvService.js';
|
||||
import { getApId, type IObject } from '@/core/activitypub/type.js';
|
||||
|
||||
export type UriParseResult = {
|
||||
/** wether the URI was generated by us */
|
||||
local: true;
|
||||
/** id in DB */
|
||||
id: string;
|
||||
/** hint of type, e.g. "notes", "users" */
|
||||
type: string;
|
||||
/** any remaining text after type and id, not including the slash after id. undefined if empty */
|
||||
rest?: string;
|
||||
} | {
|
||||
/** wether the URI was generated by us */
|
||||
local: false;
|
||||
/** uri in DB */
|
||||
uri: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class UtilityService {
|
||||
|
|
@ -302,4 +319,24 @@ export class UtilityService {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Moved from ApPersonService to avoid circular dependency
|
||||
@bindThis
|
||||
public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
|
||||
const separator = '/';
|
||||
|
||||
const apId = getApId(value);
|
||||
const uri = new URL(apId);
|
||||
if (this.toPuny(uri.host) !== this.toPuny(this.config.host)) {
|
||||
return { local: false, uri: apId };
|
||||
}
|
||||
|
||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||
return {
|
||||
local: true,
|
||||
type,
|
||||
id,
|
||||
rest: rest.length === 0 ? undefined : rest.join(separator),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,21 +19,7 @@ import { getApId } from './type.js';
|
|||
import { ApPersonService } from './models/ApPersonService.js';
|
||||
import type { IObject } from './type.js';
|
||||
|
||||
export type UriParseResult = {
|
||||
/** wether the URI was generated by us */
|
||||
local: true;
|
||||
/** id in DB */
|
||||
id: string;
|
||||
/** hint of type, e.g. "notes", "users" */
|
||||
type: string;
|
||||
/** any remaining text after type and id, not including the slash after id. undefined if empty */
|
||||
rest?: string;
|
||||
} | {
|
||||
/** wether the URI was generated by us */
|
||||
local: false;
|
||||
/** uri in DB */
|
||||
uri: string;
|
||||
};
|
||||
export type { UriParseResult } from '@/core/UtilityService.js';
|
||||
|
||||
@Injectable()
|
||||
export class ApDbResolverService implements OnApplicationShutdown {
|
||||
|
|
@ -58,23 +44,10 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
// Caches moved to ApPersonService to avoid circular dependency
|
||||
}
|
||||
|
||||
// Moved to UtilityService to avoid circular dependency
|
||||
@bindThis
|
||||
public parseUri(value: string | IObject | [string | IObject]): UriParseResult {
|
||||
const separator = '/';
|
||||
|
||||
const apId = getApId(value);
|
||||
const uri = new URL(apId);
|
||||
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
|
||||
return { local: false, uri: apId };
|
||||
}
|
||||
|
||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||
return {
|
||||
local: true,
|
||||
type,
|
||||
id,
|
||||
rest: rest.length === 0 ? undefined : rest.join(separator),
|
||||
};
|
||||
public parseUri(value: string | IObject | [string | IObject]) {
|
||||
return this.utilityService.parseUri(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -102,31 +75,8 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||
*/
|
||||
@bindThis
|
||||
public async getUserFromApId(value: string | IObject | [string | IObject]): Promise<MiLocalUser | MiRemoteUser | null> {
|
||||
const parsed = this.parseUri(value);
|
||||
|
||||
if (parsed.local) {
|
||||
if (parsed.type !== 'users') return null;
|
||||
|
||||
const u = await this.cacheService.findOptionalUserById(parsed.id);
|
||||
|
||||
if (u == null || u.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return u as MiLocalUser | MiRemoteUser;
|
||||
} else {
|
||||
const uid = await this.apPersonService.uriPersonCache.fetchMaybe(parsed.uri);
|
||||
if (uid == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const u = await this.cacheService.findOptionalUserById(uid);
|
||||
if (u == null || u.isDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return u as MiLocalUser | MiRemoteUser;
|
||||
}
|
||||
const uri = getApId(value);
|
||||
return await this.apPersonService.fetchPerson(uri);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -295,28 +295,38 @@ export class ApPersonService implements OnModuleInit {
|
|||
* Misskeyに対象のPersonが登録されていればそれを返し、登録がなければnullを返します。
|
||||
*/
|
||||
@bindThis
|
||||
public async fetchPerson(uri: string): Promise<MiLocalUser | MiRemoteUser | null> {
|
||||
const cached = await this.uriPersonCache.fetchMaybe(uri);
|
||||
if (cached) return await this.cacheService.findOptionalUserById(cached) as MiRemoteUser | MiLocalUser | undefined ?? null;
|
||||
public async fetchPerson(uri: string, opts?: { withDeleted?: boolean, withSuspended?: boolean }): Promise<MiLocalUser | MiRemoteUser | null> {
|
||||
const _opts = {
|
||||
withDeleted: opts?.withDeleted ?? false,
|
||||
withSuspended: opts?.withSuspended ?? true,
|
||||
};
|
||||
|
||||
// URIがこのサーバーを指しているならデータベースからフェッチ
|
||||
if (uri.startsWith(`${this.config.url}/`)) {
|
||||
const id = uri.split('/').pop();
|
||||
const u = await this.usersRepository.findOneBy({ id }) as MiLocalUser | null;
|
||||
if (u) await this.uriPersonCache.set(uri, u.id);
|
||||
return u;
|
||||
let userId;
|
||||
|
||||
// Resolve URI -> User ID
|
||||
const parsed = this.utilityService.parseUri(uri);
|
||||
if (parsed.local) {
|
||||
userId = parsed.type === 'users' ? parsed.id : null;
|
||||
} else {
|
||||
userId = await this.uriPersonCache.fetch(uri).catch(() => null);
|
||||
}
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.usersRepository.findOneBy({ uri }) as MiLocalUser | MiRemoteUser | null;
|
||||
|
||||
if (exist) {
|
||||
await this.uriPersonCache.set(uri, exist.id);
|
||||
return exist;
|
||||
// No match
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return null;
|
||||
const user = await this.cacheService.findUserById(userId)
|
||||
.catch(() => null) as MiLocalUser | MiRemoteUser | null;
|
||||
|
||||
if (user?.isDeleted && !_opts.withDeleted) {
|
||||
return null;
|
||||
}
|
||||
if (user?.isSuspended && !_opts.withSuspended) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async resolveAvatarAndBanner(user: MiRemoteUser, icon: any, image: any, bgimg: any): Promise<Partial<Pick<MiRemoteUser, 'avatarId' | 'bannerId' | 'backgroundId' | 'avatarUrl' | 'bannerUrl' | 'backgroundUrl' | 'avatarBlurhash' | 'bannerBlurhash' | 'backgroundBlurhash'>>> {
|
||||
|
|
@ -853,7 +863,7 @@ export class ApPersonService implements OnModuleInit {
|
|||
}
|
||||
|
||||
//#region このサーバーに既に登録されていたらそれを返す
|
||||
const exist = await this.fetchPerson(uri);
|
||||
const exist = await this.fetchPerson(uri, { withDeleted: true });
|
||||
if (exist) return exist;
|
||||
//#endregion
|
||||
|
||||
|
|
|
|||
|
|
@ -8,11 +8,24 @@ import Redis from 'ioredis';
|
|||
import type { MiUser } from '@/models/_.js';
|
||||
import { TimeService } from '@/global/TimeService.js';
|
||||
import { EnvService } from '@/global/EnvService.js';
|
||||
import { BucketRateLimit, LegacyRateLimit, LimitInfo, RateLimit, hasMinLimit, isLegacyRateLimit, Keyed, hasMaxLimit, disabledLimitInfo, MaxLegacyLimit, MinLegacyLimit } from '@/misc/rate-limit-utils.js';
|
||||
import {
|
||||
type BucketRateLimit,
|
||||
type LegacyRateLimit,
|
||||
type LimitInfo,
|
||||
type RateLimit,
|
||||
type Keyed,
|
||||
type MaxLegacyLimit,
|
||||
type MinLegacyLimit,
|
||||
hasMinLimit,
|
||||
isLegacyRateLimit,
|
||||
hasMaxLimit,
|
||||
disabledLimitInfo,
|
||||
} from '@/misc/rate-limit-utils.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheManagementService, type ManagedMemoryKVCache } from '@/global/CacheManagementService.js';
|
||||
import { ConflictError } from '@/misc/errors/ConflictError.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
||||
// Sentinel value used for caching the default role template.
|
||||
// Required because MemoryKVCache doesn't support null keys.
|
||||
|
|
@ -64,6 +77,7 @@ export class SkRateLimiterService {
|
|||
* @param limit The limit definition
|
||||
* @param actorOrUser authenticated client user or IP hash
|
||||
*/
|
||||
@bindThis
|
||||
public async limit(limit: Keyed<RateLimit>, actorOrUser: string | MiUser): Promise<LimitInfo> {
|
||||
if (this.disabled) {
|
||||
return disabledLimitInfo;
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import PerUserPvChart from '@/core/chart/charts/per-user-pv.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { renderInlineError } from '@/misc/render-inline-error.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { ApiLoggerService } from '../../ApiLoggerService.js';
|
||||
import type { FindOptionsWhere } from 'typeorm';
|
||||
|
|
@ -103,6 +103,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private roleService: RoleService,
|
||||
private perUserPvChart: PerUserPvChart,
|
||||
private apiLoggerService: ApiLoggerService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me, _1, _2, _3, ip) => {
|
||||
let user;
|
||||
|
|
@ -115,19 +116,18 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return [];
|
||||
}
|
||||
|
||||
const users = await this.usersRepository.findBy(isModerator ? {
|
||||
id: In(ps.userIds),
|
||||
} : {
|
||||
id: In(ps.userIds),
|
||||
isSuspended: false,
|
||||
});
|
||||
const users = await this.cacheService.findUsersById(ps.userIds);
|
||||
|
||||
// リクエストされた通りに並べ替え
|
||||
// 順番は保持されるけど数は減ってる可能性がある
|
||||
const _users: MiUser[] = [];
|
||||
for (const id of ps.userIds) {
|
||||
const user = users.find(x => x.id === id);
|
||||
if (user != null) _users.push(user);
|
||||
const user = users.get(id);
|
||||
if (user != null) {
|
||||
if (isModerator || !user.isSuspended) {
|
||||
_users.push(user);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const _userMap = await this.userEntityService.packMany(_users, me, { schema: ps.detail ? 'UserDetailed' : 'UserLite' })
|
||||
|
|
@ -135,17 +135,14 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
return _users.map(u => _userMap.get(u.id)!);
|
||||
} else {
|
||||
// Lookup user
|
||||
if (typeof ps.host === 'string' && typeof ps.username === 'string') {
|
||||
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host).catch(err => {
|
||||
this.apiLoggerService.logger.warn(`failed to resolve remote user: ${renderInlineError(err)}`);
|
||||
throw new ApiError(meta.errors.failedToResolveRemoteUser);
|
||||
});
|
||||
} else {
|
||||
const q: FindOptionsWhere<MiUser> = ps.userId != null
|
||||
? { id: ps.userId }
|
||||
: { usernameLower: ps.username!.toLowerCase(), host: IsNull() };
|
||||
if (ps.username) {
|
||||
user = await this.remoteUserResolveService.resolveUser(ps.username, ps.host ?? null).catch(() => null);
|
||||
} else if (ps.userId != null) {
|
||||
user = await this.cacheService.findUserById(ps.userId).catch(() => null);
|
||||
}
|
||||
|
||||
user = await this.usersRepository.findOneBy(q);
|
||||
if (user == null && ps.host != null) {
|
||||
throw new ApiError(meta.errors.failedToResolveRemoteUser);
|
||||
}
|
||||
|
||||
if (user == null || (!isModerator && user.isSuspended)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue