Compare commits
42 Commits
cdb14eda5b
...
5c0481070a
Author | SHA1 | Date | |
---|---|---|---|
|
5c0481070a | ||
|
a38d8a91a1 | ||
|
6027b516e1 | ||
|
757d9aa5ee | ||
|
36af07abe2 | ||
|
23c4aa2571 | ||
|
1758f29364 | ||
|
fa3cf6c299 | ||
|
4b556efdaa | ||
|
b0834ebf55 | ||
|
2234fbcb11 | ||
|
8e90484b3e | ||
|
0fcb23c4c1 | ||
|
776f6fd1f5 | ||
|
7b3e3f8e25 | ||
|
360d71278a | ||
|
663c06be00 | ||
|
7ccccf5545 | ||
|
f36f4b5398 | ||
|
cc4e99fdde | ||
|
5764fa55cb | ||
|
74565f67f7 | ||
|
408e782507 | ||
|
cbf8cc376e | ||
|
c04f344049 | ||
|
b9080da75d | ||
|
4d925fc086 | ||
|
b74e2e9167 | ||
|
ebea1a2962 | ||
|
4c432c07cb | ||
|
322b3b677f | ||
|
1c7e05ce9e | ||
|
9ab25ede28 | ||
|
174dfb83d0 | ||
|
ad8e8793c7 | ||
|
1e14612f0e | ||
|
9090b745e6 | ||
|
d883934826 | ||
|
27339e03c2 | ||
|
680c2a0718 | ||
|
f258888408 | ||
|
d150e92f41 |
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sharkey",
|
"name": "sharkey",
|
||||||
"version": "2024.9.1",
|
"version": "2024.9.3",
|
||||||
"codename": "shonk",
|
"codename": "shonk",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -6,7 +6,6 @@
|
|||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import * as stream from 'node:stream/promises';
|
import * as stream from 'node:stream/promises';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import ipaddr from 'ipaddr.js';
|
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import got, * as Got from 'got';
|
import got, * as Got from 'got';
|
||||||
import { parse } from 'content-disposition';
|
import { parse } from 'content-disposition';
|
||||||
@ -70,13 +69,6 @@ export class DownloadService {
|
|||||||
},
|
},
|
||||||
enableUnixSockets: false,
|
enableUnixSockets: false,
|
||||||
}).on('response', (res: Got.Response) => {
|
}).on('response', (res: Got.Response) => {
|
||||||
if ((process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'test') && !this.config.proxy && res.ip) {
|
|
||||||
if (this.isPrivateIp(res.ip)) {
|
|
||||||
this.logger.warn(`Blocked address: ${res.ip}`);
|
|
||||||
req.destroy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = res.headers['content-length'];
|
const contentLength = res.headers['content-length'];
|
||||||
if (contentLength != null) {
|
if (contentLength != null) {
|
||||||
const size = Number(contentLength);
|
const size = Number(contentLength);
|
||||||
@ -139,18 +131,4 @@ export class DownloadService {
|
|||||||
cleanup();
|
cleanup();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
|
||||||
private isPrivateIp(ip: string): boolean {
|
|
||||||
const parsedIp = ipaddr.parse(ip);
|
|
||||||
|
|
||||||
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
|
||||||
const cidr = ipaddr.parseCIDR(net);
|
|
||||||
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsedIp.range() !== 'unicast';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -312,6 +312,7 @@ export class EmailService {
|
|||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
Authorization: truemailAuthKey,
|
Authorization: truemailAuthKey,
|
||||||
},
|
},
|
||||||
|
isLocalAddressAllowed: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const json = (await res.json()) as {
|
const json = (await res.json()) as {
|
||||||
|
@ -6,6 +6,7 @@
|
|||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import * as net from 'node:net';
|
import * as net from 'node:net';
|
||||||
|
import ipaddr from 'ipaddr.js';
|
||||||
import CacheableLookup from 'cacheable-lookup';
|
import CacheableLookup from 'cacheable-lookup';
|
||||||
import fetch from 'node-fetch';
|
import fetch from 'node-fetch';
|
||||||
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
import { HttpProxyAgent, HttpsProxyAgent } from 'hpagent';
|
||||||
@ -25,8 +26,102 @@ export type HttpRequestSendOptions = {
|
|||||||
validators?: ((res: Response) => void)[];
|
validators?: ((res: Response) => void)[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
declare module 'node:http' {
|
||||||
|
interface Agent {
|
||||||
|
createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpRequestServiceAgent extends http.Agent {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
options?: http.AgentOptions,
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||||
|
const socket = super.createConnection(options, callback)
|
||||||
|
.on('connect', () => {
|
||||||
|
const address = socket.remoteAddress;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (address && ipaddr.isValid(address)) {
|
||||||
|
if (this.isPrivateIp(address)) {
|
||||||
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
|
const cidr = ipaddr.parseCIDR(net);
|
||||||
|
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp.range() !== 'unicast';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpsRequestServiceAgent extends https.Agent {
|
||||||
|
constructor(
|
||||||
|
private config: Config,
|
||||||
|
options?: https.AgentOptions,
|
||||||
|
) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public createConnection(options: net.NetConnectOpts, callback?: (err: unknown, stream: net.Socket) => void): net.Socket {
|
||||||
|
const socket = super.createConnection(options, callback)
|
||||||
|
.on('connect', () => {
|
||||||
|
const address = socket.remoteAddress;
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
if (address && ipaddr.isValid(address)) {
|
||||||
|
if (this.isPrivateIp(address)) {
|
||||||
|
socket.destroy(new Error(`Blocked address: ${address}`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private isPrivateIp(ip: string): boolean {
|
||||||
|
const parsedIp = ipaddr.parse(ip);
|
||||||
|
|
||||||
|
for (const net of this.config.allowedPrivateNetworks ?? []) {
|
||||||
|
const cidr = ipaddr.parseCIDR(net);
|
||||||
|
if (cidr[0].kind() === parsedIp.kind() && parsedIp.match(ipaddr.parseCIDR(net))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedIp.range() !== 'unicast';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HttpRequestService {
|
export class HttpRequestService {
|
||||||
|
/**
|
||||||
|
* Get http non-proxy agent (without local address filtering)
|
||||||
|
*/
|
||||||
|
private httpNative: http.Agent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get https non-proxy agent (without local address filtering)
|
||||||
|
*/
|
||||||
|
private httpsNative: https.Agent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get http non-proxy agent
|
* Get http non-proxy agent
|
||||||
*/
|
*/
|
||||||
@ -57,19 +152,20 @@ export class HttpRequestService {
|
|||||||
lookup: false, // nativeのdns.lookupにfallbackしない
|
lookup: false, // nativeのdns.lookupにfallbackしない
|
||||||
});
|
});
|
||||||
|
|
||||||
this.http = new http.Agent({
|
const agentOption = {
|
||||||
keepAlive: true,
|
keepAlive: true,
|
||||||
keepAliveMsecs: 30 * 1000,
|
keepAliveMsecs: 30 * 1000,
|
||||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
lookup: cache.lookup as unknown as net.LookupFunction,
|
||||||
localAddress: config.outgoingAddress,
|
localAddress: config.outgoingAddress,
|
||||||
});
|
};
|
||||||
|
|
||||||
this.https = new https.Agent({
|
this.httpNative = new http.Agent(agentOption);
|
||||||
keepAlive: true,
|
|
||||||
keepAliveMsecs: 30 * 1000,
|
this.httpsNative = new https.Agent(agentOption);
|
||||||
lookup: cache.lookup as unknown as net.LookupFunction,
|
|
||||||
localAddress: config.outgoingAddress,
|
this.http = new HttpRequestServiceAgent(config, agentOption);
|
||||||
});
|
|
||||||
|
this.https = new HttpsRequestServiceAgent(config, agentOption);
|
||||||
|
|
||||||
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
const maxSockets = Math.max(256, config.deliverJobConcurrency ?? 128);
|
||||||
|
|
||||||
@ -104,16 +200,22 @@ export class HttpRequestService {
|
|||||||
* @param bypassProxy Allways bypass proxy
|
* @param bypassProxy Allways bypass proxy
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public getAgentByUrl(url: URL, bypassProxy = false): http.Agent | https.Agent {
|
public getAgentByUrl(url: URL, bypassProxy = false, isLocalAddressAllowed = false): http.Agent | https.Agent {
|
||||||
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
if (bypassProxy || (this.config.proxyBypassHosts ?? []).includes(url.hostname)) {
|
||||||
|
if (isLocalAddressAllowed) {
|
||||||
|
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||||
|
}
|
||||||
return url.protocol === 'http:' ? this.http : this.https;
|
return url.protocol === 'http:' ? this.http : this.https;
|
||||||
} else {
|
} else {
|
||||||
|
if (isLocalAddressAllowed && (!this.config.proxy)) {
|
||||||
|
return url.protocol === 'http:' ? this.httpNative : this.httpsNative;
|
||||||
|
}
|
||||||
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
return url.protocol === 'http:' ? this.httpAgent : this.httpsAgent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getActivityJson(url: string): Promise<IObject> {
|
public async getActivityJson(url: string, isLocalAddressAllowed = false): Promise<IObject> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@ -121,6 +223,7 @@ export class HttpRequestService {
|
|||||||
},
|
},
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
size: 1024 * 256,
|
size: 1024 * 256,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
}, {
|
}, {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
validators: [validateContentTypeSetAsActivityPub],
|
validators: [validateContentTypeSetAsActivityPub],
|
||||||
@ -129,13 +232,13 @@ export class HttpRequestService {
|
|||||||
const finalUrl = res.url; // redirects may have been involved
|
const finalUrl = res.url; // redirects may have been involved
|
||||||
const activity = await res.json() as IObject;
|
const activity = await res.json() as IObject;
|
||||||
|
|
||||||
assertActivityMatchesUrls(activity, [url, finalUrl]);
|
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||||
|
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>): Promise<T> {
|
public async getJson<T = unknown>(url: string, accept = 'application/json, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<T> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
@ -143,19 +246,21 @@ export class HttpRequestService {
|
|||||||
}, headers ?? {}),
|
}, headers ?? {}),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
size: 1024 * 256,
|
size: 1024 * 256,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.json() as T;
|
return await res.json() as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>): Promise<string> {
|
public async getHtml(url: string, accept = 'text/html, */*', headers?: Record<string, string>, isLocalAddressAllowed = false): Promise<string> {
|
||||||
const res = await this.send(url, {
|
const res = await this.send(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: Object.assign({
|
headers: Object.assign({
|
||||||
Accept: accept,
|
Accept: accept,
|
||||||
}, headers ?? {}),
|
}, headers ?? {}),
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
|
isLocalAddressAllowed: isLocalAddressAllowed,
|
||||||
});
|
});
|
||||||
|
|
||||||
return await res.text();
|
return await res.text();
|
||||||
@ -170,6 +275,7 @@ export class HttpRequestService {
|
|||||||
headers?: Record<string, string>,
|
headers?: Record<string, string>,
|
||||||
timeout?: number,
|
timeout?: number,
|
||||||
size?: number,
|
size?: number,
|
||||||
|
isLocalAddressAllowed?: boolean,
|
||||||
} = {},
|
} = {},
|
||||||
extra: HttpRequestSendOptions = {
|
extra: HttpRequestSendOptions = {
|
||||||
throwErrorWhenResponseNotOk: true,
|
throwErrorWhenResponseNotOk: true,
|
||||||
@ -183,6 +289,8 @@ export class HttpRequestService {
|
|||||||
controller.abort();
|
controller.abort();
|
||||||
}, timeout);
|
}, timeout);
|
||||||
|
|
||||||
|
const isLocalAddressAllowed = args.isLocalAddressAllowed ?? false;
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: args.method ?? 'GET',
|
method: args.method ?? 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
@ -191,7 +299,7 @@ export class HttpRequestService {
|
|||||||
},
|
},
|
||||||
body: args.body,
|
body: args.body,
|
||||||
size: args.size ?? 10 * 1024 * 1024,
|
size: args.size ?? 10 * 1024 * 1024,
|
||||||
agent: (url) => this.getAgentByUrl(url),
|
agent: (url) => this.getAgentByUrl(url, false, isLocalAddressAllowed),
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -56,7 +56,7 @@ export class RemoteUserResolveService {
|
|||||||
|
|
||||||
host = this.utilityService.toPuny(host);
|
host = this.utilityService.toPuny(host);
|
||||||
|
|
||||||
if (this.config.host === host) {
|
if (host === this.utilityService.toPuny(this.config.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: IsNull() }).then(u => {
|
||||||
if (u == null) {
|
if (u == null) {
|
||||||
|
@ -34,6 +34,11 @@ export class UtilityService {
|
|||||||
return this.toPuny(this.config.host) === this.toPuny(host);
|
return this.toPuny(this.config.host) === this.toPuny(host);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public isUriLocal(uri: string): boolean {
|
||||||
|
return this.punyHost(uri) === this.toPuny(this.config.host);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
public isBlockedHost(blockedHosts: string[], host: string | null): boolean {
|
||||||
if (host == null) return false;
|
if (host == null) return false;
|
||||||
|
@ -10,6 +10,7 @@ import type { Config } from '@/config.js';
|
|||||||
import { MemoryKVCache } from '@/misc/cache.js';
|
import { MemoryKVCache } from '@/misc/cache.js';
|
||||||
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
import type { MiUserPublickey } from '@/models/UserPublickey.js';
|
||||||
import { CacheService } from '@/core/CacheService.js';
|
import { CacheService } from '@/core/CacheService.js';
|
||||||
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
import type { MiLocalUser, MiRemoteUser } from '@/models/User.js';
|
||||||
@ -55,6 +56,7 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||||||
private cacheService: CacheService,
|
private cacheService: CacheService,
|
||||||
private apPersonService: ApPersonService,
|
private apPersonService: ApPersonService,
|
||||||
private apLoggerService: ApLoggerService,
|
private apLoggerService: ApLoggerService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
this.publicKeyCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||||
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
this.publicKeyByUserIdCache = new MemoryKVCache<MiUserPublickey | null>(1000 * 60 * 60 * 12); // 12h
|
||||||
@ -65,7 +67,9 @@ export class ApDbResolverService implements OnApplicationShutdown {
|
|||||||
const separator = '/';
|
const separator = '/';
|
||||||
|
|
||||||
const uri = new URL(getApId(value));
|
const uri = new URL(getApId(value));
|
||||||
if (uri.origin !== this.config.url) return { local: false, uri: uri.href };
|
if (this.utilityService.toPuny(uri.host) !== this.utilityService.toPuny(this.config.host)) {
|
||||||
|
return { local: false, uri: uri.href };
|
||||||
|
}
|
||||||
|
|
||||||
const [, type, id, ...rest] = uri.pathname.split(separator);
|
const [, type, id, ...rest] = uri.pathname.split(separator);
|
||||||
return {
|
return {
|
||||||
|
@ -93,15 +93,26 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async performActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
public async performActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||||
let result = undefined as string | void;
|
let result = undefined as string | void;
|
||||||
if (isCollectionOrOrderedCollection(activity)) {
|
if (isCollectionOrOrderedCollection(activity)) {
|
||||||
const results = [] as [string, string | void][];
|
const results = [] as [string, string | void][];
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
for (const item of toArray(isCollection(activity) ? activity.items : activity.orderedItems)) {
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
|
const items = toArray(isCollection(activity) ? activity.items : activity.orderedItems);
|
||||||
|
if (items.length >= resolver.getRecursionLimit()) {
|
||||||
|
throw new Error(`skipping activity: collection would surpass recursion limit: ${this.utilityService.extractDbHost(actor.uri)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
const act = await resolver.resolve(item);
|
const act = await resolver.resolve(item);
|
||||||
|
if (act.id == null || this.utilityService.extractDbHost(act.id) !== this.utilityService.extractDbHost(actor.uri)) {
|
||||||
|
this.logger.debug('skipping activity: activity id is null or mismatching');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
results.push([getApId(item), await this.performOneActivity(actor, act)]);
|
results.push([getApId(item), await this.performOneActivity(actor, act, resolver)]);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Error || typeof err === 'string') {
|
if (err instanceof Error || typeof err === 'string') {
|
||||||
this.logger.error(err);
|
this.logger.error(err);
|
||||||
@ -116,7 +127,7 @@ export class ApInboxService {
|
|||||||
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
|
result = results.map(([id, reason]) => `${id}: ${reason}`).join('\n');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
result = await this.performOneActivity(actor, activity);
|
result = await this.performOneActivity(actor, activity, resolver);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ついでにリモートユーザーの情報が古かったら更新しておく
|
// ついでにリモートユーザーの情報が古かったら更新しておく
|
||||||
@ -131,37 +142,37 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async performOneActivity(actor: MiRemoteUser, activity: IObject): Promise<string | void> {
|
public async performOneActivity(actor: MiRemoteUser, activity: IObject, resolver?: Resolver): Promise<string | void> {
|
||||||
if (actor.isSuspended) return;
|
if (actor.isSuspended) return;
|
||||||
|
|
||||||
if (isCreate(activity)) {
|
if (isCreate(activity)) {
|
||||||
return await this.create(actor, activity);
|
return await this.create(actor, activity, resolver);
|
||||||
} else if (isDelete(activity)) {
|
} else if (isDelete(activity)) {
|
||||||
return await this.delete(actor, activity);
|
return await this.delete(actor, activity);
|
||||||
} else if (isUpdate(activity)) {
|
} else if (isUpdate(activity)) {
|
||||||
return await this.update(actor, activity);
|
return await this.update(actor, activity, resolver);
|
||||||
} else if (isFollow(activity)) {
|
} else if (isFollow(activity)) {
|
||||||
return await this.follow(actor, activity);
|
return await this.follow(actor, activity);
|
||||||
} else if (isAccept(activity)) {
|
} else if (isAccept(activity)) {
|
||||||
return await this.accept(actor, activity);
|
return await this.accept(actor, activity, resolver);
|
||||||
} else if (isReject(activity)) {
|
} else if (isReject(activity)) {
|
||||||
return await this.reject(actor, activity);
|
return await this.reject(actor, activity, resolver);
|
||||||
} else if (isAdd(activity)) {
|
} else if (isAdd(activity)) {
|
||||||
return await this.add(actor, activity);
|
return await this.add(actor, activity, resolver);
|
||||||
} else if (isRemove(activity)) {
|
} else if (isRemove(activity)) {
|
||||||
return await this.remove(actor, activity);
|
return await this.remove(actor, activity, resolver);
|
||||||
} else if (isAnnounce(activity)) {
|
} else if (isAnnounce(activity)) {
|
||||||
return await this.announce(actor, activity);
|
return await this.announce(actor, activity, resolver);
|
||||||
} else if (isLike(activity)) {
|
} else if (isLike(activity)) {
|
||||||
return await this.like(actor, activity);
|
return await this.like(actor, activity);
|
||||||
} else if (isUndo(activity)) {
|
} else if (isUndo(activity)) {
|
||||||
return await this.undo(actor, activity);
|
return await this.undo(actor, activity, resolver);
|
||||||
} else if (isBlock(activity)) {
|
} else if (isBlock(activity)) {
|
||||||
return await this.block(actor, activity);
|
return await this.block(actor, activity);
|
||||||
} else if (isFlag(activity)) {
|
} else if (isFlag(activity)) {
|
||||||
return await this.flag(actor, activity);
|
return await this.flag(actor, activity);
|
||||||
} else if (isMove(activity)) {
|
} else if (isMove(activity)) {
|
||||||
return await this.move(actor, activity);
|
return await this.move(actor, activity, resolver);
|
||||||
} else {
|
} else {
|
||||||
return `unrecognized activity type: ${activity.type}`;
|
return `unrecognized activity type: ${activity.type}`;
|
||||||
}
|
}
|
||||||
@ -203,12 +214,13 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async accept(actor: MiRemoteUser, activity: IAccept): Promise<string> {
|
private async accept(actor: MiRemoteUser, activity: IAccept, resolver?: Resolver): Promise<string> {
|
||||||
const uri = activity.id ?? activity;
|
const uri = activity.id ?? activity;
|
||||||
|
|
||||||
this.logger.info(`Accept: ${uri}`);
|
this.logger.info(`Accept: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(err => {
|
const object = await resolver.resolve(activity.object).catch(err => {
|
||||||
this.logger.error(`Resolution failed: ${err}`);
|
this.logger.error(`Resolution failed: ${err}`);
|
||||||
@ -245,7 +257,7 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async add(actor: MiRemoteUser, activity: IAdd): Promise<string | void> {
|
private async add(actor: MiRemoteUser, activity: IAdd, resolver?: Resolver): Promise<string | void> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'invalid actor';
|
return 'invalid actor';
|
||||||
}
|
}
|
||||||
@ -256,7 +268,7 @@ export class ApInboxService {
|
|||||||
|
|
||||||
if (activity.target === actor.featured) {
|
if (activity.target === actor.featured) {
|
||||||
const object = fromTuple(activity.object);
|
const object = fromTuple(activity.object);
|
||||||
const note = await this.apNoteService.resolveNote(object);
|
const note = await this.apNoteService.resolveNote(object, { resolver });
|
||||||
if (note == null) return 'note not found';
|
if (note == null) return 'note not found';
|
||||||
await this.notePiningService.addPinned(actor, note.id);
|
await this.notePiningService.addPinned(actor, note.id);
|
||||||
return;
|
return;
|
||||||
@ -266,12 +278,13 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async announce(actor: MiRemoteUser, activity: IAnnounce): Promise<string | void> {
|
private async announce(actor: MiRemoteUser, activity: IAnnounce, resolver?: Resolver): Promise<string | void> {
|
||||||
const uri = getApId(activity);
|
const uri = getApId(activity);
|
||||||
|
|
||||||
this.logger.info(`Announce: ${uri}`);
|
this.logger.info(`Announce: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const activityObject = fromTuple(activity.object);
|
const activityObject = fromTuple(activity.object);
|
||||||
if (!activityObject) return 'skip: activity has no object property';
|
if (!activityObject) return 'skip: activity has no object property';
|
||||||
@ -289,7 +302,7 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost): Promise<string | void> {
|
private async announceNote(actor: MiRemoteUser, activity: IAnnounce, target: IPost, resolver?: Resolver): Promise<string | void> {
|
||||||
const uri = getApId(activity);
|
const uri = getApId(activity);
|
||||||
|
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
@ -311,7 +324,7 @@ export class ApInboxService {
|
|||||||
// Announce対象をresolve
|
// Announce対象をresolve
|
||||||
let renote;
|
let renote;
|
||||||
try {
|
try {
|
||||||
renote = await this.apNoteService.resolveNote(target);
|
renote = await this.apNoteService.resolveNote(target, { resolver });
|
||||||
if (renote == null) return 'announce target is null';
|
if (renote == null) return 'announce target is null';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 対象が4xxならスキップ
|
// 対象が4xxならスキップ
|
||||||
@ -330,7 +343,7 @@ export class ApInboxService {
|
|||||||
|
|
||||||
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
this.logger.info(`Creating the (Re)Note: ${uri}`);
|
||||||
|
|
||||||
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc);
|
const activityAudience = await this.apAudienceService.parseAudience(actor, activity.to, activity.cc, resolver);
|
||||||
const createdAt = activity.published ? new Date(activity.published) : null;
|
const createdAt = activity.published ? new Date(activity.published) : null;
|
||||||
|
|
||||||
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
if (createdAt && createdAt < this.idService.parse(renote.id).date) {
|
||||||
@ -368,7 +381,7 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async create(actor: MiRemoteUser, activity: ICreate): Promise<string | void> {
|
private async create(actor: MiRemoteUser, activity: ICreate, resolver?: Resolver): Promise<string | void> {
|
||||||
const uri = getApId(activity);
|
const uri = getApId(activity);
|
||||||
|
|
||||||
this.logger.info(`Create: ${uri}`);
|
this.logger.info(`Create: ${uri}`);
|
||||||
@ -394,7 +407,8 @@ export class ApInboxService {
|
|||||||
activityObject.attributedTo = activity.actor;
|
activityObject.attributedTo = activity.actor;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activityObject).catch(e => {
|
const object = await resolver.resolve(activityObject).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
@ -421,6 +435,8 @@ export class ApInboxService {
|
|||||||
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
if (this.utilityService.extractDbHost(actor.uri) !== this.utilityService.extractDbHost(note.id)) {
|
||||||
return 'skip: host in actor.uri !== note.id';
|
return 'skip: host in actor.uri !== note.id';
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
return 'skip: note.id is not a string';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -430,7 +446,7 @@ export class ApInboxService {
|
|||||||
const exist = await this.apNoteService.fetchNote(note);
|
const exist = await this.apNoteService.fetchNote(note);
|
||||||
if (exist) return 'skip: note exists';
|
if (exist) return 'skip: note exists';
|
||||||
|
|
||||||
await this.apNoteService.createNote(note, resolver, silent);
|
await this.apNoteService.createNote(note, actor, resolver, silent);
|
||||||
return 'ok';
|
return 'ok';
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof StatusError && !err.isRetryable) {
|
if (err instanceof StatusError && !err.isRetryable) {
|
||||||
@ -568,12 +584,13 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async reject(actor: MiRemoteUser, activity: IReject): Promise<string> {
|
private async reject(actor: MiRemoteUser, activity: IReject, resolver?: Resolver): Promise<string> {
|
||||||
const uri = activity.id ?? activity;
|
const uri = activity.id ?? activity;
|
||||||
|
|
||||||
this.logger.info(`Reject: ${uri}`);
|
this.logger.info(`Reject: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
@ -610,7 +627,7 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async remove(actor: MiRemoteUser, activity: IRemove): Promise<string | void> {
|
private async remove(actor: MiRemoteUser, activity: IRemove, resolver?: Resolver): Promise<string | void> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'invalid actor';
|
return 'invalid actor';
|
||||||
}
|
}
|
||||||
@ -621,7 +638,7 @@ export class ApInboxService {
|
|||||||
|
|
||||||
if (activity.target === actor.featured) {
|
if (activity.target === actor.featured) {
|
||||||
const activityObject = fromTuple(activity.object);
|
const activityObject = fromTuple(activity.object);
|
||||||
const note = await this.apNoteService.resolveNote(activityObject);
|
const note = await this.apNoteService.resolveNote(activityObject, { resolver });
|
||||||
if (note == null) return 'note not found';
|
if (note == null) return 'note not found';
|
||||||
await this.notePiningService.removePinned(actor, note.id);
|
await this.notePiningService.removePinned(actor, note.id);
|
||||||
return;
|
return;
|
||||||
@ -631,7 +648,7 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async undo(actor: MiRemoteUser, activity: IUndo): Promise<string> {
|
private async undo(actor: MiRemoteUser, activity: IUndo, resolver?: Resolver): Promise<string> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'invalid actor';
|
return 'invalid actor';
|
||||||
}
|
}
|
||||||
@ -640,7 +657,8 @@ export class ApInboxService {
|
|||||||
|
|
||||||
this.logger.info(`Undo: ${uri}`);
|
this.logger.info(`Undo: ${uri}`);
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
@ -764,14 +782,15 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async update(actor: MiRemoteUser, activity: IUpdate): Promise<string> {
|
private async update(actor: MiRemoteUser, activity: IUpdate, resolver?: Resolver): Promise<string> {
|
||||||
if (actor.uri !== activity.actor) {
|
if (actor.uri !== activity.actor) {
|
||||||
return 'skip: invalid actor';
|
return 'skip: invalid actor';
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.debug('Update');
|
this.logger.debug('Update');
|
||||||
|
|
||||||
const resolver = this.apResolverService.createResolver();
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
resolver ??= this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(activity.object).catch(e => {
|
const object = await resolver.resolve(activity.object).catch(e => {
|
||||||
this.logger.error(`Resolution failed: ${e}`);
|
this.logger.error(`Resolution failed: ${e}`);
|
||||||
@ -782,10 +801,10 @@ export class ApInboxService {
|
|||||||
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
await this.apPersonService.updatePerson(actor.uri, resolver, object);
|
||||||
return 'ok: Person updated';
|
return 'ok: Person updated';
|
||||||
} else if (getApType(object) === 'Question') {
|
} else if (getApType(object) === 'Question') {
|
||||||
await this.apQuestionService.updateQuestion(object, resolver).catch(err => console.error(err));
|
await this.apQuestionService.updateQuestion(object, actor, resolver).catch(err => console.error(err));
|
||||||
return 'ok: Question updated';
|
return 'ok: Question updated';
|
||||||
} else if (getApType(object) === 'Note') {
|
} else if (getApType(object) === 'Note') {
|
||||||
await this.apNoteService.updateNote(object, resolver).catch(err => console.error(err));
|
await this.apNoteService.updateNote(object, actor, resolver).catch(err => console.error(err));
|
||||||
return 'ok: Note updated';
|
return 'ok: Note updated';
|
||||||
} else {
|
} else {
|
||||||
return `skip: Unknown type: ${getApType(object)}`;
|
return `skip: Unknown type: ${getApType(object)}`;
|
||||||
@ -793,11 +812,11 @@ export class ApInboxService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async move(actor: MiRemoteUser, activity: IMove): Promise<string> {
|
private async move(actor: MiRemoteUser, activity: IMove, resolver?: Resolver): Promise<string> {
|
||||||
// fetch the new and old accounts
|
// fetch the new and old accounts
|
||||||
const targetUri = getApHrefNullable(activity.target);
|
const targetUri = getApHrefNullable(activity.target);
|
||||||
if (!targetUri) return 'skip: invalid activity target';
|
if (!targetUri) return 'skip: invalid activity target';
|
||||||
|
|
||||||
return await this.apPersonService.updatePerson(actor.uri) ?? 'skip: nothing to do';
|
return await this.apPersonService.updatePerson(actor.uri, resolver) ?? 'skip: nothing to do';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -18,6 +18,7 @@ import type Logger from '@/logger.js';
|
|||||||
import type { IObject } from './type.js';
|
import type { IObject } from './type.js';
|
||||||
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
|
||||||
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
import { assertActivityMatchesUrls } from '@/core/activitypub/misc/check-against-url.js';
|
||||||
|
import { UtilityService } from "@/core/UtilityService.js";
|
||||||
|
|
||||||
type Request = {
|
type Request = {
|
||||||
url: string;
|
url: string;
|
||||||
@ -147,6 +148,7 @@ export class ApRequestService {
|
|||||||
private userKeypairService: UserKeypairService,
|
private userKeypairService: UserKeypairService,
|
||||||
private httpRequestService: HttpRequestService,
|
private httpRequestService: HttpRequestService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
this.logger = this.loggerService?.getLogger('ap-request'); // なぜか TypeError: Cannot read properties of undefined (reading 'getLogger') と言われる
|
||||||
@ -241,7 +243,9 @@ export class ApRequestService {
|
|||||||
if (alternate) {
|
if (alternate) {
|
||||||
const href = alternate.getAttribute('href');
|
const href = alternate.getAttribute('href');
|
||||||
if (href) {
|
if (href) {
|
||||||
return await this.signedGet(href, user, false);
|
if (this.utilityService.punyHost(url) === this.utilityService.punyHost(href)) {
|
||||||
|
return await this.signedGet(href, user, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -257,7 +261,7 @@ export class ApRequestService {
|
|||||||
const finalUrl = res.url; // redirects may have been involved
|
const finalUrl = res.url; // redirects may have been involved
|
||||||
const activity = await res.json() as IObject;
|
const activity = await res.json() as IObject;
|
||||||
|
|
||||||
assertActivityMatchesUrls(activity, [url, finalUrl]);
|
assertActivityMatchesUrls(activity, [finalUrl]);
|
||||||
|
|
||||||
return activity;
|
return activity;
|
||||||
}
|
}
|
||||||
|
@ -42,7 +42,7 @@ export class Resolver {
|
|||||||
private apRendererService: ApRendererService,
|
private apRendererService: ApRendererService,
|
||||||
private apDbResolverService: ApDbResolverService,
|
private apDbResolverService: ApDbResolverService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
private recursionLimit = 100,
|
private recursionLimit = 256,
|
||||||
) {
|
) {
|
||||||
this.history = new Set();
|
this.history = new Set();
|
||||||
this.logger = this.loggerService.getLogger('ap-resolve');
|
this.logger = this.loggerService.getLogger('ap-resolve');
|
||||||
@ -53,6 +53,11 @@ export class Resolver {
|
|||||||
return Array.from(this.history);
|
return Array.from(this.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public getRecursionLimit(): number {
|
||||||
|
return this.recursionLimit;
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
public async resolveCollection(value: string | IObject): Promise<ICollection | IOrderedCollection> {
|
||||||
const collection = typeof value === 'string'
|
const collection = typeof value === 'string'
|
||||||
@ -121,7 +126,11 @@ export class Resolver {
|
|||||||
// `object.id` or `object.url` matches the URL used to fetch the
|
// `object.id` or `object.url` matches the URL used to fetch the
|
||||||
// object after redirects; here we double-check that no redirects
|
// object after redirects; here we double-check that no redirects
|
||||||
// bounced between hosts
|
// bounced between hosts
|
||||||
if (object.id && (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value))) {
|
if (object.id == null) {
|
||||||
|
throw new Error('invalid AP object: missing id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(object.id) !== this.utilityService.punyHost(value)) {
|
||||||
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
|
throw new Error(`invalid AP object ${value}: id ${object.id} has different host`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -6,7 +6,7 @@
|
|||||||
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
import { forwardRef, Inject, Injectable } from '@nestjs/common';
|
||||||
import { In } from 'typeorm';
|
import { In } from 'typeorm';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
import type { UsersRepository, PollsRepository, EmojisRepository, NotesRepository, MiMeta } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { MiRemoteUser } from '@/models/User.js';
|
import type { MiRemoteUser } from '@/models/User.js';
|
||||||
import type { MiNote } from '@/models/Note.js';
|
import type { MiNote } from '@/models/Note.js';
|
||||||
@ -49,6 +49,9 @@ export class ApNoteService {
|
|||||||
@Inject(DI.meta)
|
@Inject(DI.meta)
|
||||||
private meta: MiMeta,
|
private meta: MiMeta,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.pollsRepository)
|
@Inject(DI.pollsRepository)
|
||||||
private pollsRepository: PollsRepository,
|
private pollsRepository: PollsRepository,
|
||||||
|
|
||||||
@ -82,7 +85,13 @@ export class ApNoteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public validateNote(object: IObject, uri: string): Error | null {
|
public validateNote(
|
||||||
|
object: IObject,
|
||||||
|
uri: string,
|
||||||
|
actor?: MiRemoteUser,
|
||||||
|
user?: MiRemoteUser,
|
||||||
|
note?: MiNote,
|
||||||
|
): Error | null {
|
||||||
const expectHost = this.utilityService.extractDbHost(uri);
|
const expectHost = this.utilityService.extractDbHost(uri);
|
||||||
const apType = getApType(object);
|
const apType = getApType(object);
|
||||||
|
|
||||||
@ -99,10 +108,27 @@ export class ApNoteService {
|
|||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${actualHost}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (actor) {
|
||||||
|
const attribution = (object.attributedTo) ? getOneApId(object.attributedTo) : actor.uri;
|
||||||
|
if (attribution !== actor.uri) {
|
||||||
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: attribution does not match the actor that send it. attribution: ${attribution}, actor: ${actor.uri}`);
|
||||||
|
}
|
||||||
|
if (user && attribution !== user.uri) {
|
||||||
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated attribution does not match original attribution. updated attribution: ${user.uri}, original attribution: ${attribution}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
|
if (object.published && !this.idService.isSafeT(new Date(object.published).valueOf())) {
|
||||||
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', 'invalid Note: published timestamp is malformed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (note) {
|
||||||
|
const url = (object.url) ? getOneApId(object.url) : note.url;
|
||||||
|
if (url && url !== note.url) {
|
||||||
|
return new IdentifiableError('d450b8a9-48e4-4dab-ae36-f4db763fda7c', `invalid Note: updated url does not match original url. updated url: ${url}, original url: ${note.url}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,14 +146,14 @@ export class ApNoteService {
|
|||||||
* Noteを作成します。
|
* Noteを作成します。
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async createNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
public async createNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
|
|
||||||
const object = await resolver.resolve(value);
|
const object = await resolver.resolve(value);
|
||||||
|
|
||||||
const entryUri = getApId(value);
|
const entryUri = getApId(value);
|
||||||
const err = this.validateNote(object, entryUri);
|
const err = this.validateNote(object, entryUri, actor);
|
||||||
if (err) {
|
if (err) {
|
||||||
this.logger.error(err.message, {
|
this.logger.error(err.message, {
|
||||||
resolver: { history: resolver.getHistory() },
|
resolver: { history: resolver.getHistory() },
|
||||||
@ -141,14 +167,24 @@ export class ApNoteService {
|
|||||||
|
|
||||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
if (note.id && !checkHttps(note.id)) {
|
if (note.id == null) {
|
||||||
|
throw new Error('Refusing to create note without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkHttps(note.id)) {
|
||||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = getOneApHrefNullable(note.url);
|
const url = getOneApHrefNullable(note.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (url != null) {
|
||||||
throw new Error('unexpected schema of note url: ' + url);
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of note url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(note.id)) {
|
||||||
|
throw new Error(`note url <> uri host mismatch: ${url} <> ${note.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Creating the Note: ${note.id}`);
|
this.logger.info(`Creating the Note: ${note.id}`);
|
||||||
@ -161,8 +197,9 @@ export class ApNoteService {
|
|||||||
const uri = getOneApId(note.attributedTo);
|
const uri = getOneApId(note.attributedTo);
|
||||||
|
|
||||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
||||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (cachedActor && cachedActor.isSuspended) {
|
actor ??= await this.apPersonService.fetchPerson(uri) as MiRemoteUser | undefined;
|
||||||
|
if (actor && actor.isSuspended) {
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,7 +231,8 @@ export class ApNoteService {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
actor ??= await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
||||||
|
|
||||||
// 解決した投稿者が凍結されていたらスキップ
|
// 解決した投稿者が凍結されていたらスキップ
|
||||||
if (actor.isSuspended) {
|
if (actor.isSuspended) {
|
||||||
@ -335,7 +373,7 @@ export class ApNoteService {
|
|||||||
* Noteを作成します。
|
* Noteを作成します。
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateNote(value: string | IObject, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
public async updateNote(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver, silent = false): Promise<MiNote | null> {
|
||||||
const noteUri = typeof value === 'string' ? value : value.id;
|
const noteUri = typeof value === 'string' ? value : value.id;
|
||||||
if (noteUri == null) throw new Error('uri is null');
|
if (noteUri == null) throw new Error('uri is null');
|
||||||
|
|
||||||
@ -346,6 +384,9 @@ export class ApNoteService {
|
|||||||
const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
const UpdatedNote = await this.notesRepository.findOneBy({ uri: noteUri });
|
||||||
if (UpdatedNote == null) throw new Error('Note is not registered');
|
if (UpdatedNote == null) throw new Error('Note is not registered');
|
||||||
|
|
||||||
|
const user = await this.usersRepository.findOneBy({ id: UpdatedNote.userId }) as MiRemoteUser | null;
|
||||||
|
if (user == null) throw new Error('Note is not registered');
|
||||||
|
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
|
|
||||||
@ -362,11 +403,19 @@ export class ApNoteService {
|
|||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `validateNote` checks that the actor and user are one and the same
|
||||||
|
// eslint-disable-next-line no-param-reassign
|
||||||
|
actor ??= user;
|
||||||
|
|
||||||
const note = object as IPost;
|
const note = object as IPost;
|
||||||
|
|
||||||
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
this.logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`);
|
||||||
|
|
||||||
if (note.id && !checkHttps(note.id)) {
|
if (note.id == null) {
|
||||||
|
throw new Error('Refusing to update note without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!checkHttps(note.id)) {
|
||||||
throw new Error('unexpected schema of note.id: ' + note.id);
|
throw new Error('unexpected schema of note.id: ' + note.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -376,18 +425,19 @@ export class ApNoteService {
|
|||||||
throw new Error('unexpected schema of note url: ' + url);
|
throw new Error('unexpected schema of note url: ' + url);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Creating the Note: ${note.id}`);
|
if (url != null) {
|
||||||
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of note url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
// 投稿者をフェッチ
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(note.id)) {
|
||||||
if (note.attributedTo == null) {
|
throw new Error(`note url <> id host mismatch: ${url} <> ${note.id}`);
|
||||||
throw new Error('invalid note.attributedTo: ' + note.attributedTo);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const uri = getOneApId(note.attributedTo);
|
this.logger.info(`Creating the Note: ${note.id}`);
|
||||||
|
|
||||||
// ローカルで投稿者を検索し、もし凍結されていたらスキップ
|
if (actor.isSuspended) {
|
||||||
const cachedActor = await this.apPersonService.fetchPerson(uri) as MiRemoteUser;
|
|
||||||
if (cachedActor && cachedActor.isSuspended) {
|
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -419,13 +469,6 @@ export class ApNoteService {
|
|||||||
}
|
}
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
const actor = cachedActor ?? await this.apPersonService.resolvePerson(uri, resolver) as MiRemoteUser;
|
|
||||||
|
|
||||||
// 投稿者が凍結されていたらスキップ
|
|
||||||
if (actor.isSuspended) {
|
|
||||||
throw new IdentifiableError('85ab9bd7-3a41-4530-959d-f07073900109', 'actor has been suspended');
|
|
||||||
}
|
|
||||||
|
|
||||||
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
const noteAudience = await this.apAudienceService.parseAudience(actor, note.to, note.cc, resolver);
|
||||||
let visibility = noteAudience.visibility;
|
let visibility = noteAudience.visibility;
|
||||||
const visibleUsers = noteAudience.visibleUsers;
|
const visibleUsers = noteAudience.visibleUsers;
|
||||||
@ -578,7 +621,7 @@ export class ApNoteService {
|
|||||||
if (exist) return exist;
|
if (exist) return exist;
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
if (uri.startsWith(this.config.url)) {
|
if (this.utilityService.isUriLocal(uri)) {
|
||||||
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
throw new StatusError('cannot resolve local note', 400, 'cannot resolve local note');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -586,7 +629,7 @@ export class ApNoteService {
|
|||||||
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
// ここでuriの代わりに添付されてきたNote Objectが指定されていると、サーバーフェッチを経ずにノートが生成されるが
|
||||||
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
// 添付されてきたNote Objectは偽装されている可能性があるため、常にuriを指定してサーバーフェッチを行う。
|
||||||
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
const createFrom = options.sentFrom?.origin === new URL(uri).origin ? value : uri;
|
||||||
return await this.createNote(createFrom, options.resolver, true);
|
return await this.createNote(createFrom, undefined, options.resolver, true);
|
||||||
} finally {
|
} finally {
|
||||||
unlock();
|
unlock();
|
||||||
}
|
}
|
||||||
|
@ -154,11 +154,24 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
throw new Error('invalid Actor: inbox has different host');
|
throw new Error('invalid Actor: inbox has different host');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sharedInboxObject = x.sharedInbox ?? (x.endpoints ? x.endpoints.sharedInbox : undefined);
|
||||||
|
if (sharedInboxObject != null) {
|
||||||
|
const sharedInbox = getApId(sharedInboxObject);
|
||||||
|
if (!(typeof sharedInbox === 'string' && sharedInbox.length > 0 && this.utilityService.punyHost(sharedInbox) === expectHost)) {
|
||||||
|
throw new Error('invalid Actor: wrong shared inbox');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
for (const collection of ['outbox', 'followers', 'following'] as (keyof IActor)[]) {
|
||||||
const collectionUri = (x as IActor)[collection];
|
const xCollection = (x as IActor)[collection];
|
||||||
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
if (xCollection != null) {
|
||||||
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
|
const collectionUri = getApId(xCollection);
|
||||||
throw new Error(`invalid Actor: ${collection} has different host`);
|
if (typeof collectionUri === 'string' && collectionUri.length > 0) {
|
||||||
|
if (this.utilityService.punyHost(collectionUri) !== expectHost) {
|
||||||
|
throw new Error(`invalid Actor: ${collection} has different host`);
|
||||||
|
}
|
||||||
|
} else if (collectionUri != null) {
|
||||||
|
throw new Error(`invalid Actor: wrong ${collection}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -286,7 +299,8 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
public async createPerson(uri: string, resolver?: Resolver): Promise<MiRemoteUser> {
|
||||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||||
|
|
||||||
if (uri.startsWith(this.config.url)) {
|
const host = this.utilityService.punyHost(uri);
|
||||||
|
if (host === this.utilityService.toPuny(this.config.host)) {
|
||||||
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
throw new StatusError('cannot resolve local user', 400, 'cannot resolve local user');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -300,8 +314,6 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
|
|
||||||
this.logger.info(`Creating the Person: ${person.id}`);
|
this.logger.info(`Creating the Person: ${person.id}`);
|
||||||
|
|
||||||
const host = this.utilityService.punyHost(object.id);
|
|
||||||
|
|
||||||
const fields = this.analyzeAttachments(person.attachment ?? []);
|
const fields = this.analyzeAttachments(person.attachment ?? []);
|
||||||
|
|
||||||
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
const tags = extractApHashtags(person.tag).map(normalizeForSearch).splice(0, 32);
|
||||||
@ -327,8 +339,18 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
|
|
||||||
const url = getOneApHrefNullable(person.url);
|
const url = getOneApHrefNullable(person.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (person.id == null) {
|
||||||
throw new Error('unexpected schema of person url: ' + url);
|
throw new Error('Refusing to create person without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url != null) {
|
||||||
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of person url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||||
|
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
// Create user
|
||||||
@ -480,7 +502,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
if (typeof uri !== 'string') throw new Error('uri is not string');
|
if (typeof uri !== 'string') throw new Error('uri is not string');
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (uri.startsWith(`${this.config.url}/`)) return;
|
if (this.utilityService.isUriLocal(uri)) return;
|
||||||
|
|
||||||
//#region このサーバーに既に登録されているか
|
//#region このサーバーに既に登録されているか
|
||||||
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
const exist = await this.fetchPerson(uri) as MiRemoteUser | null;
|
||||||
@ -529,8 +551,18 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
|
|
||||||
const url = getOneApHrefNullable(person.url);
|
const url = getOneApHrefNullable(person.url);
|
||||||
|
|
||||||
if (url && !checkHttps(url)) {
|
if (person.id == null) {
|
||||||
throw new Error('unexpected schema of person url: ' + url);
|
throw new Error('Refusing to update person without id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url != null) {
|
||||||
|
if (!checkHttps(url)) {
|
||||||
|
throw new Error('unexpected schema of person url: ' + url);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.utilityService.punyHost(url) !== this.utilityService.punyHost(person.id)) {
|
||||||
|
throw new Error(`person url <> uri host mismatch: ${url} <> ${person.id}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updates = {
|
const updates = {
|
||||||
@ -747,7 +779,7 @@ export class ApPersonService implements OnModuleInit {
|
|||||||
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
await this.updatePerson(src.movedToUri, undefined, undefined, [...movePreventUris, src.uri]);
|
||||||
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
dst = await this.fetchPerson(src.movedToUri) ?? dst;
|
||||||
} else {
|
} else {
|
||||||
if (src.movedToUri.startsWith(`${this.config.url}/`)) {
|
if (this.utilityService.isUriLocal(src.movedToUri)) {
|
||||||
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
// ローカルユーザーっぽいのにfetchPersonで見つからないということはmovedToUriが間違っている
|
||||||
return 'failed: movedTo is local but not found';
|
return 'failed: movedTo is local but not found';
|
||||||
}
|
}
|
||||||
|
@ -5,16 +5,18 @@
|
|||||||
|
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
import { DI } from '@/di-symbols.js';
|
import { DI } from '@/di-symbols.js';
|
||||||
import type { NotesRepository, PollsRepository } from '@/models/_.js';
|
import type { UsersRepository, NotesRepository, PollsRepository } from '@/models/_.js';
|
||||||
import type { Config } from '@/config.js';
|
import type { Config } from '@/config.js';
|
||||||
import type { IPoll } from '@/models/Poll.js';
|
import type { IPoll } from '@/models/Poll.js';
|
||||||
|
import type { MiRemoteUser } from '@/models/User.js';
|
||||||
import type Logger from '@/logger.js';
|
import type Logger from '@/logger.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { isQuestion } from '../type.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
|
import { getOneApId, isQuestion } from '../type.js';
|
||||||
import { ApLoggerService } from '../ApLoggerService.js';
|
import { ApLoggerService } from '../ApLoggerService.js';
|
||||||
import { ApResolverService } from '../ApResolverService.js';
|
import { ApResolverService } from '../ApResolverService.js';
|
||||||
import type { Resolver } from '../ApResolverService.js';
|
import type { Resolver } from '../ApResolverService.js';
|
||||||
import type { IObject, IQuestion } from '../type.js';
|
import type { IObject } from '../type.js';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ApQuestionService {
|
export class ApQuestionService {
|
||||||
@ -24,6 +26,9 @@ export class ApQuestionService {
|
|||||||
@Inject(DI.config)
|
@Inject(DI.config)
|
||||||
private config: Config,
|
private config: Config,
|
||||||
|
|
||||||
|
@Inject(DI.usersRepository)
|
||||||
|
private usersRepository: UsersRepository,
|
||||||
|
|
||||||
@Inject(DI.notesRepository)
|
@Inject(DI.notesRepository)
|
||||||
private notesRepository: NotesRepository,
|
private notesRepository: NotesRepository,
|
||||||
|
|
||||||
@ -32,6 +37,7 @@ export class ApQuestionService {
|
|||||||
|
|
||||||
private apResolverService: ApResolverService,
|
private apResolverService: ApResolverService,
|
||||||
private apLoggerService: ApLoggerService,
|
private apLoggerService: ApLoggerService,
|
||||||
|
private utilityService: UtilityService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.apLoggerService.logger;
|
this.logger = this.apLoggerService.logger;
|
||||||
}
|
}
|
||||||
@ -65,12 +71,12 @@ export class ApQuestionService {
|
|||||||
* @returns true if updated
|
* @returns true if updated
|
||||||
*/
|
*/
|
||||||
@bindThis
|
@bindThis
|
||||||
public async updateQuestion(value: string | IObject, resolver?: Resolver): Promise<boolean> {
|
public async updateQuestion(value: string | IObject, actor?: MiRemoteUser, resolver?: Resolver): Promise<boolean> {
|
||||||
const uri = typeof value === 'string' ? value : value.id;
|
const uri = typeof value === 'string' ? value : value.id;
|
||||||
if (uri == null) throw new Error('uri is null');
|
if (uri == null) throw new Error('uri is null');
|
||||||
|
|
||||||
// URIがこのサーバーを指しているならスキップ
|
// URIがこのサーバーを指しているならスキップ
|
||||||
if (uri.startsWith(this.config.url + '/')) throw new Error('uri points local');
|
if (this.utilityService.isUriLocal(uri)) throw new Error('uri points local');
|
||||||
|
|
||||||
//#region このサーバーに既に登録されているか
|
//#region このサーバーに既に登録されているか
|
||||||
const note = await this.notesRepository.findOneBy({ uri });
|
const note = await this.notesRepository.findOneBy({ uri });
|
||||||
@ -78,15 +84,26 @@ export class ApQuestionService {
|
|||||||
|
|
||||||
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
const poll = await this.pollsRepository.findOneBy({ noteId: note.id });
|
||||||
if (poll == null) throw new Error('Question is not registered');
|
if (poll == null) throw new Error('Question is not registered');
|
||||||
|
|
||||||
|
const user = await this.usersRepository.findOneBy({ id: poll.userId });
|
||||||
|
if (user == null) throw new Error('Question is not registered');
|
||||||
//#endregion
|
//#endregion
|
||||||
|
|
||||||
// resolve new Question object
|
// resolve new Question object
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
if (resolver == null) resolver = this.apResolverService.createResolver();
|
if (resolver == null) resolver = this.apResolverService.createResolver();
|
||||||
const question = await resolver.resolve(value) as IQuestion;
|
const question = await resolver.resolve(value);
|
||||||
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
this.logger.debug(`fetched question: ${JSON.stringify(question, null, 2)}`);
|
||||||
|
|
||||||
if (question.type !== 'Question') throw new Error('object is not a Question');
|
if (!isQuestion(question)) throw new Error('object is not a Question');
|
||||||
|
|
||||||
|
const attribution = (question.attributedTo) ? getOneApId(question.attributedTo) : user.uri;
|
||||||
|
const attributionMatchesExisting = attribution === user.uri;
|
||||||
|
const actorMatchesAttribution = (actor) ? attribution === actor.uri : true;
|
||||||
|
|
||||||
|
if (!attributionMatchesExisting || !actorMatchesAttribution) {
|
||||||
|
throw new Error('Refusing to ingest update for poll by different user');
|
||||||
|
}
|
||||||
|
|
||||||
const apChoices = question.oneOf ?? question.anyOf;
|
const apChoices = question.oneOf ?? question.anyOf;
|
||||||
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
if (apChoices == null) throw new Error('invalid apChoices: ' + apChoices);
|
||||||
@ -96,7 +113,7 @@ export class ApQuestionService {
|
|||||||
for (const choice of poll.choices) {
|
for (const choice of poll.choices) {
|
||||||
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
const oldCount = poll.votes[poll.choices.indexOf(choice)];
|
||||||
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
const newCount = apChoices.filter(ap => ap.name === choice).at(0)?.replies?.totalItems;
|
||||||
if (newCount == null) throw new Error('invalid newCount: ' + newCount);
|
if (newCount == null || !(Number.isInteger(newCount) && newCount >= 0)) throw new Error('invalid newCount: ' + newCount);
|
||||||
|
|
||||||
if (oldCount <= newCount) {
|
if (oldCount <= newCount) {
|
||||||
changed = true;
|
changed = true;
|
||||||
|
@ -17,6 +17,7 @@ import { DebounceLoader } from '@/misc/loader.js';
|
|||||||
import { IdService } from '@/core/IdService.js';
|
import { IdService } from '@/core/IdService.js';
|
||||||
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
import { ReactionsBufferingService } from '@/core/ReactionsBufferingService.js';
|
||||||
import type { OnModuleInit } from '@nestjs/common';
|
import type { OnModuleInit } from '@nestjs/common';
|
||||||
|
import type { CacheService } from '../CacheService.js';
|
||||||
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
import type { CustomEmojiService } from '../CustomEmojiService.js';
|
||||||
import type { ReactionService } from '../ReactionService.js';
|
import type { ReactionService } from '../ReactionService.js';
|
||||||
import type { UserEntityService } from './UserEntityService.js';
|
import type { UserEntityService } from './UserEntityService.js';
|
||||||
@ -27,6 +28,7 @@ import type { Config } from '@/config.js';
|
|||||||
export class NoteEntityService implements OnModuleInit {
|
export class NoteEntityService implements OnModuleInit {
|
||||||
private userEntityService: UserEntityService;
|
private userEntityService: UserEntityService;
|
||||||
private driveFileEntityService: DriveFileEntityService;
|
private driveFileEntityService: DriveFileEntityService;
|
||||||
|
private cacheService: CacheService;
|
||||||
private customEmojiService: CustomEmojiService;
|
private customEmojiService: CustomEmojiService;
|
||||||
private reactionService: ReactionService;
|
private reactionService: ReactionService;
|
||||||
private reactionsBufferingService: ReactionsBufferingService;
|
private reactionsBufferingService: ReactionsBufferingService;
|
||||||
@ -75,6 +77,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.userEntityService = this.moduleRef.get('UserEntityService');
|
this.userEntityService = this.moduleRef.get('UserEntityService');
|
||||||
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
this.driveFileEntityService = this.moduleRef.get('DriveFileEntityService');
|
||||||
|
this.cacheService = this.moduleRef.get('CacheService');
|
||||||
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
this.customEmojiService = this.moduleRef.get('CustomEmojiService');
|
||||||
this.reactionService = this.moduleRef.get('ReactionService');
|
this.reactionService = this.moduleRef.get('ReactionService');
|
||||||
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
|
this.reactionsBufferingService = this.moduleRef.get('ReactionsBufferingService');
|
||||||
@ -147,6 +150,12 @@ export class NoteEntityService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hide && meId && packedNote.userId !== meId) {
|
||||||
|
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(packedNote.userId);
|
||||||
|
|
||||||
|
if (isBlocked) hide = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (hide) {
|
if (hide) {
|
||||||
packedNote.visibleUserIds = undefined;
|
packedNote.visibleUserIds = undefined;
|
||||||
packedNote.fileIds = [];
|
packedNote.fileIds = [];
|
||||||
@ -154,6 +163,12 @@ export class NoteEntityService implements OnModuleInit {
|
|||||||
packedNote.text = null;
|
packedNote.text = null;
|
||||||
packedNote.poll = undefined;
|
packedNote.poll = undefined;
|
||||||
packedNote.cw = null;
|
packedNote.cw = null;
|
||||||
|
packedNote.repliesCount = 0;
|
||||||
|
packedNote.reactionAcceptance = null;
|
||||||
|
packedNote.reactionAndUserPairCache = undefined;
|
||||||
|
packedNote.reactionCount = 0;
|
||||||
|
packedNote.reactionEmojis = undefined;
|
||||||
|
packedNote.reactions = undefined;
|
||||||
packedNote.isHidden = true;
|
packedNote.isHidden = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -272,7 +287,8 @@ export class NoteEntityService implements OnModuleInit {
|
|||||||
return true;
|
return true;
|
||||||
} else {
|
} else {
|
||||||
// フォロワーかどうか
|
// フォロワーかどうか
|
||||||
const [following, user] = await Promise.all([
|
const [blocked, following, user] = await Promise.all([
|
||||||
|
this.cacheService.userBlockingCache.fetch(meId).then((ids) => ids.has(note.userId)),
|
||||||
this.followingsRepository.count({
|
this.followingsRepository.count({
|
||||||
where: {
|
where: {
|
||||||
followeeId: note.userId,
|
followeeId: note.userId,
|
||||||
@ -283,6 +299,8 @@ export class NoteEntityService implements OnModuleInit {
|
|||||||
this.usersRepository.findOneByOrFail({ id: meId }),
|
this.usersRepository.findOneByOrFail({ id: meId }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (blocked) return false;
|
||||||
|
|
||||||
/* If we know the following, everyhting is fine.
|
/* If we know the following, everyhting is fine.
|
||||||
|
|
||||||
But if we do not know the following, it might be that both the
|
But if we do not know the following, it might be that both the
|
||||||
@ -294,6 +312,12 @@ export class NoteEntityService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (meId != null) {
|
||||||
|
const isBlocked = (await this.cacheService.userBlockedCache.fetch(meId)).has(note.userId);
|
||||||
|
|
||||||
|
if (isBlocked) return false;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -192,6 +192,8 @@ export class InboxProcessorService implements OnApplicationShutdown {
|
|||||||
if (signerHost !== activityIdHost) {
|
if (signerHost !== activityIdHost) {
|
||||||
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
throw new Bull.UnrecoverableError(`skip: signerHost(${signerHost}) !== activity.id host(${activityIdHost}`);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
throw new Bull.UnrecoverableError('skip: activity id is not a string');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update stats
|
// Update stats
|
||||||
|
@ -152,7 +152,7 @@ export class ActivityPubServerService {
|
|||||||
let signature;
|
let signature;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// not signed, or malformed signature: refuse
|
// not signed, or malformed signature: refuse
|
||||||
this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`);
|
this.authlogger.warn(`${request.id} ${request.url} not signed, or malformed signature: refuse`);
|
||||||
@ -229,7 +229,7 @@ export class ActivityPubServerService {
|
|||||||
let signature;
|
let signature;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
signature = httpSignature.parseRequest(request.raw, { 'headers': [] });
|
signature = httpSignature.parseRequest(request.raw, { 'headers': ['(request-target)', 'digest', 'host', 'date'], authorizationHeaderName: 'signature' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
reply.code(401);
|
reply.code(401);
|
||||||
return;
|
return;
|
||||||
|
@ -28,7 +28,11 @@ import { bindThis } from '@/decorators.js';
|
|||||||
import { isMimeImage } from '@/misc/is-mime-image.js';
|
import { isMimeImage } from '@/misc/is-mime-image.js';
|
||||||
import { correctFilename } from '@/misc/correct-filename.js';
|
import { correctFilename } from '@/misc/correct-filename.js';
|
||||||
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
|
import { handleRequestRedirectToOmitSearch } from '@/misc/fastify-hook-handlers.js';
|
||||||
|
import { RateLimiterService } from '@/server/api/RateLimiterService.js';
|
||||||
|
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||||
|
import { AuthenticateService } from '@/server/api/AuthenticateService.js';
|
||||||
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyPluginOptions } from 'fastify';
|
||||||
|
import type Limiter from 'ratelimiter';
|
||||||
|
|
||||||
const _filename = fileURLToPath(import.meta.url);
|
const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
@ -52,6 +56,8 @@ export class FileServerService {
|
|||||||
private videoProcessingService: VideoProcessingService,
|
private videoProcessingService: VideoProcessingService,
|
||||||
private internalStorageService: InternalStorageService,
|
private internalStorageService: InternalStorageService,
|
||||||
private loggerService: LoggerService,
|
private loggerService: LoggerService,
|
||||||
|
private authenticateService: AuthenticateService,
|
||||||
|
private rateLimiterService: RateLimiterService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('server', 'gray');
|
this.logger = this.loggerService.getLogger('server', 'gray');
|
||||||
|
|
||||||
@ -76,6 +82,8 @@ export class FileServerService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => {
|
fastify.get<{ Params: { key: string; } }>('/files/:key', async (request, reply) => {
|
||||||
|
if (!await this.checkRateLimit(request, reply, `/files/${request.params.key}`)) return;
|
||||||
|
|
||||||
return await this.sendDriveFile(request, reply)
|
return await this.sendDriveFile(request, reply)
|
||||||
.catch(err => this.errorHandler(request, reply, err));
|
.catch(err => this.errorHandler(request, reply, err));
|
||||||
});
|
});
|
||||||
@ -89,6 +97,20 @@ export class FileServerService {
|
|||||||
Params: { url: string; };
|
Params: { url: string; };
|
||||||
Querystring: { url?: string; };
|
Querystring: { url?: string; };
|
||||||
}>('/proxy/:url*', async (request, reply) => {
|
}>('/proxy/:url*', async (request, reply) => {
|
||||||
|
const url = 'url' in request.query ? request.query.url : 'https://' + request.params.url;
|
||||||
|
if (!url || !URL.canParse(url)) {
|
||||||
|
reply.code(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyUrl = new URL(url);
|
||||||
|
keyUrl.searchParams.forEach(k => keyUrl.searchParams.delete(k));
|
||||||
|
keyUrl.hash = '';
|
||||||
|
keyUrl.username = '';
|
||||||
|
keyUrl.password = '';
|
||||||
|
|
||||||
|
if (!await this.checkRateLimit(request, reply, `/proxy/${keyUrl}`)) return;
|
||||||
|
|
||||||
return await this.proxyHandler(request, reply)
|
return await this.proxyHandler(request, reply)
|
||||||
.catch(err => this.errorHandler(request, reply, err));
|
.catch(err => this.errorHandler(request, reply, err));
|
||||||
});
|
});
|
||||||
@ -572,4 +594,71 @@ export class FileServerService {
|
|||||||
path,
|
path,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Based on ApiCallService
|
||||||
|
private async checkRateLimit(
|
||||||
|
request: FastifyRequest<{
|
||||||
|
Body?: Record<string, unknown> | undefined,
|
||||||
|
Querystring?: Record<string, unknown> | undefined,
|
||||||
|
Params?: Record<string, unknown> | unknown,
|
||||||
|
}>,
|
||||||
|
reply: FastifyReply,
|
||||||
|
rateLimitKey: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const body = request.method === 'GET'
|
||||||
|
? request.query
|
||||||
|
: request.body;
|
||||||
|
|
||||||
|
// https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive)
|
||||||
|
const token = request.headers.authorization?.startsWith('Bearer ')
|
||||||
|
? request.headers.authorization.slice(7)
|
||||||
|
: body?.['i'];
|
||||||
|
if (token != null && typeof token !== 'string') {
|
||||||
|
reply.code(400);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// koa will automatically load the `X-Forwarded-For` header if `proxy: true` is configured in the app.
|
||||||
|
const [user] = await this.authenticateService.authenticate(token);
|
||||||
|
const actor = user?.id ?? getIpHash(request.ip);
|
||||||
|
|
||||||
|
const limit = {
|
||||||
|
// Group by resource
|
||||||
|
key: rateLimitKey,
|
||||||
|
|
||||||
|
// Maximum of 10 requests / 10 minutes
|
||||||
|
max: 10,
|
||||||
|
duration: 1000 * 60 * 10,
|
||||||
|
|
||||||
|
// Minimum of 250 ms between each request
|
||||||
|
minInterval: 250,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limit proxy requests
|
||||||
|
try {
|
||||||
|
await this.rateLimiterService.limit(limit, actor);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
// errはLimiter.LimiterInfoであることが期待される
|
||||||
|
reply.code(429);
|
||||||
|
|
||||||
|
if (hasRateLimitInfo(err)) {
|
||||||
|
const cooldownInSeconds = Math.ceil((err.info.resetMs - Date.now()) / 1000);
|
||||||
|
// もしかするとマイナスになる可能性がなくはないのでマイナスだったら0にしておく
|
||||||
|
reply.header('Retry-After', Math.max(cooldownInSeconds, 0).toString(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.send({
|
||||||
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasRateLimitInfo(err: unknown): err is { info: Limiter.LimiterInfo } {
|
||||||
|
return err != null && typeof(err) === 'object' && 'info' in err;
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,7 @@ import { ApResolverService } from '@/core/activitypub/ApResolverService.js';
|
|||||||
export const meta = {
|
export const meta = {
|
||||||
tags: ['federation'],
|
tags: ['federation'],
|
||||||
|
|
||||||
|
requireAdmin: true,
|
||||||
requireCredential: true,
|
requireCredential: true,
|
||||||
kind: 'read:federation',
|
kind: 'read:federation',
|
||||||
|
|
||||||
|
@ -140,7 +140,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||||||
return await this.mergePack(
|
return await this.mergePack(
|
||||||
me,
|
me,
|
||||||
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
|
isActor(object) ? await this.apPersonService.createPerson(getApId(object)) : null,
|
||||||
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, true) : null,
|
isPost(object) ? await this.apNoteService.createNote(getApId(object), undefined, undefined, true) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,6 +170,6 @@ export class UrlPreviewService {
|
|||||||
contentLengthRequired: meta.urlPreviewRequireContentLength,
|
contentLengthRequired: meta.urlPreviewRequireContentLength,
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`);
|
return this.httpRequestService.getJson<SummalyResult>(`${proxy}?${queryStr}`, 'application/json, */*', undefined, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -176,7 +176,7 @@ describe('ActivityPub', () => {
|
|||||||
resolver.register(actor.id, actor);
|
resolver.register(actor.id, actor);
|
||||||
resolver.register(post.id, post);
|
resolver.register(post.id, post);
|
||||||
|
|
||||||
const note = await noteService.createNote(post.id, resolver, true);
|
const note = await noteService.createNote(post.id, undefined, resolver, true);
|
||||||
|
|
||||||
assert.deepStrictEqual(note?.uri, post.id);
|
assert.deepStrictEqual(note?.uri, post.id);
|
||||||
assert.deepStrictEqual(note.visibility, 'public');
|
assert.deepStrictEqual(note.visibility, 'public');
|
||||||
@ -336,7 +336,7 @@ describe('ActivityPub', () => {
|
|||||||
resolver.register(actor.featured, featured);
|
resolver.register(actor.featured, featured);
|
||||||
resolver.register(firstNote.id, firstNote);
|
resolver.register(firstNote.id, firstNote);
|
||||||
|
|
||||||
const note = await noteService.createNote(firstNote.id as string, resolver);
|
const note = await noteService.createNote(firstNote.id as string, undefined, resolver);
|
||||||
assert.strictEqual(note?.uri, firstNote.id);
|
assert.strictEqual(note?.uri, firstNote.id);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user