import config from "../../config/index.js"; import { getJsonActivity } from "../../misc/fetch.js"; import { getInstanceActor } from "../../services/instance-actor.js"; import { fetchMeta } from "../../misc/fetch-meta.js"; import { extractDbHost, isSelfHost } from "../../misc/convert-host.js"; import { signedGet } from "./request.js"; import { isCollectionOrOrderedCollection, getApId } from "./type.js"; import { FollowRequests, Notes, NoteReactions, Polls, Users, Bites, InteractionStamps } from "../../models/index.js"; import { parseUri } from "./db-resolver.js"; import renderNote from "./renderer/note.js"; import { renderLike } from "./renderer/like.js"; import { renderPerson } from "./renderer/person.js"; import renderQuestion from "./renderer/question.js"; import renderCreate from "./renderer/create.js"; import { renderActivity } from "./renderer/index.js"; import renderFollow from "./renderer/follow.js"; import { shouldBlockInstance } from "../../misc/should-block-instance.js"; import { apLogger } from "./logger.js"; import { IsNull, Not } from "typeorm"; import { tickResolve } from "../../metrics.js"; import renderBite from "./renderer/bite.js"; import renderQuoteAuthorization from "./renderer/quote-authorization.js"; export default class Resolver { history; user; recursionLimit; constructor(recursionLimit = 100){ this.history = new Set(); this.recursionLimit = recursionLimit; } setUser(user) { this.user = user; } reset() { this.history = new Set(); return this; } getHistory() { return Array.from(this.history); } async resolveCollection(value) { const collection = await this.resolve(value); if (isCollectionOrOrderedCollection(collection)) { return collection; } else { throw new Error(`unrecognized collection type: ${collection.type}`); } } async resolve(value) { if (value == null) { throw new Error("resolvee is null (or undefined)"); } if (typeof value !== "string") { apLogger.debug("Object to resolve is not a string"); if (typeof value.id !== "undefined") { const host = extractDbHost(getApId(value)); if (await shouldBlockInstance(host)) { throw new Error("instance is blocked"); } } apLogger.debug("Returning existing object:"); apLogger.debug(JSON.stringify(value, null, 2)); return value; } apLogger.debug(`Resolving: ${value}`); if (value.includes("#")) { // URLs with fragment parts cannot be resolved correctly because // the fragment part does not get transmitted over HTTP(S). // Avoid strange behaviour by not trying to resolve these at all. throw new Error(`cannot resolve URL with fragment: ${value}`); } if (this.history.has(value)) { throw new Error("cannot resolve already resolved one"); } if (this.recursionLimit && this.history.size > this.recursionLimit) { throw new Error("hit recursion limit"); } this.history.add(value); const host = extractDbHost(value); if (isSelfHost(host)) { return await this.resolveLocal(value); } const meta = await fetchMeta(); if (await shouldBlockInstance(host, meta)) { throw new Error("Instance is blocked"); } if (meta.privateMode && config.host !== host && config.domain !== host && !meta.allowedHosts.includes(host)) { throw new Error("Instance is not allowed"); } if (!this.user) { this.user = await getInstanceActor(); } apLogger.debug("Getting object from remote, authenticated as user:"); apLogger.debug(JSON.stringify(this.user, null, 2)); const { res, object } = await this.doFetch(value); if (object.id == null) throw new Error("Object has no ID"); const objectId = new URL(object.id); const resFinalUrl = new URL(res.finalUrl); if (resFinalUrl.toString() === objectId.toString()) { tickResolve(); return object; } if (resFinalUrl.host !== objectId.host) throw new Error("Object ID host doesn't match final url host"); const { res: finalRes, object: finalObject } = await this.doFetch(object.id); if (finalObject.id == null) throw new Error("Final object has no ID"); const finalObjectId = new URL(finalObject.id); const finalResFinalUrl = new URL(finalRes.finalUrl); if (finalResFinalUrl.toString() !== finalObjectId.toString()) throw new Error("Object ID still doesn't match final URL after second fetch attempt"); tickResolve(); return finalObject; } async doFetch(uri) { let res = this.user ? await signedGet(uri, this.user) : await getJsonActivity(uri); let object = res.content; if (object == null || (Array.isArray(object["@context"]) ? !object["@context"].includes("https://www.w3.org/ns/activitystreams") : object["@context"] !== "https://www.w3.org/ns/activitystreams")) { throw new Error("invalid response"); } return { res, object }; } async resolveLocal(url) { const parsed = parseUri(url); if (!parsed.local) throw new Error("resolveLocal: not local"); switch(parsed.type){ case "notes": { const note = await Notes.findOneByOrFail({ id: parsed.id }); if (parsed.rest === "activity") { // this refers to the create activity and not the note itself return renderActivity(renderCreate(await renderNote(note), note)); } else { return renderActivity(await renderNote(note)); } } case "users": { const user = await Users.findOneByOrFail({ id: parsed.id }); return await renderPerson(user); } case "questions": { // Polls are indexed by the note they are attached to. const [note, poll] = await Promise.all([ Notes.findOneByOrFail({ id: parsed.id }), Polls.findOneByOrFail({ noteId: parsed.id }) ]); return renderActivity(await renderQuestion({ id: note.userId }, note, poll)); } case "likes": { const reaction = await NoteReactions.findOneByOrFail({ id: parsed.id }); return renderActivity(await renderLike(reaction, { uri: null })); } case "follows": { // if rest is a if (parsed.rest != null && /^\w+$/.test(parsed.rest)) { const follower = await Users.findOneByOrFail({ id: parsed.id }); const followee = await Users.findOneByOrFail({ id: parsed.rest }); return renderActivity(renderFollow(follower, followee, url)); } // Another situation is there is only requestId, then obtained object from database. const followRequest = await FollowRequests.findOneBy({ id: parsed.id }); if (followRequest == null) { throw new Error("resolveLocal: invalid follow URI"); } const follower = await Users.findOneBy({ id: followRequest.followerId, host: IsNull() }); const followee = await Users.findOneBy({ id: followRequest.followeeId, host: Not(IsNull()) }); if (follower == null || followee == null) { throw new Error("resolveLocal: invalid follow URI"); } return renderActivity(renderFollow(follower, followee, url)); } case "bites": { const bite = await Bites.findOneOrFail({ where: { id: parsed.id }, relations: [ "targetUser", "targetBite", "targetNote" ] }); return renderActivity(await renderBite(bite)); } case "stamp": { const stamp = await InteractionStamps.findOneOrFail({ where: { id: parsed.id }, relations: [ "note", "targetNote" ] }); return renderActivity(renderQuoteAuthorization(stamp)); } default: throw new Error(`resolveLocal: type ${parsed.type} unhandled`); } } }