import promiseLimit from "promise-limit"; import * as mfm from "mfm-js"; import config from "../../../config/index.js"; import Resolver from "../resolver.js"; import post from "../../../services/note/create.js"; import { extractMentionedUsers } from "../../../services/note/create.js"; import { resolvePerson } from "./person.js"; import { resolveImage } from "./image.js"; import { htmlToMfm } from "../misc/html-to-mfm.js"; import { extractApHashtags } from "./tag.js"; import { unique, toArray, toSingle } from "../../../prelude/array.js"; import { extractPollFromQuestion } from "./question.js"; import vote from "../../../services/note/polls/vote.js"; import { apLogger } from "../logger.js"; import { extractDbHost, toPuny } from "../../../misc/convert-host.js"; import { Emojis, Polls, MessagingMessages, Notes, NoteEdits, DriveFiles, PollVotes } from "../../../models/index.js"; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from "../type.js"; import { genId } from "../../../misc/gen-id.js"; import { getApLock } from "../../../misc/app-lock.js"; import { createMessage } from "../../../services/messages/create.js"; import { parseAudience } from "../audience.js"; import { extractApMentions } from "./mention.js"; import DbResolver from "../db-resolver.js"; import { StatusError } from "../../../misc/fetch.js"; import { shouldBlockInstance } from "../../../misc/should-block-instance.js"; import { publishNoteStream, publishNoteUpdatesStream } from "../../../services/stream.js"; import { extractHashtags } from "../../../misc/extract-hashtags.js"; import { UserProfiles } from "../../../models/index.js"; import { In } from "typeorm"; import { DB_MAX_IMAGE_COMMENT_LENGTH } from "../../../misc/hard-limits.js"; import { truncate } from "../../../misc/truncate.js"; import { getEmojiSize } from "../../../misc/emoji-meta.js"; import { RecursionLimiter } from "../../../models/repositories/user-profile.js"; const logger = apLogger; export function validateNote(object, uri) { const expectHost = extractDbHost(uri); if (object == null) { return new Error("invalid Note: object is null"); } if (!validPost.includes(getApType(object))) { return new Error(`invalid Note: invalid object type ${getApType(object)}`); } if (object.id && extractDbHost(object.id) !== expectHost) { return new Error(`invalid Note: id has different host. expected: ${expectHost}, actual: ${extractDbHost(object.id)}`); } if (object.attributedTo && extractDbHost(getOneApId(object.attributedTo)) !== expectHost) { return new Error(`invalid Note: attributedTo has different host. expected: ${expectHost}, actual: ${extractDbHost(object.attributedTo)}`); } return null; } /** * Fetch Notes. * * If the target Note is registered in Iceshrimp, it will be returned. */ export async function fetchNote(object) { const dbResolver = new DbResolver(); return await dbResolver.getNoteFromApId(object); } /** * Create a Note. */ export async function createNote(value, resolver, silent = false, limiter = new RecursionLimiter()) { if (resolver == null) resolver = new Resolver(); const object = await resolver.resolve(value); const entryUri = getApId(value); const err = validateNote(object, entryUri); if (err) { logger.error(`${err.message}`, { resolver: { history: resolver.getHistory() }, value: value, object: object }); throw new Error("invalid note"); } const note = object; if (note.id == null) { throw new Error('Note must have an id'); } const idUrl = new URL(note.id); if (idUrl.protocol != 'https:') { throw new Error(`unexpected schema of note.id: ${note.id}`); } let url = getOneApHrefNullable(note.url); const urlUrl = url != null ? new URL(url) : null; if (urlUrl != null && urlUrl.protocol != 'https:') { throw new Error(`unexpected schema of note url: ${url}`); } logger.debug(`Note fetched: ${JSON.stringify(note, null, 2)}`); logger.info(`Creating the Note: ${note.id}`); // Skip if note is made before 2007 (1yr before Fedi was created) // OR skip if note is made 3 days in advance if (note.published) { const DateChecker = new Date(note.published); const FutureCheck = new Date(); FutureCheck.setDate(FutureCheck.getDate() + 3); // Allow some wiggle room for misconfigured hosts if (DateChecker.getFullYear() < 2007) { logger.warn("Note somehow made before Activitypub was created; discarding"); return null; } if (DateChecker > FutureCheck) { logger.warn("Note somehow made after today; discarding"); return null; } } // Fetch author const actor = await resolvePerson(getOneApId(note.attributedTo), resolver, limiter); if (actor.uri == null) { logger.warn('Note actor uri is null, discarding'); return null; } const actorUri = new URL(actor.uri); if (idUrl.host != actorUri.host) { logger.warn("Note id host doesn't match actor host, discarding"); return null; } if (urlUrl != null && urlUrl.host != actorUri.host) { logger.debug("Note url host doesn't match actor host, clearing variable"); url = undefined; } // Skip if author is suspended. if (actor.isSuspended) { logger.debug(`User ${actor.usernameLower}@${actor.host} suspended; discarding.`); return null; } const noteAudience = await parseAudience(actor, note.to, note.cc, undefined, limiter); let visibility = noteAudience.visibility; const visibleUsers = noteAudience.visibleUsers; // If Audience (to, cc) was not specified if (visibility === "specified" && visibleUsers.length === 0) { if (typeof value === "string") { // If the input is a string, GET occurs in resolver // Public if you can GET anonymously from here visibility = "public"; } } let isTalk = note._misskey_talk && visibility === "specified"; const apMentions = await extractApMentions(note.tag, limiter); const apHashtags = extractApHashtags(note.tag); // Attachments // TODO: attachmentは必ずしもImageではない // TODO: attachmentは必ずしも配列ではない // Noteがsensitiveなら添付もsensitiveにする const limit = promiseLimit(2); note.attachment = Array.isArray(note.attachment) ? note.attachment : note.attachment ? [ note.attachment ] : []; note.attachment = note.attachment.filter((attach)=>[ "Document", "Image", "Audio", "Video" ].includes(attach.type)); const files = note.attachment.map((attach)=>attach.sensitive = note.sensitive) ? (await Promise.all(note.attachment.map((x)=>limit(()=>resolveImage(actor, x))))).filter((image)=>image != null) : []; // Reply const reply = note.inReplyTo ? await resolveNote(note.inReplyTo, resolver, limiter).then((x)=>{ if (x == null) { logger.warn("Specified inReplyTo, but nout found"); throw new Error("inReplyTo not found"); } else { return x; } }).catch(async (e)=>{ // トークだったらinReplyToのエラーは無視 const uri = getApId(note.inReplyTo); if (uri.startsWith(`${config.url}/`)) { const id = uri.split("/").pop(); const talk = await MessagingMessages.findOneBy({ id }); if (talk) { isTalk = true; return null; } } logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${e.statusCode || e}`); throw e; }) : null; // Quote let quote; if (note._misskey_quote || note.quoteUrl || note.quoteUri || note.quote) { const tryResolveNote = async (uri)=>{ if (typeof uri !== "string" || !uri.match(/^https?:/)) return { status: "permerror" }; try { const res = await resolveNote(uri, undefined, limiter); if (res) { return { status: "ok", res }; } else { return { status: "permerror" }; } } catch (e) { return { status: e instanceof StatusError && !e.isRetryable ? "permerror" : "temperror" }; } }; const uris = unique([ note._misskey_quote, note.quoteUrl, note.quoteUri, note.quote ].filter((x)=>typeof x === "string")); const results = await Promise.all(uris.map((uri)=>tryResolveNote(uri))); quote = results.filter((x)=>x.status === "ok").map((x)=>x.res).find((x)=>x); if (!quote) { if (results.some((x)=>x.status === "temperror")) { throw new Error("quote resolve failed"); } } } const cw = note.summary === "" ? null : note.summary; // Text parsing let text = null; if (note.source?.mediaType === "text/x.misskeymarkdown" && typeof note.source?.content === "string") { text = note.source.content; } else if (typeof note._misskey_content !== "undefined") { text = note._misskey_content; } else if (typeof note.content === "string") { text = await htmlToMfm(note.content, note.tag); } // vote if (reply?.hasPoll) { const poll = await Polls.findOneByOrFail({ noteId: reply.id }); const tryCreateVote = async (name, index)=>{ if (poll.expiresAt && Date.now() > new Date(poll.expiresAt).getTime()) { logger.warn(`vote to expired poll from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); } else if (index >= 0) { logger.info(`vote from AP: actor=${actor.username}@${actor.host}, note=${note.id}, choice=${name}`); await vote(actor, reply, index); } return null; }; if (note.name) { return await tryCreateVote(note.name, poll.choices.findIndex((x)=>x === note.name)); } } const emojis = await extractEmojis(note.tag || [], actor.host).catch((e)=>{ logger.info(`extractEmojis: ${e}`); return []; }); const apEmojis = emojis.map((emoji)=>emoji.name); const poll = await extractPollFromQuestion(note, resolver).catch(()=>undefined); if (isTalk) { for (const recipient of visibleUsers){ await createMessage(actor, recipient, undefined, text || undefined, files && files.length > 0 ? files[0] : null, object.id); return null; } } return await post(actor, { createdAt: note.published ? new Date(note.published) : null, files, reply, renote: quote, name: note.name, cw, text, localOnly: false, visibility, visibleUsers, apMentions, apHashtags, apEmojis, poll, uri: note.id, url: url, canQuote: !!note.interactionPolicy?.canQuote }, silent, limiter); } /** * Resolve Note. * * If the target Note is registered in Iceshrimp, return it, otherwise * Fetch from remote server, register with Iceshrimp and return it. */ export async function resolveNote(value, resolver, limiter = new RecursionLimiter()) { const uri = typeof value === "string" ? value : value.id; if (uri == null) throw new Error("missing uri"); // Abort if origin host is blocked if (await shouldBlockInstance(extractDbHost(uri))) throw new StatusError("host blocked", 451, `host ${extractDbHost(uri)} is blocked`); const unlock = await getApLock(uri); try { //#region Returns if already registered with this server const exist = await fetchNote(uri); if (exist) { return exist; } //#endregion if (extractDbHost(uri) === toPuny(config.host)) { throw new StatusError("cannot resolve local note", 400, "cannot resolve local note"); } // Fetch from remote server and register // If the attached `Note` Object is specified here instead of the uri, the note will be generated without going through the server fetch. // Since the attached Note Object may be disguised, always specify the uri and fetch it from the server. return await createNote(uri, resolver, true, limiter); } finally{ unlock(); } } export async function extractEmojis(tags, host) { host = toPuny(host); if (!tags) return []; const eomjiTags = toArray(tags).filter(isEmoji); return await Promise.all(eomjiTags.map(async (tag)=>{ const name = tag.name.replace(/^:/, "").replace(/:$/, ""); tag.icon = toSingle(tag.icon); const exists = await Emojis.findOneBy({ host, name }); if (exists) { if (tag.updated != null && exists.updatedAt == null || tag.id != null && exists.uri == null || tag.updated != null && exists.updatedAt != null && new Date(tag.updated) > exists.updatedAt || tag.icon.url !== exists.originalUrl || !(exists.width && exists.height)) { let size = { width: 0, height: 0 }; try { size = await getEmojiSize(tag.icon.url); } catch { /* skip if any error happens */ } await Emojis.update({ host, name }, { uri: tag.id, originalUrl: tag.icon.url, publicUrl: tag.icon.url, updatedAt: new Date(), width: size.width || null, height: size.height || null }); return await Emojis.findOneBy({ host, name }); } return exists; } logger.info(`register emoji host=${host}, name=${name}`); let size = { width: 0, height: 0 }; try { size = await getEmojiSize(tag.icon.url); } catch { /* skip if any error happens */ } return await Emojis.insert({ id: genId(), host, name, uri: tag.id, originalUrl: tag.icon.url, publicUrl: tag.icon.url, updatedAt: new Date(), aliases: [], glyph: tag.icon?.type === "image/svg+xml", width: size.width || null, height: size.height || null }).then((x)=>Emojis.findOneByOrFail(x.identifiers[0])); })); } function notEmpty(partial) { return Object.keys(partial).length > 0; } export async function updateNote(value, actor, resolver) { const uri = typeof value === "string" ? value : value.id; if (!uri) throw new Error("Missing note uri"); // Skip if URI points to this server if (extractDbHost(uri) === toPuny(config.host)) throw new Error("uri points local"); // A new resolver is created if not specified if (resolver == null) resolver = new Resolver(); // Resolve the updated Note object const post1 = await resolver.resolve(value); if (getOneApId(post1.attributedTo) !== actor.uri || actor.uri == null) { throw new Error('Refusing to ingest update for note with mismatching actor'); } // Already registered with this server? const note = await Notes.findOneBy({ uri }); if (note == null) { return await createNote(post1, resolver); } if (note.userId !== actor.id) { throw new Error('Refusing to ingest update for note of different user'); } // Whether to tell clients the note has been updated and requires refresh. let updating = false; // Text parsing let text = null; if (post1.source?.mediaType === "text/x.misskeymarkdown" && typeof post1.source?.content === "string") { text = post1.source.content; } else if (typeof post1._misskey_content !== "undefined") { text = post1._misskey_content; } else if (typeof post1.content === "string") { text = await htmlToMfm(post1.content, post1.tag); } const cw = post1.summary === "" ? null : post1.summary; // File parsing const fileList = post1.attachment ? Array.isArray(post1.attachment) ? post1.attachment : [ post1.attachment ] : []; // Fetch files const limit = promiseLimit(2); const driveFiles = (await Promise.all(fileList.map((x)=>limit(async ()=>{ const file = await resolveImage(actor, x); const update = {}; const altText = truncate(x.name, DB_MAX_IMAGE_COMMENT_LENGTH) ?? null; if (file.comment !== altText) { update.comment = altText; } // Don't unmark previously marked sensitive files, // but if edited post contains sensitive marker, update it. if (post1.sensitive && !file.isSensitive) { update.isSensitive = post1.sensitive; } if (notEmpty(update)) { await DriveFiles.update(file.id, update); updating = true; } return file; })))).filter((file)=>file != null); const fileIds = driveFiles.map((file)=>file.id); const fileTypes = driveFiles.map((file)=>file.type); const apEmojis = (await extractEmojis(post1.tag || [], actor.host).catch((e)=>[])).map((emoji)=>emoji.name); const apMentions = await extractApMentions(post1.tag); const apHashtags = await extractApHashtags(post1.tag); const poll = await extractPollFromQuestion(post1, resolver).catch(()=>undefined); const choices = poll?.choices.flatMap((choice)=>mfm.parse(choice)) ?? []; const tokens = mfm.parse(text || "").concat(mfm.parse(cw || "")).concat(choices); const hashTags = apHashtags || extractHashtags(tokens); const mentionUsers = apMentions || await extractMentionedUsers(actor, tokens); const mentionUserIds = mentionUsers.map((user)=>user.id); const remoteUsers = mentionUsers.filter((user)=>user.host != null); const remoteUserIds = remoteUsers.map((user)=>user.id); const remoteProfiles = await UserProfiles.findBy({ userId: In(remoteUserIds) }); const mentionedRemoteUsers = remoteUsers.map((user)=>{ const profile = remoteProfiles.find((profile)=>profile.userId === user.id); return { username: user.username, host: user.host ?? null, uri: user.uri, url: profile ? profile.url : undefined }; }); const update = {}; if (text && text !== note.text) { update.text = text; } if (cw !== note.cw) { update.cw = cw ? cw : null; } if (fileIds.sort().join(",") !== note.fileIds.sort().join(",")) { update.fileIds = fileIds; update.attachedFileTypes = fileTypes; } if (hashTags.sort().join(",") !== note.tags.sort().join(",")) { update.tags = hashTags; } if (mentionUserIds.sort().join(",") !== note.mentions.sort().join(",")) { update.mentions = mentionUserIds; update.mentionedRemoteUsers = JSON.stringify(mentionedRemoteUsers); } if (apEmojis.sort().join(",") !== note.emojis.sort().join(",")) { update.emojis = apEmojis; } if (note.hasPoll !== !!poll) { update.hasPoll = !!poll; } if (poll) { const dbPoll = await Polls.findOneBy({ noteId: note.id }); if (poll?.votes != null && poll.votes.find((p)=>!Number.isInteger(p) || p < 0) !== undefined) { throw new Error('Refusing to ingest poll with non-integer or negative vote count'); } if (dbPoll == null) { await Polls.insert({ noteId: note.id, choices: poll?.choices, multiple: poll?.multiple, votes: poll?.votes, expiresAt: poll?.expiresAt, noteVisibility: note.visibility === "hidden" ? "home" : note.visibility, userId: actor.id, userHost: actor.host }); updating = true; } else { const choicesChanged = JSON.stringify(dbPoll.choices) !== JSON.stringify(poll.choices); if (dbPoll.multiple !== poll.multiple || dbPoll.expiresAt !== poll.expiresAt || dbPoll.noteVisibility !== note.visibility || choicesChanged) { await Polls.update({ noteId: note.id }, { choices: poll?.choices, multiple: poll?.multiple, votes: poll?.votes, expiresAt: poll?.expiresAt, noteVisibility: note.visibility === "hidden" ? "home" : note.visibility }); // Reset votes if (choicesChanged) { await PollVotes.delete({ noteId: dbPoll.noteId }); } updating = true; } else { for(let i = 0; i < poll.choices.length; i++){ if (dbPoll.votes[i] !== poll.votes?.[i]) { await Polls.update({ noteId: note.id }, { votes: poll?.votes }); updating = true; break; } } } } } // Update Note if (notEmpty(update)) { update.updatedAt = new Date(); // Save updated note to the database await Notes.update({ uri }, update); // Save an edit history for the previous note await NoteEdits.insert({ id: genId(), noteId: note.id, text: note.text, cw: note.cw, fileIds: note.fileIds, updatedAt: update.updatedAt }); updating = true; } if (updating) { // Publish update event for the updated note details publishNoteStream(note.id, "updated", { updatedAt: update.updatedAt }); const updatedNote = { ...note, ...update }; publishNoteUpdatesStream("updated", updatedNote); } return null; }