(hashtagNames.map(x => normalizeForSearch(x)));
- const dom = parse5.parseFragment(html);
+ const dom = htmlparser2.parseDocument(html);
let text = '';
@@ -51,57 +48,50 @@ export class MfmService {
return text.trim();
function getText(node: Node): string {
- if (treeAdapter.isTextNode(node)) return node.value;
- if (!treeAdapter.isElementNode(node)) return '';
- if (node.nodeName === 'br') return '\n';
+ if (isText(node)) return node.data;
+ if (!isTag(node)) return '';
+ if (node.tagName === 'br') return '\n';
- if (node.childNodes) {
- return node.childNodes.map(n => getText(n)).join('');
- }
-
- return '';
+ return node.childNodes.map(n => getText(n)).join('');
}
function appendChildren(childNodes: ChildNode[]): void {
- if (childNodes) {
- for (const n of childNodes) {
- analyze(n);
- }
+ for (const n of childNodes) {
+ analyze(n);
}
}
function analyze(node: Node) {
- if (treeAdapter.isTextNode(node)) {
- text += node.value;
+ if (isText(node)) {
+ text += node.data;
return;
}
// Skip comment or document type node
- if (!treeAdapter.isElementNode(node)) {
+ if (!isTag(node)) {
return;
}
- switch (node.nodeName) {
+ switch (node.tagName) {
case 'br': {
text += '\n';
- break;
+ return;
}
-
case 'a': {
const txt = getText(node);
- const rel = node.attrs.find(x => x.name === 'rel');
- const href = node.attrs.find(x => x.name === 'href');
+ const rel = node.attribs.rel;
+ const href = node.attribs.href;
// ハッシュタグ
if (normalizedHashtagNames && href && normalizedHashtagNames.has(normalizeForSearch(txt))) {
text += txt;
// メンション
- } else if (txt.startsWith('@') && !(rel && rel.value.startsWith('me '))) {
+ } else if (txt.startsWith('@') && !(rel && rel.startsWith('me '))) {
const part = txt.split('@');
if (part.length === 2 && href) {
//#region ホスト名部分が省略されているので復元する
- const acct = `${txt}@${(new URL(href.value)).hostname}`;
+ const acct = `${txt}@${(new URL(href)).hostname}`;
text += acct;
//#endregion
} else if (part.length === 3) {
@@ -116,25 +106,32 @@ export class MfmService {
if (!href) {
return txt;
}
- if (!txt || txt === href.value) { // #6383: Missing text node
- if (href.value.match(urlRegexFull)) {
- return href.value;
+ if (!txt || txt === href) { // #6383: Missing text node
+ if (href.match(urlRegexFull)) {
+ return href;
} else {
- return `<${href.value}>`;
+ return `<${href}>`;
}
}
- if (href.value.match(urlRegex) && !href.value.match(urlRegexFull)) {
- return `[${txt}](<${href.value}>)`; // #6846
+ if (href.match(urlRegex) && !href.match(urlRegexFull)) {
+ return `[${txt}](<${href}>)`; // #6846
} else {
- return `[${txt}](${href.value})`;
+ return `[${txt}](${href})`;
}
};
text += generateLink();
}
- break;
+ return;
}
+ }
+ // Don't produce invalid empty MFM
+ if (node.childNodes.length < 1) {
+ return;
+ }
+
+ switch (node.tagName) {
case 'h1': {
text += '**【';
appendChildren(node.childNodes);
@@ -185,14 +182,17 @@ export class MfmService {
case 'ruby--': {
let ruby: [string, string][] = [];
for (const child of node.childNodes) {
- if (child.nodeName === 'rp') {
+ if (isText(child) && !/\s|\[|\]/.test(child.data)) {
+ ruby.push([child.data, '']);
continue;
}
- if (treeAdapter.isTextNode(child) && !/\s|\[|\]/.test(child.value)) {
- ruby.push([child.value, '']);
+ if (!isTag(child)) {
continue;
}
- if (child.nodeName === 'rt' && ruby.length > 0) {
+ if (child.tagName === 'rp') {
+ continue;
+ }
+ if (child.tagName === 'rt' && ruby.length > 0) {
const rt = getText(child);
if (/\s|\[|\]/.test(rt)) {
// If any space is included in rt, it is treated as a normal text
@@ -217,7 +217,7 @@ export class MfmService {
// block code ()
case 'pre': {
- if (node.childNodes.length === 1 && node.childNodes[0].nodeName === 'code') {
+ if (node.childNodes.length === 1 && isTag(node.childNodes[0]) && node.childNodes[0].tagName === 'code') {
text += '\n```\n';
text += getText(node.childNodes[0]);
text += '\n```\n';
@@ -302,17 +302,17 @@ export class MfmService {
let nonRtNodes = [];
// scan children, ignore `rp`, split on `rt`
for (const child of node.childNodes) {
- if (treeAdapter.isTextNode(child)) {
+ if (isText(child)) {
nonRtNodes.push(child);
continue;
}
- if (!treeAdapter.isElementNode(child)) {
+ if (!isTag(child)) {
continue;
}
- if (child.nodeName === 'rp') {
+ if (child.tagName === 'rp') {
continue;
}
- if (child.nodeName === 'rt') {
+ if (child.tagName === 'rt') {
// the only case in which we don't need a `$[group ]`
// is when both sides of the ruby are simple words
const needsGroup = nonRtNodes.length > 1 ||
@@ -335,6 +335,38 @@ export class MfmService {
break;
}
+ // Replace iframe with link so we can generate previews.
+ // We shouldn't normally see this, but federated blogging platforms (WordPress, MicroBlog.Pub) can send it.
+ case 'iframe': {
+ const txt: string | undefined = node.attribs.title || node.attribs.alt;
+ const href: string | undefined = node.attribs.src;
+ if (href) {
+ if (href.match(/[\s>]/)) {
+ if (txt) {
+ // href is invalid + has a label => render a pseudo-link
+ text += `${text} (${href})`;
+ } else {
+ // href is invalid + no label => render plain text
+ text += href;
+ }
+ } else {
+ if (txt) {
+ // href is valid + has a label => render a link
+ const label = txt
+ .replaceAll('[', '(')
+ .replaceAll(']', ')')
+ .replaceAll(/\r?\n/, ' ')
+ .replaceAll('`', '\'');
+ text += `[${label}](<${href}>)`;
+ } else {
+ // href is valid + no label => render a plain URL
+ text += `<${href}>`;
+ }
+ }
+ }
+ break;
+ }
+
default: // includes inline elements
{
appendChildren(node.childNodes);
@@ -350,45 +382,44 @@ export class MfmService {
return null;
}
- const { happyDOM, window } = new Window();
+ const doc = new Document([]);
- const doc = window.document;
+ const body = new Element('p', {});
+ doc.childNodes.push(body);
- const body = doc.createElement('p');
-
- function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
- if (children) {
- for (const child of children.map(x => (handlers as any)[x.type](x))) targetElement.appendChild(child);
+ function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
+ for (const child of children.map(x => handle(x))) {
+ targetElement.childNodes.push(child);
}
}
function fnDefault(node: mfm.MfmFn) {
- const el = doc.createElement('i');
+ const el = new Element('i', {});
appendChildren(node.children, el);
return el;
}
- const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any } = {
+ const handlers: { [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => ChildNode } = {
bold: (node) => {
- const el = doc.createElement('b');
+ const el = new Element('b', {});
appendChildren(node.children, el);
return el;
},
small: (node) => {
- const el = doc.createElement('small');
+ const el = new Element('small', {});
appendChildren(node.children, el);
return el;
},
strike: (node) => {
- const el = doc.createElement('del');
+ const el = new Element('del', {});
appendChildren(node.children, el);
return el;
},
italic: (node) => {
- const el = doc.createElement('i');
+ const el = new Element('i', {});
appendChildren(node.children, el);
return el;
},
@@ -399,11 +430,12 @@ export class MfmService {
const text = node.children[0].type === 'text' ? node.children[0].props.text : '';
try {
const date = new Date(parseInt(text, 10) * 1000);
- const el = doc.createElement('time');
- el.setAttribute('datetime', date.toISOString());
- el.textContent = date.toISOString();
+ const el = new Element('time', {
+ datetime: date.toISOString(),
+ });
+ el.childNodes.push(new Text(date.toISOString()));
return el;
- } catch (err) {
+ } catch {
return fnDefault(node);
}
}
@@ -412,20 +444,20 @@ export class MfmService {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
- const rubyEl = doc.createElement('ruby');
- const rtEl = doc.createElement('rt');
+ const rubyEl = new Element('ruby', {});
+ const rtEl = new Element('rt', {});
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
- const rpStartEl = doc.createElement('rp');
- rpStartEl.appendChild(doc.createTextNode('('));
- const rpEndEl = doc.createElement('rp');
- rpEndEl.appendChild(doc.createTextNode(')'));
+ const rpStartEl = new Element('rp', {});
+ rpStartEl.childNodes.push(new Text('('));
+ const rpEndEl = new Element('rp', {});
+ rpEndEl.childNodes.push(new Text(')'));
- rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
- rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
- rubyEl.appendChild(rpStartEl);
- rubyEl.appendChild(rtEl);
- rubyEl.appendChild(rpEndEl);
+ rubyEl.childNodes.push(new Text(text.split(' ')[0]));
+ rtEl.childNodes.push(new Text(text.split(' ')[1]));
+ rubyEl.childNodes.push(rpStartEl);
+ rubyEl.childNodes.push(rtEl);
+ rubyEl.childNodes.push(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
@@ -435,20 +467,20 @@ export class MfmService {
}
const text = rt.type === 'text' ? rt.props.text : '';
- const rubyEl = doc.createElement('ruby');
- const rtEl = doc.createElement('rt');
+ const rubyEl = new Element('ruby', {});
+ const rtEl = new Element('rt', {});
// ruby未対応のHTMLサニタイザーを通したときにルビが「劉備(りゅうび)」となるようにする
- const rpStartEl = doc.createElement('rp');
- rpStartEl.appendChild(doc.createTextNode('('));
- const rpEndEl = doc.createElement('rp');
- rpEndEl.appendChild(doc.createTextNode(')'));
+ const rpStartEl = new Element('rp', {});
+ rpStartEl.childNodes.push(new Text('('));
+ const rpEndEl = new Element('rp', {});
+ rpEndEl.childNodes.push(new Text(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
- rtEl.appendChild(doc.createTextNode(text.trim()));
- rubyEl.appendChild(rpStartEl);
- rubyEl.appendChild(rtEl);
- rubyEl.appendChild(rpEndEl);
+ rtEl.childNodes.push(new Text(text.trim()));
+ rubyEl.childNodes.push(rpStartEl);
+ rubyEl.childNodes.push(rtEl);
+ rubyEl.childNodes.push(rpEndEl);
return rubyEl;
}
}
@@ -456,7 +488,7 @@ export class MfmService {
// hack for ruby, should never be needed because we should
// never send this out to other instances
case 'group': {
- const el = doc.createElement('span');
+ const el = new Element('span', {});
appendChildren(node.children, el);
return el;
}
@@ -468,125 +500,135 @@ export class MfmService {
},
blockCode: (node) => {
- const pre = doc.createElement('pre');
- const inner = doc.createElement('code');
- inner.textContent = node.props.code;
- pre.appendChild(inner);
+ const pre = new Element('pre', {});
+ const inner = new Element('code', {});
+ inner.childNodes.push(new Text(node.props.code));
+ pre.childNodes.push(inner);
return pre;
},
center: (node) => {
- const el = doc.createElement('div');
+ const el = new Element('div', {});
appendChildren(node.children, el);
return el;
},
emojiCode: (node) => {
- return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+ return new Text(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji: (node) => {
- return doc.createTextNode(node.props.emoji);
+ return new Text(node.props.emoji);
},
hashtag: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
- a.textContent = `#${node.props.hashtag}`;
- a.setAttribute('rel', 'tag');
+ const a = new Element('a', {
+ href: `${this.config.url}/tags/${node.props.hashtag}`,
+ rel: 'tag',
+ });
+ a.childNodes.push(new Text(`#${node.props.hashtag}`));
return a;
},
inlineCode: (node) => {
- const el = doc.createElement('code');
- el.textContent = node.props.code;
+ const el = new Element('code', {});
+ el.childNodes.push(new Text(node.props.code));
return el;
},
mathInline: (node) => {
- const el = doc.createElement('code');
- el.textContent = node.props.formula;
+ const el = new Element('code', {});
+ el.childNodes.push(new Text(node.props.formula));
return el;
},
mathBlock: (node) => {
- const el = doc.createElement('code');
- el.textContent = node.props.formula;
+ const el = new Element('code', {});
+ el.childNodes.push(new Text(node.props.formula));
return el;
},
link: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', node.props.url);
+ const a = new Element('a', {
+ href: node.props.url,
+ });
appendChildren(node.children, a);
return a;
},
mention: (node) => {
- const a = doc.createElement('a');
const { username, host, acct } = node.props;
const remoteUserInfo = mentionedRemoteUsers.find(remoteUser => remoteUser.username.toLowerCase() === username.toLowerCase() && remoteUser.host?.toLowerCase() === host?.toLowerCase());
- a.setAttribute('href', remoteUserInfo
- ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
- : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`);
- a.className = 'u-url mention';
- a.textContent = acct;
+
+ const a = new Element('a', {
+ href: remoteUserInfo
+ ? (remoteUserInfo.url ? remoteUserInfo.url : remoteUserInfo.uri)
+ : `${this.config.url}/${acct.endsWith(`@${this.config.url}`) ? acct.substring(0, acct.length - this.config.url.length - 1) : acct}`,
+ class: 'u-url mention',
+ });
+ a.childNodes.push(new Text(acct));
return a;
},
quote: (node) => {
- const el = doc.createElement('blockquote');
+ const el = new Element('blockquote', {});
appendChildren(node.children, el);
return el;
},
text: (node) => {
if (!node.props.text.match(/[\r\n]/)) {
- return doc.createTextNode(node.props.text);
+ return new Text(node.props.text);
}
- const el = doc.createElement('span');
- const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => doc.createTextNode(x));
+ const el = new Element('span', {});
+ const nodes = node.props.text.split(/\r\n|\r|\n/).map(x => new Text(x));
for (const x of intersperse('br', nodes)) {
- el.appendChild(x === 'br' ? doc.createElement('br') : x);
+ el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
}
return el;
},
url: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', node.props.url);
- a.textContent = node.props.url;
+ const a = new Element('a', {
+ href: node.props.url,
+ });
+ a.childNodes.push(new Text(node.props.url));
return a;
},
search: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
- a.textContent = node.props.content;
+ const a = new Element('a', {
+ href: `https://www.google.com/search?q=${node.props.query}`,
+ });
+ a.childNodes.push(new Text(node.props.content));
return a;
},
plain: (node) => {
- const el = doc.createElement('span');
+ const el = new Element('span', {});
appendChildren(node.children, el);
return el;
},
};
+ // Utility function to make TypeScript behave
+ function handle(node: T): ChildNode {
+ const handler = handlers[node.type] as (node: T) => ChildNode;
+ return handler(node);
+ }
+
appendChildren(nodes, body);
for (const additionalAppender of additionalAppenders) {
additionalAppender(doc, body);
}
- const serialized = body.outerHTML;
-
- happyDOM.close().catch(err => {});
-
- return serialized;
+ return domserializer.render(body, {
+ encodeEntities: 'utf8'
+ });
}
// the toMastoApiHtml function was taken from Iceshrimp and written by zotan and modified by marie to work with the current MK version
@@ -598,55 +640,55 @@ export class MfmService {
return null;
}
- const { happyDOM, window } = new Window();
+ const doc = new Document([]);
- const doc = window.document;
+ const body = new Element('p', {});
+ doc.childNodes.push(body);
- const body = doc.createElement('p');
-
- function appendChildren(children: mfm.MfmNode[], targetElement: any): void {
- if (children) {
- for (const child of children.map((x) => (handlers as any)[x.type](x))) targetElement.appendChild(child);
+ function appendChildren(children: mfm.MfmNode[], targetElement: ParentNode): void {
+ for (const child of children) {
+ const result = handle(child);
+ targetElement.childNodes.push(result);
}
}
const handlers: {
- [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => any;
+ [K in mfm.MfmNode['type']]: (node: mfm.NodeType) => ChildNode;
} = {
bold(node) {
- const el = doc.createElement('span');
- el.textContent = '**';
+ const el = new Element('span', {});
+ el.childNodes.push(new Text('**'));
appendChildren(node.children, el);
- el.textContent += '**';
+ el.childNodes.push(new Text('**'));
return el;
},
small(node) {
- const el = doc.createElement('small');
+ const el = new Element('small', {});
appendChildren(node.children, el);
return el;
},
strike(node) {
- const el = doc.createElement('span');
- el.textContent = '~~';
+ const el = new Element('span', {});
+ el.childNodes.push(new Text('~~'));
appendChildren(node.children, el);
- el.textContent += '~~';
+ el.childNodes.push(new Text('~~'));
return el;
},
italic(node) {
- const el = doc.createElement('span');
- el.textContent = '*';
+ const el = new Element('span', {});
+ el.childNodes.push(new Text('*'));
appendChildren(node.children, el);
- el.textContent += '*';
+ el.childNodes.push(new Text('*'));
return el;
},
fn(node) {
switch (node.props.name) {
case 'group': { // hack for ruby
- const el = doc.createElement('span');
+ const el = new Element('span', {});
appendChildren(node.children, el);
return el;
}
@@ -654,119 +696,121 @@ export class MfmService {
if (node.children.length === 1) {
const child = node.children[0];
const text = child.type === 'text' ? child.props.text : '';
- const rubyEl = doc.createElement('ruby');
- const rtEl = doc.createElement('rt');
+ const rubyEl = new Element('ruby', {});
+ const rtEl = new Element('rt', {});
- const rpStartEl = doc.createElement('rp');
- rpStartEl.appendChild(doc.createTextNode('('));
- const rpEndEl = doc.createElement('rp');
- rpEndEl.appendChild(doc.createTextNode(')'));
+ const rpStartEl = new Element('rp', {});
+ rpStartEl.childNodes.push(new Text('('));
+ const rpEndEl = new Element('rp', {});
+ rpEndEl.childNodes.push(new Text(')'));
- rubyEl.appendChild(doc.createTextNode(text.split(' ')[0]));
- rtEl.appendChild(doc.createTextNode(text.split(' ')[1]));
- rubyEl.appendChild(rpStartEl);
- rubyEl.appendChild(rtEl);
- rubyEl.appendChild(rpEndEl);
+ rubyEl.childNodes.push(new Text(text.split(' ')[0]));
+ rtEl.childNodes.push(new Text(text.split(' ')[1]));
+ rubyEl.childNodes.push(rpStartEl);
+ rubyEl.childNodes.push(rtEl);
+ rubyEl.childNodes.push(rpEndEl);
return rubyEl;
} else {
const rt = node.children.at(-1);
if (!rt) {
- const el = doc.createElement('span');
+ const el = new Element('span', {});
appendChildren(node.children, el);
return el;
}
const text = rt.type === 'text' ? rt.props.text : '';
- const rubyEl = doc.createElement('ruby');
- const rtEl = doc.createElement('rt');
+ const rubyEl = new Element('ruby', {});
+ const rtEl = new Element('rt', {});
- const rpStartEl = doc.createElement('rp');
- rpStartEl.appendChild(doc.createTextNode('('));
- const rpEndEl = doc.createElement('rp');
- rpEndEl.appendChild(doc.createTextNode(')'));
+ const rpStartEl = new Element('rp', {});
+ rpStartEl.childNodes.push(new Text('('));
+ const rpEndEl = new Element('rp', {});
+ rpEndEl.childNodes.push(new Text(')'));
appendChildren(node.children.slice(0, node.children.length - 1), rubyEl);
- rtEl.appendChild(doc.createTextNode(text.trim()));
- rubyEl.appendChild(rpStartEl);
- rubyEl.appendChild(rtEl);
- rubyEl.appendChild(rpEndEl);
+ rtEl.childNodes.push(new Text(text.trim()));
+ rubyEl.childNodes.push(rpStartEl);
+ rubyEl.childNodes.push(rtEl);
+ rubyEl.childNodes.push(rpEndEl);
return rubyEl;
}
}
default: {
- const el = doc.createElement('span');
- el.textContent = '*';
+ const el = new Element('span', {});
+ el.childNodes.push(new Text('*'));
appendChildren(node.children, el);
- el.textContent += '*';
+ el.childNodes.push(new Text('*'));
return el;
}
}
},
blockCode(node) {
- const pre = doc.createElement('pre');
- const inner = doc.createElement('code');
+ const pre = new Element('pre', {});
+ const inner = new Element('code', {});
const nodes = node.props.code
.split(/\r\n|\r|\n/)
- .map((x) => doc.createTextNode(x));
+ .map((x) => new Text(x));
for (const x of intersperse('br', nodes)) {
- inner.appendChild(x === 'br' ? doc.createElement('br') : x);
+ inner.childNodes.push(x === 'br' ? new Element('br', {}) : x);
}
- pre.appendChild(inner);
+ pre.childNodes.push(inner);
return pre;
},
center(node) {
- const el = doc.createElement('div');
+ const el = new Element('div', {});
appendChildren(node.children, el);
return el;
},
emojiCode(node) {
- return doc.createTextNode(`\u200B:${node.props.name}:\u200B`);
+ return new Text(`\u200B:${node.props.name}:\u200B`);
},
unicodeEmoji(node) {
- return doc.createTextNode(node.props.emoji);
+ return new Text(node.props.emoji);
},
hashtag: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', `${this.config.url}/tags/${node.props.hashtag}`);
- a.textContent = `#${node.props.hashtag}`;
- a.setAttribute('rel', 'tag');
- a.setAttribute('class', 'hashtag');
+ const a = new Element('a', {
+ href: `${this.config.url}/tags/${node.props.hashtag}`,
+ rel: 'tag',
+ class: 'hashtag',
+ });
+ a.childNodes.push(new Text(`#${node.props.hashtag}`));
return a;
},
inlineCode(node) {
- const el = doc.createElement('code');
- el.textContent = node.props.code;
+ const el = new Element('code', {});
+ el.childNodes.push(new Text(node.props.code));
return el;
},
mathInline(node) {
- const el = doc.createElement('code');
- el.textContent = node.props.formula;
+ const el = new Element('code', {});
+ el.childNodes.push(new Text(node.props.formula));
return el;
},
mathBlock(node) {
- const el = doc.createElement('code');
- el.textContent = node.props.formula;
+ const el = new Element('code', {});
+ el.childNodes.push(new Text(node.props.formula));
return el;
},
link(node) {
- const a = doc.createElement('a');
- a.setAttribute('rel', 'nofollow noopener noreferrer');
- a.setAttribute('target', '_blank');
- a.setAttribute('href', node.props.url);
+ const a = new Element('a', {
+ rel: 'nofollow noopener noreferrer',
+ target: '_blank',
+ href: node.props.url,
+ });
appendChildren(node.children, a);
return a;
},
@@ -775,92 +819,107 @@ export class MfmService {
const { username, host, acct } = node.props;
const resolved = mentionedRemoteUsers.find(remoteUser => remoteUser.username === username && remoteUser.host === host);
- const el = doc.createElement('span');
+ const el = new Element('span', {});
if (!resolved) {
- el.textContent = acct;
+ el.childNodes.push(new Text(acct));
} else {
- el.setAttribute('class', 'h-card');
- el.setAttribute('translate', 'no');
- const a = doc.createElement('a');
- a.setAttribute('href', resolved.url ? resolved.url : resolved.uri);
- a.className = 'u-url mention';
- const span = doc.createElement('span');
- span.textContent = resolved.username || username;
- a.textContent = '@';
- a.appendChild(span);
- el.appendChild(a);
+ el.attribs.class = 'h-card';
+ el.attribs.translate = 'no';
+ const a = new Element('a', {
+ href: resolved.url ? resolved.url : resolved.uri,
+ class: 'u-url mention',
+ });
+ const span = new Element('span', {});
+ span.childNodes.push(new Text(resolved.username || username));
+ a.childNodes.push(new Text('@'));
+ a.childNodes.push(span);
+ el.childNodes.push(a);
}
return el;
},
quote(node) {
- const el = doc.createElement('blockquote');
+ const el = new Element('blockquote', {});
appendChildren(node.children, el);
return el;
},
text(node) {
- const el = doc.createElement('span');
+ if (!node.props.text.match(/[\r\n]/)) {
+ return new Text(node.props.text);
+ }
+
+ const el = new Element('span', {});
const nodes = node.props.text
.split(/\r\n|\r|\n/)
- .map((x) => doc.createTextNode(x));
+ .map((x) => new Text(x));
for (const x of intersperse('br', nodes)) {
- el.appendChild(x === 'br' ? doc.createElement('br') : x);
+ el.childNodes.push(x === 'br' ? new Element('br', {}) : x);
}
return el;
},
url(node) {
- const a = doc.createElement('a');
- a.setAttribute('rel', 'nofollow noopener noreferrer');
- a.setAttribute('target', '_blank');
- a.setAttribute('href', node.props.url);
- a.textContent = node.props.url.replace(/^https?:\/\//, '');
+ const a = new Element('a', {
+ rel: 'nofollow noopener noreferrer',
+ target: '_blank',
+ href: node.props.url,
+ });
+ a.childNodes.push(new Text(node.props.url.replace(/^https?:\/\//, '')));
return a;
},
search: (node) => {
- const a = doc.createElement('a');
- a.setAttribute('href', `https://www.google.com/search?q=${node.props.query}`);
- a.textContent = node.props.content;
+ const a = new Element('a', {
+ href: `https://www.google.com/search?q=${node.props.query}`,
+ });
+ a.childNodes.push(new Text(node.props.content));
return a;
},
plain(node) {
- const el = doc.createElement('span');
+ const el = new Element('span', {});
appendChildren(node.children, el);
return el;
},
};
+ // Utility function to make TypeScript behave
+ function handle(node: T): ChildNode {
+ const handler = handlers[node.type] as (node: T) => ChildNode;
+ return handler(node);
+ }
+
appendChildren(nodes, body);
if (quoteUri !== null) {
- const a = doc.createElement('a');
- a.setAttribute('href', quoteUri);
- a.textContent = quoteUri.replace(/^https?:\/\//, '');
+ const a = new Element('a', {
+ href: quoteUri,
+ });
+ a.childNodes.push(new Text(quoteUri.replace(/^https?:\/\//, '')));
- const quote = doc.createElement('span');
- quote.setAttribute('class', 'quote-inline');
- quote.appendChild(doc.createElement('br'));
- quote.appendChild(doc.createElement('br'));
- quote.innerHTML += 'RE: ';
- quote.appendChild(a);
+ const quote = new Element('span', {
+ class: 'quote-inline',
+ });
+ quote.childNodes.push(new Element('br', {}));
+ quote.childNodes.push(new Element('br', {}));
+ quote.childNodes.push(new Text('RE: '));
+ quote.childNodes.push(a);
- body.appendChild(quote);
+ body.childNodes.push(quote);
}
- let result = body.outerHTML;
+ let result = domserializer.render(body, {
+ encodeEntities: 'utf8'
+ });
if (inline) {
result = result.replace(/^/, '').replace(/<\/p>$/, '');
}
- happyDOM.close().catch(() => {});
-
return result;
}
}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index a9f4083446..bf7d209fef 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -4,7 +4,7 @@
*/
import { setImmediate } from 'node:timers/promises';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import { In, DataSource, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
@@ -676,18 +676,15 @@ export class NoteCreateService implements OnApplicationShutdown {
});
// 通知
if (data.reply.userHost === null) {
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: data.reply.userId,
- threadId: data.reply.threadId ?? data.reply.id,
- },
- });
+ const threadId = data.reply.threadId ?? data.reply.id;
const [
+ isThreadMuted,
userIdsWhoMeMuting,
- ] = data.reply.userId ? await Promise.all([
+ ] = await Promise.all([
+ this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.reply.userId),
- ]) : [new Set()];
+ ]);
const muted = isUserRelated(note, userIdsWhoMeMuting);
@@ -705,14 +702,17 @@ export class NoteCreateService implements OnApplicationShutdown {
// Notify
if (data.renote.userHost === null) {
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: data.renote.userId,
- threadId: data.renote.threadId ?? data.renote.id,
- },
- });
+ const threadId = data.renote.threadId ?? data.renote.id;
- const muted = data.renote.userId && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(data.renote.userId));
+ const [
+ isThreadMuted,
+ userIdsWhoMeMuting,
+ ] = await Promise.all([
+ this.cacheService.threadMutingsCache.fetch(data.renote.userId).then(ms => ms.has(threadId)),
+ this.cacheService.userMutingsCache.fetch(data.renote.userId),
+ ]);
+
+ const muted = data.renote.userId && isUserRelated(note, userIdsWhoMeMuting);
if (!isThreadMuted && !muted) {
nm.push(data.renote.userId, type);
@@ -731,7 +731,7 @@ export class NoteCreateService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => {
- const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
+ const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@@ -842,18 +842,23 @@ export class NoteCreateService implements OnApplicationShutdown {
@bindThis
private async createMentionedEvents(mentionedUsers: MinimumUser[], note: MiNote, nm: NotificationManager) {
+ const [
+ threadMutings,
+ userMutings,
+ ] = await Promise.all([
+ this.cacheService.threadMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
+ this.cacheService.userMutingsCache.fetchMany(mentionedUsers.map(u => u.id)).then(ms => new Map(ms)),
+ ]);
+
// Only create mention events for local users, and users for whom the note is visible
for (const u of mentionedUsers.filter(u => (note.visibility !== 'specified' || note.visibleUserIds.some(x => x === u.id)) && this.userEntityService.isLocalUser(u))) {
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: u.id,
- threadId: note.threadId ?? note.id,
- },
- });
+ const threadId = note.threadId ?? note.id;
+ const isThreadMuted = threadMutings.get(u.id)?.has(threadId);
- const muted = u.id && isUserRelated(note, await this.cacheService.userMutingsCache.fetch(u.id));
+ const mutings = userMutings.get(u.id);
+ const isUserMuted = mutings != null && isUserRelated(note, mutings);
- if (isThreadMuted || muted) {
+ if (isThreadMuted || isUserMuted) {
continue;
}
@@ -874,17 +879,6 @@ export class NoteCreateService implements OnApplicationShutdown {
this.notesRepository.increment({ id: reply.id }, 'repliesCount', 1);
}
- @bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
- if (data.localOnly) return null;
-
- const content = this.isRenote(data) && !this.isQuote(data)
- ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
- : this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, user, false), note);
-
- return this.apRendererService.addContext(content);
- }
-
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return;
@@ -964,6 +958,7 @@ export class NoteCreateService implements OnApplicationShutdown {
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
+ if (following.followerHost !== null) continue;
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
diff --git a/packages/backend/src/core/NoteEditService.ts b/packages/backend/src/core/NoteEditService.ts
index a359381573..5b0d1980c3 100644
--- a/packages/backend/src/core/NoteEditService.ts
+++ b/packages/backend/src/core/NoteEditService.ts
@@ -4,7 +4,7 @@
*/
import { setImmediate } from 'node:timers/promises';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import { DataSource, In, IsNull, LessThan } from 'typeorm';
import * as Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
@@ -647,18 +647,15 @@ export class NoteEditService implements OnApplicationShutdown {
if (data.reply) {
// 通知
if (data.reply.userHost === null) {
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: data.reply.userId,
- threadId: data.reply.threadId ?? data.reply.id,
- },
- });
+ const threadId = data.reply.threadId ?? data.reply.id;
const [
+ isThreadMuted,
userIdsWhoMeMuting,
- ] = data.reply.userId ? await Promise.all([
+ ] = await Promise.all([
+ this.cacheService.threadMutingsCache.fetch(data.reply.userId).then(ms => ms.has(threadId)),
this.cacheService.userMutingsCache.fetch(data.reply.userId),
- ]) : [new Set()];
+ ]);
const muted = isUserRelated(note, userIdsWhoMeMuting);
@@ -675,7 +672,7 @@ export class NoteEditService implements OnApplicationShutdown {
//#region AP deliver
if (!data.localOnly && this.userEntityService.isLocalUser(user)) {
trackTask(async () => {
- const noteActivity = await this.renderNoteOrRenoteActivity(data, note, user);
+ const noteActivity = await this.apRendererService.renderNoteOrRenoteActivity(note, user, { renote: data.renote });
const dm = this.apDeliverManagerService.createDeliverManager(user, noteActivity);
// メンションされたリモートユーザーに配送
@@ -770,17 +767,6 @@ export class NoteEditService implements OnApplicationShutdown {
(note.files != null && note.files.length > 0);
}
- @bindThis
- private async renderNoteOrRenoteActivity(data: Option, note: MiNote, user: MiUser) {
- if (data.localOnly) return null;
-
- const content = this.isRenote(data) && !this.isQuote(data)
- ? this.apRendererService.renderAnnounce(data.renote.uri ? data.renote.uri : `${this.config.url}/notes/${data.renote.id}`, note)
- : this.apRendererService.renderUpdate(await this.apRendererService.renderUpNote(note, user, false), user);
-
- return this.apRendererService.addContext(content);
- }
-
@bindThis
private index(note: MiNote) {
if (note.text == null && note.cw == null) return;
@@ -849,6 +835,7 @@ export class NoteEditService implements OnApplicationShutdown {
// TODO: あまりにも数が多いと redisPipeline.exec に失敗する(理由は不明)ため、3万件程度を目安に分割して実行するようにする
for (const following of followings) {
+ if (following.followerHost !== null) continue;
// 基本的にvisibleUserIdsには自身のidが含まれている前提であること
if (note.visibility === 'specified' && !note.visibleUserIds.some(v => v === following.followerId)) continue;
diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts
index d0e281e20c..e1bfe8d3b9 100644
--- a/packages/backend/src/core/QueryService.ts
+++ b/packages/backend/src/core/QueryService.ts
@@ -47,29 +47,36 @@ export class QueryService {
) {
}
- public makePaginationQuery(q: SelectQueryBuilder, sinceId?: string | null, untilId?: string | null, sinceDate?: number | null, untilDate?: number | null): SelectQueryBuilder {
+ public makePaginationQuery(
+ q: SelectQueryBuilder,
+ sinceId?: string | null,
+ untilId?: string | null,
+ sinceDate?: number | null,
+ untilDate?: number | null,
+ targetColumn = 'id',
+ ): SelectQueryBuilder {
if (sinceId && untilId) {
- q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
- q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
- q.orderBy(`${q.alias}.id`, 'DESC');
+ q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
+ q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
+ q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceId) {
- q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: sinceId });
- q.orderBy(`${q.alias}.id`, 'ASC');
+ q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: sinceId });
+ q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
} else if (untilId) {
- q.andWhere(`${q.alias}.id < :untilId`, { untilId: untilId });
- q.orderBy(`${q.alias}.id`, 'DESC');
+ q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: untilId });
+ q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceDate && untilDate) {
- q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
- q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
- q.orderBy(`${q.alias}.id`, 'DESC');
+ q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
+ q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
+ q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else if (sinceDate) {
- q.andWhere(`${q.alias}.id > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
- q.orderBy(`${q.alias}.id`, 'ASC');
+ q.andWhere(`${q.alias}.${targetColumn} > :sinceId`, { sinceId: this.idService.gen(sinceDate) });
+ q.orderBy(`${q.alias}.${targetColumn}`, 'ASC');
} else if (untilDate) {
- q.andWhere(`${q.alias}.id < :untilId`, { untilId: this.idService.gen(untilDate) });
- q.orderBy(`${q.alias}.id`, 'DESC');
+ q.andWhere(`${q.alias}.${targetColumn} < :untilId`, { untilId: this.idService.gen(untilDate) });
+ q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
} else {
- q.orderBy(`${q.alias}.id`, 'DESC');
+ q.orderBy(`${q.alias}.${targetColumn}`, 'DESC');
}
return q;
}
@@ -557,4 +564,26 @@ export class QueryService {
return q[join](`NOT EXISTS (${threadMutedQuery.getQuery()})`, threadMutedQuery.getParameters());
}
+
+ // Requirements: user replyUser renoteUser must be joined
+ @bindThis
+ public generateSuspendedUserQueryForNote(q: SelectQueryBuilder, excludeAuthor?: boolean): void {
+ if (excludeAuthor) {
+ const brakets = (user: string) => new Brackets(qb => qb
+ .where(`note.${user}Id IS NULL`)
+ .orWhere(`user.id = ${user}.id`)
+ .orWhere(`${user}.isSuspended = FALSE`));
+ q
+ .andWhere(brakets('replyUser'))
+ .andWhere(brakets('renoteUser'));
+ } else {
+ const brakets = (user: string) => new Brackets(qb => qb
+ .where(`note.${user}Id IS NULL`)
+ .orWhere(`${user}.isSuspended = FALSE`));
+ q
+ .andWhere('user.isSuspended = FALSE')
+ .andWhere(brakets('replyUser'))
+ .andWhere(brakets('renoteUser'));
+ }
+ }
}
diff --git a/packages/backend/src/core/QueueModule.ts b/packages/backend/src/core/QueueModule.ts
index 6dd48927c1..a48ffaab43 100644
--- a/packages/backend/src/core/QueueModule.ts
+++ b/packages/backend/src/core/QueueModule.ts
@@ -9,6 +9,7 @@ import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { baseQueueOptions, QUEUE } from '@/queue/const.js';
import { allSettled } from '@/misc/promise-tracker.js';
+import Logger from '@/logger.js';
import {
DeliverJobData,
EndedPollNotificationJobData,
@@ -120,6 +121,8 @@ const $scheduleNotePost: Provider = {
],
})
export class QueueModule implements OnApplicationShutdown {
+ private readonly logger = new Logger('queue');
+
constructor(
@Inject('queue:system') public systemQueue: SystemQueue,
@Inject('queue:endedPollNotification') public endedPollNotificationQueue: EndedPollNotificationQueue,
@@ -135,8 +138,10 @@ export class QueueModule implements OnApplicationShutdown {
public async dispose(): Promise {
// Wait for all potential queue jobs
+ this.logger.info('Finalizing active promises...');
await allSettled();
// And then close all queues
+ this.logger.info('Closing BullMQ queues...');
await Promise.all([
this.systemQueue.close(),
this.endedPollNotificationQueue.close(),
@@ -149,6 +154,7 @@ export class QueueModule implements OnApplicationShutdown {
this.systemWebhookDeliverQueue.close(),
this.scheduleNotePostQueue.close(),
]);
+ this.logger.info('Queue module disposed.');
}
async onApplicationShutdown(signal: string): Promise {
diff --git a/packages/backend/src/core/QueueService.ts b/packages/backend/src/core/QueueService.ts
index 361e636662..f4f069b64b 100644
--- a/packages/backend/src/core/QueueService.ts
+++ b/packages/backend/src/core/QueueService.ts
@@ -684,8 +684,11 @@ export class QueueService {
}
@bindThis
- public createCleanRemoteFilesJob() {
- return this.objectStorageQueue.add('cleanRemoteFiles', {}, {
+ public createCleanRemoteFilesJob(olderThanSeconds: number = 0, keepFilesInUse: boolean = false) {
+ return this.objectStorageQueue.add('cleanRemoteFiles', {
+ keepFilesInUse,
+ olderThanSeconds,
+ }, {
removeOnComplete: {
age: 3600 * 24 * 7, // keep up to 7 days
count: 30,
diff --git a/packages/backend/src/core/ReactionService.ts b/packages/backend/src/core/ReactionService.ts
index 8d2dc7d4e8..bb56c6c745 100644
--- a/packages/backend/src/core/ReactionService.ts
+++ b/packages/backend/src/core/ReactionService.ts
@@ -275,12 +275,8 @@ export class ReactionService {
// リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) {
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: note.userId,
- threadId: note.threadId ?? note.id,
- },
- });
+ const threadId = note.threadId ?? note.id;
+ const isThreadMuted = await this.cacheService.threadMutingsCache.fetch(note.userId).then(ms => ms.has(threadId));
if (!isThreadMuted) {
this.notificationService.createNotification(note.userId, 'reaction', {
diff --git a/packages/backend/src/core/RegistryApiService.ts b/packages/backend/src/core/RegistryApiService.ts
index 2c8877d8a8..2c7ad4026d 100644
--- a/packages/backend/src/core/RegistryApiService.ts
+++ b/packages/backend/src/core/RegistryApiService.ts
@@ -27,25 +27,9 @@ export class RegistryApiService {
public async set(userId: MiUser['id'], domain: string | null, scope: string[], key: string, value: any) {
// TODO: 作成できるキーの数を制限する
- const query = this.registryItemsRepository.createQueryBuilder('item');
- if (domain) {
- query.where('item.domain = :domain', { domain: domain });
- } else {
- query.where('item.domain IS NULL');
- }
- query.andWhere('item.userId = :userId', { userId: userId });
- query.andWhere('item.key = :key', { key: key });
- query.andWhere('item.scope = :scope', { scope: scope });
-
- const existingItem = await query.getOne();
-
- if (existingItem) {
- await this.registryItemsRepository.update(existingItem.id, {
- updatedAt: new Date(),
- value: value,
- });
- } else {
- await this.registryItemsRepository.insert({
+ await this.registryItemsRepository.createQueryBuilder('item')
+ .insert()
+ .values({
id: this.idService.gen(),
updatedAt: new Date(),
userId: userId,
@@ -53,8 +37,13 @@ export class RegistryApiService {
scope: scope,
key: key,
value: value,
- });
- }
+ })
+ .orUpdate(
+ ['updatedAt', 'value'],
+ ['userId', 'key', 'scope', 'domain'],
+ { upsertType: 'on-conflict-do-update' }
+ )
+ .execute();
if (domain == null) {
// TODO: サードパーティアプリが傍受出来てしまうのでどうにかする
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index b250eeee21..10e1315dd2 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -737,6 +737,17 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
}
}
+ @bindThis
+ public async clone(role: MiRole, moderator?: MiUser): Promise {
+ const suffix = ' (cloned)';
+ const newName = role.name.slice(0, 256 - suffix.length) + suffix;
+
+ return this.create({
+ ...role,
+ name: newName,
+ }, moderator);
+ }
+
@bindThis
public async delete(role: MiRole, moderator?: MiUser): Promise {
await this.rolesRepository.delete({ id: role.id });
diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts
index 37238dc4b0..9b9ae27739 100644
--- a/packages/backend/src/core/SearchService.ts
+++ b/packages/backend/src/core/SearchService.ts
@@ -301,6 +301,7 @@ export class SearchService {
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateSuspendedUserQueryForNote(query);
if (me) this.queryService.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
@@ -368,11 +369,17 @@ export class SearchService {
])
: [new Set(), new Set()];
- const query = this.notesRepository.createQueryBuilder('note');
+ const query = this.notesRepository.createQueryBuilder('note')
+ .innerJoinAndSelect('note.user', 'user')
+ .leftJoinAndSelect('note.reply', 'reply')
+ .leftJoinAndSelect('note.renote', 'renote')
+ .leftJoinAndSelect('reply.user', 'replyUser')
+ .leftJoinAndSelect('renote.user', 'renoteUser');
query.where('note.id IN (:...noteIds)', { noteIds: res.hits.map(x => x.id) });
this.queryService.generateBlockedHostQueryForNote(query);
+ this.queryService.generateSuspendedUserQueryForNote(query);
const notes = (await query.getMany()).filter(note => {
if (me && isUserRelated(note, userIdsWhoBlockingMe)) return false;
diff --git a/packages/backend/src/core/UserSuspendService.ts b/packages/backend/src/core/UserSuspendService.ts
index f375dff862..ddadab7022 100644
--- a/packages/backend/src/core/UserSuspendService.ts
+++ b/packages/backend/src/core/UserSuspendService.ts
@@ -6,7 +6,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { Not, IsNull } from 'typeorm';
import type { FollowingsRepository, FollowRequestsRepository, UsersRepository } from '@/models/_.js';
-import type { MiUser } from '@/models/User.js';
+import { MiUser } from '@/models/User.js';
import { QueueService } from '@/core/QueueService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
@@ -17,9 +17,15 @@ import { RelationshipJobData } from '@/queue/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js';
import { isSystemAccount } from '@/misc/is-system-account.js';
import { CacheService } from '@/core/CacheService.js';
+import { LoggerService } from '@/core/LoggerService.js';
+import type Logger from '@/logger.js';
+import { renderInlineError } from '@/misc/render-inline-error.js';
+import { trackPromise } from '@/misc/promise-tracker.js';
@Injectable()
export class UserSuspendService {
+ private readonly logger: Logger;
+
constructor(
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@@ -36,7 +42,10 @@ export class UserSuspendService {
private apRendererService: ApRendererService,
private moderationLogService: ModerationLogService,
private readonly cacheService: CacheService,
+
+ loggerService: LoggerService,
) {
+ this.logger = loggerService.getLogger('user-suspend');
}
@bindThis
@@ -47,16 +56,16 @@ export class UserSuspendService {
isSuspended: true,
});
- this.moderationLogService.log(moderator, 'suspend', {
+ await this.moderationLogService.log(moderator, 'suspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
- (async () => {
- await this.postSuspend(user).catch(e => {});
- await this.unFollowAll(user).catch(e => {});
- })();
+ trackPromise((async () => {
+ await this.postSuspend(user);
+ await this.freezeAll(user);
+ })().catch(e => this.logger.error(`Error suspending user ${user.id}: ${renderInlineError(e)}`)));
}
@bindThis
@@ -65,33 +74,36 @@ export class UserSuspendService {
isSuspended: false,
});
- this.moderationLogService.log(moderator, 'unsuspend', {
+ await this.moderationLogService.log(moderator, 'unsuspend', {
userId: user.id,
userUsername: user.username,
userHost: user.host,
});
- (async () => {
- await this.postUnsuspend(user).catch(e => {});
- })();
+ trackPromise((async () => {
+ await this.postUnsuspend(user);
+ await this.unFreezeAll(user);
+ })().catch(e => this.logger.error(`Error un-suspending for user ${user.id}: ${renderInlineError(e)}`)));
}
@bindThis
private async postSuspend(user: { id: MiUser['id']; host: MiUser['host'] }): Promise {
this.globalEventService.publishInternalEvent('userChangeSuspendedState', { id: user.id, isSuspended: true });
+ /*
this.followRequestsRepository.delete({
followeeId: user.id,
});
this.followRequestsRepository.delete({
followerId: user.id,
});
+ */
if (this.userEntityService.isLocalUser(user)) {
// 知り得る全SharedInboxにDelete配信
const content = this.apRendererService.addContext(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user));
- const queue: string[] = [];
+ const queue = new Map();
const followings = await this.followingsRepository.find({
where: [
@@ -104,12 +116,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
- if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ if (inbox != null) {
+ queue.set(inbox, true);
+ }
}
- for (const inbox of queue) {
- this.queueService.deliver(user, content, inbox, true);
- }
+ await this.queueService.deliverMany(user, content, queue);
}
}
@@ -121,7 +133,7 @@ export class UserSuspendService {
// 知り得る全SharedInboxにUndo Delete配信
const content = this.apRendererService.addContext(this.apRendererService.renderUndo(this.apRendererService.renderDelete(this.userEntityService.genLocalUserUri(user.id), user), user));
- const queue: string[] = [];
+ const queue = new Map();
const followings = await this.followingsRepository.find({
where: [
@@ -134,12 +146,12 @@ export class UserSuspendService {
const inboxes = followings.map(x => x.followerSharedInbox ?? x.followeeSharedInbox);
for (const inbox of inboxes) {
- if (inbox != null && !queue.includes(inbox)) queue.push(inbox);
+ if (inbox != null) {
+ queue.set(inbox, true);
+ }
}
- for (const inbox of queue) {
- this.queueService.deliver(user as any, content, inbox, true);
- }
+ await this.queueService.deliverMany(user, content, queue);
}
}
@@ -160,4 +172,36 @@ export class UserSuspendService {
}
this.queueService.createUnfollowJob(jobs);
}
+
+ @bindThis
+ private async freezeAll(user: MiUser): Promise {
+ // Freeze follow relations with all remote users
+ await this.followingsRepository
+ .createQueryBuilder('following')
+ .orWhere({
+ followeeId: user.id,
+ followerHost: Not(IsNull()),
+ })
+ .update({
+ isFollowerHibernated: true,
+ })
+ .execute();
+ }
+
+ @bindThis
+ private async unFreezeAll(user: MiUser): Promise {
+ // Restore follow relations with all remote users
+ await this.followingsRepository
+ .createQueryBuilder('following')
+ .innerJoin(MiUser, 'follower', 'user.id = following.followerId')
+ .andWhere('follower.isHibernated = false') // Don't unfreeze if the follower is *actually* frozen
+ .andWhere({
+ followeeId: user.id,
+ followerHost: Not(IsNull()),
+ })
+ .update({
+ isFollowerHibernated: false,
+ })
+ .execute();
+ }
}
diff --git a/packages/backend/src/core/UtilityService.ts b/packages/backend/src/core/UtilityService.ts
index 3098367392..5de76e00a6 100644
--- a/packages/backend/src/core/UtilityService.ts
+++ b/packages/backend/src/core/UtilityService.ts
@@ -7,10 +7,12 @@ import { URL, domainToASCII } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
import RE2 from 're2';
import psl from 'psl';
+import semver from 'semver';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js';
-import { MiMeta } from '@/models/Meta.js';
+import { MiMeta, SoftwareSuspension } from '@/models/Meta.js';
+import { MiInstance } from '@/models/Instance.js';
@Injectable()
export class UtilityService {
@@ -213,4 +215,20 @@ export class UtilityService {
return '';
}
}
+
+ @bindThis
+ public isDeliverSuspendedSoftware(software: Pick): SoftwareSuspension | undefined {
+ if (software.softwareName == null) return undefined;
+ if (software.softwareVersion == null) {
+ // software version is null; suspend iff versionRange is *
+ return this.meta.deliverSuspendedSoftware.find(x =>
+ x.software === software.softwareName
+ && x.versionRange.trim() === '*');
+ } else {
+ const softwareVersion = software.softwareVersion;
+ return this.meta.deliverSuspendedSoftware.find(x =>
+ x.software === software.softwareName
+ && semver.satisfies(softwareVersion, x.versionRange, { includePrerelease: true }));
+ }
+ }
}
diff --git a/packages/backend/src/core/WebfingerService.ts b/packages/backend/src/core/WebfingerService.ts
index 664963f3a3..bb9f0be4c6 100644
--- a/packages/backend/src/core/WebfingerService.ts
+++ b/packages/backend/src/core/WebfingerService.ts
@@ -5,7 +5,7 @@
import { URL } from 'node:url';
import { Injectable } from '@nestjs/common';
-import { XMLParser } from 'fast-xml-parser';
+import { load as cheerio } from 'cheerio/slim';
import { HttpRequestService } from '@/core/HttpRequestService.js';
import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
@@ -101,14 +101,12 @@ export class WebfingerService {
private async fetchWebFingerTemplateFromHostMeta(url: string): Promise {
try {
const res = await this.httpRequestService.getHtml(url, 'application/xrd+xml');
- const options = {
- ignoreAttributes: false,
- isArray: (_name: string, jpath: string) => jpath === 'XRD.Link',
- };
- const parser = new XMLParser(options);
- const hostMeta = parser.parse(res);
- const template = (hostMeta['XRD']['Link'] as Array).filter(p => p['@_rel'] === 'lrdd')[0]['@_template'];
- return template.indexOf('{uri}') < 0 ? null : template;
+ const hostMeta = cheerio(res, {
+ xml: true,
+ });
+
+ const template = hostMeta('XRD > Link[rel="lrdd"][template*="{uri}"]').attr('template');
+ return template ?? null;
} catch (err) {
this.logger.error(`error while request host-meta for ${url}: ${renderInlineError(err)}`);
return null;
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index c4b01d535b..0b1e8110e5 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -13,6 +13,7 @@ import { type WebhookEventTypes } from '@/models/Webhook.js';
import { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { type UserWebhookPayload, UserWebhookService } from '@/core/UserWebhookService.js';
import { QueueService } from '@/core/QueueService.js';
+import { IdService } from '@/core/IdService.js';
import { ModeratorInactivityRemainingTime } from '@/queue/processors/CheckModeratorsActivityProcessorService.js';
const oneDayMillis = 24 * 60 * 60 * 1000;
@@ -166,6 +167,7 @@ export class WebhookTestService {
private userWebhookService: UserWebhookService,
private systemWebhookService: SystemWebhookService,
private queueService: QueueService,
+ private readonly idService: IdService,
) {
}
@@ -392,6 +394,7 @@ export class WebhookTestService {
private async toPackedNote(note: MiNote, detail = true, override?: Packed<'Note'>): Promise> {
return {
id: note.id,
+ threadId: note.threadId ?? note.id,
createdAt: new Date().toISOString(),
deletedAt: null,
text: note.text,
@@ -401,6 +404,10 @@ export class WebhookTestService {
replyId: note.replyId,
renoteId: note.renoteId,
isHidden: false,
+ isMutingThread: false,
+ isMutingNote: false,
+ isFavorited: false,
+ isRenoted: false,
visibility: note.visibility,
mentions: note.mentions,
visibleUserIds: note.visibleUserIds,
@@ -435,10 +442,12 @@ export class WebhookTestService {
private async toPackedUserLite(user: MiUser, override?: Packed<'UserLite'>): Promise> {
return {
...user,
+ createdAt: this.idService.parse(user.id).date.toISOString(),
id: user.id,
name: user.name,
username: user.username,
host: user.host,
+ description: 'dummy user',
avatarUrl: user.avatarId == null ? null : user.avatarUrl,
avatarBlurhash: user.avatarId == null ? null : user.avatarBlurhash,
avatarDecorations: user.avatarDecorations.map(it => ({
diff --git a/packages/backend/src/core/activitypub/ApMfmService.ts b/packages/backend/src/core/activitypub/ApMfmService.ts
index c4a948429a..ddb6461746 100644
--- a/packages/backend/src/core/activitypub/ApMfmService.ts
+++ b/packages/backend/src/core/activitypub/ApMfmService.ts
@@ -4,7 +4,7 @@
*/
import { Injectable } from '@nestjs/common';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import { MfmService, Appender } from '@/core/MfmService.js';
import type { MiNote } from '@/models/Note.js';
import { bindThis } from '@/decorators.js';
diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts
index 6068d707de..9f55be11ac 100644
--- a/packages/backend/src/core/activitypub/ApRendererService.ts
+++ b/packages/backend/src/core/activitypub/ApRendererService.ts
@@ -6,8 +6,9 @@
import { createPublicKey, randomUUID } from 'node:crypto';
import { Inject, Injectable } from '@nestjs/common';
import { In } from 'typeorm';
-import * as mfm from '@transfem-org/sfm-js';
+import * as mfm from 'mfm-js';
import { UnrecoverableError } from 'bullmq';
+import { Element, Text } from 'domhandler';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiPartialLocalUser, MiLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
@@ -31,6 +32,8 @@ import { IdService } from '@/core/IdService.js';
import { appendContentWarning } from '@/misc/append-content-warning.js';
import { QueryService } from '@/core/QueryService.js';
import { UtilityService } from '@/core/UtilityService.js';
+import { CacheService } from '@/core/CacheService.js';
+import { isPureRenote, isQuote, isRenote } from '@/misc/is-renote.js';
import { JsonLdService } from './JsonLdService.js';
import { ApMfmService } from './ApMfmService.js';
import { CONTEXT } from './misc/contexts.js';
@@ -74,6 +77,7 @@ export class ApRendererService {
private idService: IdService,
private readonly queryService: QueryService,
private utilityService: UtilityService,
+ private readonly cacheService: CacheService,
) {
}
@@ -231,7 +235,7 @@ export class ApRendererService {
*/
@bindThis
public async renderFollowUser(id: MiUser['id']): Promise {
- const user = await this.usersRepository.findOneByOrFail({ id: id }) as MiPartialLocalUser | MiPartialRemoteUser;
+ const user = await this.cacheService.findUserById(id) as MiPartialLocalUser | MiPartialRemoteUser;
return this.userEntityService.getUserUri(user);
}
@@ -401,7 +405,7 @@ export class ApRendererService {
inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
if (inReplyToNote != null) {
- const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
+ const inReplyToUser = await this.cacheService.findUserById(inReplyToNote.userId);
if (inReplyToUser) {
if (inReplyToNote.uri) {
@@ -421,7 +425,7 @@ export class ApRendererService {
let quote: string | undefined = undefined;
- if (note.renoteId) {
+ if (isRenote(note) && isQuote(note)) {
const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
if (renote) {
@@ -475,16 +479,18 @@ export class ApRendererService {
// the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
// For compatibility, the span part should be kept as possible.
apAppend.push((doc, body) => {
- body.appendChild(doc.createElement('br'));
- body.appendChild(doc.createElement('br'));
- const span = doc.createElement('span');
- span.className = 'quote-inline';
- span.appendChild(doc.createTextNode('RE: '));
- const link = doc.createElement('a');
- link.setAttribute('href', quote);
- link.textContent = quote;
- span.appendChild(link);
- body.appendChild(span);
+ body.childNodes.push(new Element('br', {}));
+ body.childNodes.push(new Element('br', {}));
+ const span = new Element('span', {
+ class: 'quote-inline',
+ });
+ span.childNodes.push(new Text('RE: '));
+ const link = new Element('a', {
+ href: quote,
+ });
+ link.childNodes.push(new Text(quote));
+ span.childNodes.push(link);
+ body.childNodes.push(span);
});
}
@@ -539,6 +545,7 @@ export class ApRendererService {
attributedTo,
summary: summary ?? undefined,
content: content ?? undefined,
+ updated: note.updatedAt?.toISOString() ?? undefined,
_misskey_content: text,
source: {
content: text,
@@ -548,7 +555,8 @@ export class ApRendererService {
quoteUrl: quote,
quoteUri: quote,
// https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
- quote: quote,
+ // Disabled since Mastodon hides the fallback link when this is set
+ // quote: quote,
published: this.idService.parse(note.id).date.toISOString(),
to,
cc,
@@ -753,174 +761,6 @@ export class ApRendererService {
};
}
- @bindThis
- public async renderUpNote(note: MiNote, author: MiUser, dive = true): Promise {
- const getPromisedFiles = async (ids: string[]): Promise => {
- if (ids.length === 0) return [];
- const items = await this.driveFilesRepository.findBy({ id: In(ids) });
- return ids.map(id => items.find(item => item.id === id)).filter((item): item is MiDriveFile => item != null);
- };
-
- let inReplyTo;
- let inReplyToNote: MiNote | null;
-
- if (note.replyId) {
- inReplyToNote = await this.notesRepository.findOneBy({ id: note.replyId });
-
- if (inReplyToNote != null) {
- const inReplyToUser = await this.usersRepository.findOneBy({ id: inReplyToNote.userId });
-
- if (inReplyToUser) {
- if (inReplyToNote.uri) {
- inReplyTo = inReplyToNote.uri;
- } else {
- if (dive) {
- inReplyTo = await this.renderUpNote(inReplyToNote, inReplyToUser, false);
- } else {
- inReplyTo = `${this.config.url}/notes/${inReplyToNote.id}`;
- }
- }
- }
- }
- } else {
- inReplyTo = null;
- }
-
- let quote: string | undefined = undefined;
-
- if (note.renoteId) {
- const renote = await this.notesRepository.findOneBy({ id: note.renoteId });
-
- if (renote) {
- quote = renote.uri ? renote.uri : `${this.config.url}/notes/${renote.id}`;
- }
- }
-
- const attributedTo = this.userEntityService.genLocalUserUri(note.userId);
-
- const mentions = note.mentionedRemoteUsers ? (JSON.parse(note.mentionedRemoteUsers) as IMentionedRemoteUsers).map(x => x.uri) : [];
-
- let to: string[] = [];
- let cc: string[] = [];
-
- if (note.visibility === 'public') {
- to = ['https://www.w3.org/ns/activitystreams#Public'];
- cc = [`${attributedTo}/followers`].concat(mentions);
- } else if (note.visibility === 'home') {
- to = [`${attributedTo}/followers`];
- cc = ['https://www.w3.org/ns/activitystreams#Public'].concat(mentions);
- } else if (note.visibility === 'followers') {
- to = [`${attributedTo}/followers`];
- cc = mentions;
- } else {
- to = mentions;
- }
-
- const mentionedUsers = note.mentions && note.mentions.length > 0 ? await this.usersRepository.findBy({
- id: In(note.mentions),
- }) : [];
-
- const hashtagTags = note.tags.map(tag => this.renderHashtag(tag));
- const mentionTags = mentionedUsers.map(u => this.renderMention(u as MiLocalUser | MiRemoteUser));
-
- const files = await getPromisedFiles(note.fileIds);
-
- const text = note.text ?? '';
- let poll: MiPoll | null = null;
-
- if (note.hasPoll) {
- poll = await this.pollsRepository.findOneBy({ noteId: note.id });
- }
-
- const apAppend: Appender[] = [];
-
- if (quote) {
- // Append quote link as `
RE: ...`
- // the claas name `quote-inline` is used in non-misskey clients for styling quote notes.
- // For compatibility, the span part should be kept as possible.
- apAppend.push((doc, body) => {
- body.appendChild(doc.createElement('br'));
- body.appendChild(doc.createElement('br'));
- const span = doc.createElement('span');
- span.className = 'quote-inline';
- span.appendChild(doc.createTextNode('RE: '));
- const link = doc.createElement('a');
- link.setAttribute('href', quote);
- link.textContent = quote;
- span.appendChild(link);
- body.appendChild(span);
- });
- }
-
- let summary = note.cw === '' ? String.fromCharCode(0x200B) : note.cw;
-
- // Apply mandatory CW, if applicable
- if (author.mandatoryCW) {
- summary = appendContentWarning(summary, author.mandatoryCW);
- }
-
- const { content } = this.apMfmService.getNoteHtml(note, apAppend);
-
- const emojis = await this.getEmojis(note.emojis);
- const apemojis = emojis.filter(emoji => !emoji.localOnly).map(emoji => this.renderEmoji(emoji));
-
- const tag: IObject[] = [
- ...hashtagTags,
- ...mentionTags,
- ...apemojis,
- ];
-
- // https://codeberg.org/fediverse/fep/src/branch/main/fep/e232/fep-e232.md
- if (quote) {
- tag.push({
- type: 'Link',
- mediaType: 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"',
- rel: 'https://misskey-hub.net/ns#_misskey_quote',
- href: quote,
- } satisfies ILink);
- }
-
- const asPoll = poll ? {
- type: 'Question',
- [poll.expiresAt && poll.expiresAt < new Date() ? 'closed' : 'endTime']: poll.expiresAt,
- [poll.multiple ? 'anyOf' : 'oneOf']: poll.choices.map((text, i) => ({
- type: 'Note',
- name: text,
- replies: {
- type: 'Collection',
- totalItems: poll!.votes[i],
- },
- })),
- } as const : {};
-
- return {
- id: `${this.config.url}/notes/${note.id}`,
- type: 'Note',
- attributedTo,
- summary: summary ?? undefined,
- content: content ?? undefined,
- updated: note.updatedAt?.toISOString(),
- _misskey_content: text,
- source: {
- content: text,
- mediaType: 'text/x.misskeymarkdown',
- },
- _misskey_quote: quote,
- quoteUrl: quote,
- quoteUri: quote,
- // https://codeberg.org/fediverse/fep/src/branch/main/fep/044f/fep-044f.md
- quote: quote,
- published: this.idService.parse(note.id).date.toISOString(),
- to,
- cc,
- inReplyTo,
- attachment: files.map(x => this.renderDocument(x)),
- sensitive: note.cw != null || files.some(file => file.isSensitive),
- tag,
- ...asPoll,
- };
- }
-
@bindThis
public renderVote(user: { id: MiUser['id'] }, vote: MiPollVote, note: MiNote, poll: MiPoll, pollOwner: MiRemoteUser): ICreate {
return {
@@ -1074,6 +914,27 @@ export class ApRendererService {
};
}
+ @bindThis
+ public async renderNoteOrRenoteActivity(note: MiNote, user: MiUser, hint?: { renote?: MiNote | null }) {
+ if (note.localOnly) return null;
+
+ if (isPureRenote(note)) {
+ const renote = hint?.renote ?? note.renote ?? await this.notesRepository.findOneByOrFail({ id: note.renoteId });
+ const apAnnounce = this.renderAnnounce(renote.uri ?? `${this.config.url}/notes/${renote.id}`, note);
+ return this.addContext(apAnnounce);
+ }
+
+ const apNote = await this.renderNote(note, user, false);
+
+ if (note.updatedAt != null) {
+ const apUpdate = this.renderUpdate(apNote, user);
+ return this.addContext(apUpdate);
+ } else {
+ const apCreate = this.renderCreate(apNote, note);
+ return this.addContext(apCreate);
+ }
+ }
+
@bindThis
private async getEmojis(names: string[]): Promise {
if (names.length === 0) return [];
diff --git a/packages/backend/src/core/activitypub/ApRequestService.ts b/packages/backend/src/core/activitypub/ApRequestService.ts
index 4c7cac2169..e4db9b237c 100644
--- a/packages/backend/src/core/activitypub/ApRequestService.ts
+++ b/packages/backend/src/core/activitypub/ApRequestService.ts
@@ -6,7 +6,7 @@
import * as crypto from 'node:crypto';
import { URL } from 'node:url';
import { Inject, Injectable } from '@nestjs/common';
-import { Window } from 'happy-dom';
+import { load as cheerio } from 'cheerio/slim';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
import type { MiUser } from '@/models/User.js';
@@ -18,6 +18,8 @@ import { bindThis } from '@/decorators.js';
import type Logger from '@/logger.js';
import { validateContentTypeSetAsActivityPub } from '@/core/activitypub/misc/validator.js';
import type { IObject, IObjectWithId } from './type.js';
+import type { Cheerio, CheerioAPI } from 'cheerio/slim';
+import type { AnyNode } from 'domhandler';
type Request = {
url: string;
@@ -219,53 +221,33 @@ export class ApRequestService {
(contentType ?? '').split(';')[0].trimEnd().toLowerCase() === 'text/html' &&
_followAlternate === true
) {
- const html = await res.text();
- const { window, happyDOM } = new Window({
- settings: {
- disableJavaScriptEvaluation: true,
- disableJavaScriptFileLoading: true,
- disableCSSFileLoading: true,
- disableComputedStyleRendering: true,
- handleDisabledFileLoadingAsSuccess: true,
- navigation: {
- disableMainFrameNavigation: true,
- disableChildFrameNavigation: true,
- disableChildPageNavigation: true,
- disableFallbackToSetURL: true,
- },
- timer: {
- maxTimeout: 0,
- maxIntervalTime: 0,
- maxIntervalIterations: 0,
- },
- },
- });
- const document = window.document;
+ let alternate: Cheerio | null;
try {
- document.documentElement.innerHTML = html;
+ const html = await res.text();
+ const document = cheerio(html);
// Search for any matching value in priority order:
// 1. Type=AP > Type=none > Type=anything
// 2. Alternate > Canonical
// 3. Page order (fallback)
- const alternate =
- document.querySelector('head > link[href][rel="alternate"][type="application/activity+json"]') ??
- document.querySelector('head > link[href][rel="canonical"][type="application/activity+json"]') ??
- document.querySelector('head > link[href][rel="alternate"]:not([type])') ??
- document.querySelector('head > link[href][rel="canonical"]:not([type])') ??
- document.querySelector('head > link[href][rel="alternate"]') ??
- document.querySelector('head > link[href][rel="canonical"]');
-
- if (alternate) {
- const href = alternate.getAttribute('href');
- if (href && this.apUtilityService.haveSameAuthority(url, href)) {
- return await this.signedGet(href, user, allowAnonymous, false);
- }
- }
+ alternate = selectFirst(document, [
+ 'head > link[href][rel="alternate"][type="application/activity+json"]',
+ 'head > link[href][rel="canonical"][type="application/activity+json"]',
+ 'head > link[href][rel="alternate"]:not([type])',
+ 'head > link[href][rel="canonical"]:not([type])',
+ 'head > link[href][rel="alternate"]',
+ 'head > link[href][rel="canonical"]',
+ ]);
} catch {
// something went wrong parsing the HTML, ignore the whole thing
- } finally {
- happyDOM.close().catch(err => {});
+ alternate = null;
+ }
+
+ if (alternate) {
+ const href = alternate.attr('href');
+ if (href && this.apUtilityService.haveSameAuthority(url, href)) {
+ return await this.signedGet(href, user, allowAnonymous, false);
+ }
}
}
//#endregion
@@ -285,3 +267,14 @@ export class ApRequestService {
return activity as IObjectWithId;
}
}
+
+function selectFirst($: CheerioAPI, selectors: string[]): Cheerio | null {
+ for (const selector of selectors) {
+ const selection = $(selector);
+ if (selection.length > 0) {
+ return selection;
+ }
+ }
+
+ return null;
+}
diff --git a/packages/backend/src/core/activitypub/ApResolverService.ts b/packages/backend/src/core/activitypub/ApResolverService.ts
index 201920612c..d53e265d36 100644
--- a/packages/backend/src/core/activitypub/ApResolverService.ts
+++ b/packages/backend/src/core/activitypub/ApResolverService.ts
@@ -21,6 +21,8 @@ import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
import { SystemAccountService } from '@/core/SystemAccountService.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { toArray } from '@/misc/prelude/array.js';
+import { isPureRenote } from '@/misc/is-renote.js';
+import { CacheService } from '@/core/CacheService.js';
import { AnyCollection, getApId, getNullableApId, IObjectWithId, isCollection, isCollectionOrOrderedCollection, isCollectionPage, isOrderedCollection, isOrderedCollectionPage } from './type.js';
import { ApDbResolverService } from './ApDbResolverService.js';
import { ApRendererService } from './ApRendererService.js';
@@ -49,6 +51,7 @@ export class Resolver {
private loggerService: LoggerService,
private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
+ private readonly cacheService: CacheService,
private recursionLimit = 256,
) {
this.history = new Set();
@@ -355,18 +358,20 @@ export class Resolver {
switch (parsed.type) {
case 'notes':
- return this.notesRepository.findOneByOrFail({ id: parsed.id, userHost: IsNull() })
+ return this.notesRepository.findOneOrFail({ where: { id: parsed.id, userHost: IsNull() }, relations: { user: true, renote: true } })
.then(async note => {
- const author = await this.usersRepository.findOneByOrFail({ id: note.userId });
+ const author = note.user ?? await this.cacheService.findUserById(note.userId);
if (parsed.rest === 'activity') {
- // this refers to the create activity and not the note itself
- return this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, author), note));
+ return await this.apRendererService.renderNoteOrRenoteActivity(note, author);
+ } else if (!isPureRenote(note)) {
+ const apNote = await this.apRendererService.renderNote(note, author);
+ return this.apRendererService.addContext(apNote);
} else {
- return this.apRendererService.renderNote(note, author);
+ throw new IdentifiableError('732c2633-3395-4d51-a9b7-c7084774e3e7', `Failed to resolve local ${url}: cannot resolve a boost as note`);
}
}) as Promise;
case 'users':
- return this.usersRepository.findOneByOrFail({ id: parsed.id, host: IsNull() })
+ return this.cacheService.findLocalUserById(parsed.id)
.then(user => this.apRendererService.renderPerson(user as MiLocalUser));
case 'questions':
// Polls are indexed by the note they are attached to.
@@ -387,14 +392,8 @@ export class Resolver {
.then(async followRequest => {
if (followRequest == null) throw new IdentifiableError('a9d946e5-d276-47f8-95fb-f04230289bb0', `failed to resolve local ${url}: invalid follow request ID`);
const [follower, followee] = await Promise.all([
- this.usersRepository.findOneBy({
- id: followRequest.followerId,
- host: IsNull(),
- }),
- this.usersRepository.findOneBy({
- id: followRequest.followeeId,
- host: Not(IsNull()),
- }),
+ this.cacheService.findLocalUserById(followRequest.followerId),
+ this.cacheService.findLocalUserById(followRequest.followeeId),
]);
if (follower == null || followee == null) {
throw new IdentifiableError('06ae3170-1796-4d93-a697-2611ea6d83b6', `failed to resolve local ${url}: follower or followee does not exist`);
@@ -440,6 +439,7 @@ export class ApResolverService {
private loggerService: LoggerService,
private readonly apLogService: ApLogService,
private readonly apUtilityService: ApUtilityService,
+ private readonly cacheService: CacheService,
) {
}
@@ -465,6 +465,7 @@ export class ApResolverService {
this.loggerService,
this.apLogService,
this.apUtilityService,
+ this.cacheService,
opts?.recursionLimit,
);
}
diff --git a/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts b/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts
new file mode 100644
index 0000000000..3816479fd3
--- /dev/null
+++ b/packages/backend/src/core/activitypub/misc/extract-media-from-html.ts
@@ -0,0 +1,83 @@
+/*
+ * SPDX-FileCopyrightText: hazelnoot and other Sharkey contributors
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { load as cheerio } from 'cheerio/slim';
+import type { IApDocument } from '@/core/activitypub/type.js';
+import type { CheerioAPI } from 'cheerio/slim';
+
+/**
+ * Finds HTML elements representing inline media and returns them as simulated AP documents.
+ * Returns an empty array if the input cannot be parsed, or no media was found.
+ * @param html Input HTML to analyze.
+ */
+export function extractMediaFromHtml(html: string): IApDocument[] {
+ const $ = parseHtml(html);
+ if (!$) return [];
+
+ const attachments = new Map();
+
+ //
tags, including and