Files
2026-07-26 18:25:37 +09:00

564 lines
22 KiB
JavaScript

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;
}