yufan.me/src/components/comment/artalk.ts

168 lines
4.5 KiB
TypeScript

import type {
Comment,
CommentConfig,
CommentItem,
CommentReq,
CommentResp,
Comments,
ErrorResp,
} from '@/components/comment/types';
import { increaseViews } from '@/helpers/db/query';
import { options } from '@/helpers/schema';
import { urlJoin } from '@/helpers/tools';
import { ARTALK_HOST } from 'astro:env/server';
import _ from 'lodash';
import { marked } from 'marked';
import * as querystring from 'node:querystring';
import { ELEMENT_NODE, transform, walk } from 'ultrahtml';
import sanitize from 'ultrahtml/transformers/sanitize';
// Access the artalk in internal docker host when it was deployed on zeabur.
const server = import.meta.env.PROD ? `http://${ARTALK_HOST}:23366` : options.settings.comments.server;
export const getConfig = async (): Promise<CommentConfig | null> => {
const data = await fetch(urlJoin(server, '/api/v2/conf'))
.then((response) => response.json())
.catch((e) => {
console.log(e);
return null;
});
return data != null ? (data as CommentConfig) : data;
};
export const loadComments = async (
key: string,
title: string | null,
offset: number,
config: CommentConfig,
): Promise<Comments | null> => {
let params: Record<string, string | number | boolean> = {
limit: config.frontend_conf.pagination.pageSize,
offset: offset,
flat_mode: false,
page_key: key,
site_name: options.title,
};
if (title !== null) {
params = { ...params, title: title };
}
const data = await fetch(urlJoin(server, `/api/v2/comments?${querystring.stringify(params)}`))
.then((response) => response.json())
.catch((e) => {
console.log(e);
return null;
});
// Increase the PV.
await increaseViews(key);
return data != null ? (data as Comments) : data;
};
export const createComment = async (req: CommentReq): Promise<ErrorResp | CommentResp> => {
const response = await fetch(urlJoin(server, '/api/v2/comments'), {
method: 'POST',
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
body: JSON.stringify({ ...req, site_name: options.title, rid: req.rid ? Number(req.rid) : 0 }),
}).catch((e) => {
console.log(e);
return null;
});
if (response === null) {
return { msg: 'failed to create comment' };
}
if (!response.ok) {
return (await response.json()) as ErrorResp;
}
return (await response.json()) as CommentResp;
};
export const parseComments = async (comments: Comment[]): Promise<CommentItem[]> => {
const parsedComments = await Promise.all(
comments.map(async (comment) => ({ ...comment, content: await parseContent(comment.content) })),
);
const childComments = _.groupBy(
parsedComments.filter((comment) => !rootCommentFilter(comment)),
(c) => c.rid,
);
return parsedComments.filter(rootCommentFilter).map((comment) => commentItems(comment, childComments));
};
const rootCommentFilter = (comment: Comment): boolean =>
comment.rid === 0 || comment.rid === null || typeof comment.rid === 'undefined';
const commentItems = (comment: Comment, childComments: _.Dictionary<Comment[]>): CommentItem => {
const children = childComments[`${comment.id}`];
if (typeof children === 'undefined') {
return comment;
}
return { ...comment, children: children.map((child) => commentItems(child, childComments)) };
};
const parseContent = async (content: string): Promise<string> => {
// Support paragraph in blank line.
const escapedContent = content.replace(/\r\n/g, '\n').replace(/(?<!\n)\n(?!\n)/g, '<br />');
const parsed = await marked.parse(escapedContent);
// Avoid the XSS attack.
return transform(parsed, [
async (node) => {
await walk(node, (node) => {
if (node.type === ELEMENT_NODE) {
if (node.name === 'a' && !node.attributes.href?.startsWith('https://yufan.me')) {
node.attributes.target = '_blank';
node.attributes.rel = 'nofollow';
}
}
});
return node;
},
sanitize({
allowElements: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'a',
'img',
'span',
'strong',
'code',
'pre',
'blockquote',
'del',
'i',
'u',
'sup',
'sub',
'em',
'b',
'font',
'hr',
'br',
'ul',
'ol',
'li',
],
allowAttributes: {
src: ['img'],
width: ['img'],
height: ['img'],
rel: ['a'],
target: ['a'],
},
allowComments: false,
}),
]);
};