Expand application functionality

This commit is contained in:
2026-07-20 19:23:11 +08:00 Unverified
parent 6aeed6b880
commit 5b86cdaec2
10 changed files with 3037 additions and 22 deletions
+17 -7
View File
@@ -1,4 +1,5 @@
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
export function createAdminRoutes(context) {
const {
@@ -1001,18 +1002,22 @@ export function createAdminRoutes(context) {
}
if (request.method === 'GET' && pathname === '/api/admin/notices') {
if (!requirePermission(user, response, '*')) return true;
return sendJson(response, 200, { ok: true, notices: db.notices.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt)) });
const notices = db.notices
.sort((a, b) => new Date(b.publishAt || b.createdAt) - new Date(a.publishAt || a.createdAt))
.map(noticeForClient);
return sendJson(response, 200, { ok: true, notices });
}
if (request.method === 'POST' && pathname === '/api/admin/notices') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const title = cleanText(body.title, 120);
const content = cleanText(body.content, 5000);
if (!title || !content) return sendError(response, 400, '通知标题和正文不能为空');
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || content.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
const content = sanitizeNoticeContent(body.content);
const contentText = noticePlainText(content);
if (!title || !contentText) return sendError(response, 400, '通知标题和正文不能为空');
const notice = { id: uid('notice'), title, summary: cleanText(body.summary, 260) || contentText.slice(0, 80), content, category: cleanText(body.category, 30) || '通知公告', pinned: Boolean(body.pinned), status: body.status === 'draft' ? 'draft' : 'published', publishAt: body.status === 'draft' ? null : nowIso(), createdAt: nowIso(), author: user.displayName };
const log = logAction(db, user, notice.status === 'published' ? '发布通知' : '保存通知草稿', notice.title);
await database.createNotice(notice, log);
return sendJson(response, 201, { ok: true, notice });
return sendJson(response, 201, { ok: true, notice: noticeForClient(notice) });
}
const noticeMatch = pathname.match(/^\/api\/admin\/notices\/([^/]+)$/);
if (request.method === 'PATCH' && noticeMatch) {
@@ -1020,7 +1025,12 @@ export function createAdminRoutes(context) {
const body = await readJson(request);
const notice = db.notices.find(item => item.id === noticeMatch[1]);
if (!notice) return sendError(response, 404, '通知不存在');
['title', 'summary', 'content', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], field === 'content' ? 5000 : 260); });
['title', 'summary', 'category'].forEach(field => { if (body[field] != null) notice[field] = cleanText(body[field], 260); });
if (body.content != null) {
const content = sanitizeNoticeContent(body.content);
if (!noticePlainText(content)) return sendError(response, 400, '通知正文不能为空');
notice.content = content;
}
if (body.pinned != null) notice.pinned = Boolean(body.pinned);
if (body.status && ['draft', 'published'].includes(body.status)) {
notice.status = body.status;
@@ -1028,7 +1038,7 @@ export function createAdminRoutes(context) {
}
const log = logAction(db, user, '更新通知', `${notice.title} · ${notice.status}`);
await database.updateNotice(notice, log);
return sendJson(response, 200, { ok: true, notice });
return sendJson(response, 200, { ok: true, notice: noticeForClient(notice) });
}
if (request.method === 'GET' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, 'results.read')) return true;
+3 -1
View File
@@ -1,3 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
export function createCandidateRoutes(context) {
const {
database,
@@ -65,7 +67,7 @@ export function createCandidateRoutes(context) {
if (request.method === 'GET' && pathname === '/api/candidate/dashboard') {
const registrations = db.registrations.filter(item => item.userId === user.id).map(item => examRegistrationView(db, item));
const results = db.results.filter(result => result.published && registrations.some(reg => reg.id === result.registrationId));
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5);
const notices = db.notices.filter(item => item.status === 'published').sort((a, b) => new Date(b.publishAt) - new Date(a.publishAt)).slice(0, 5).map(noticeForClient);
const profileInstance = pendingWorkflow(db, 'profile_change', profile.id)
|| db.workflowInstances.filter(item => item.businessType === 'profile_change' && item.businessId === profile.id)[0];
return sendJson(response, 200, { ok: true, profile, profileWorkflow: workflowView(db, profileInstance), registrations, results, notices });
+4 -2
View File
@@ -1,3 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
export function createPublicRoutes(context) {
const {
database,
@@ -50,14 +52,14 @@ export function createPublicRoutes(context) {
async function handlePublic(pathname, response) {
const db = await readDb();
if (pathname === '/api/public/home') {
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt));
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
return sendJson(response, 200, { ok: true, organization: db.organization, schools: db.schools.filter(item => item.active), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } });
}
const noticeMatch = pathname.match(/^\/api\/public\/notices\/([^/]+)$/);
if (noticeMatch) {
const notice = db.notices.find(item => item.id === noticeMatch[1] && item.status === 'published');
return notice ? sendJson(response, 200, { ok: true, notice }) : sendError(response, 404, '通知不存在或尚未发布');
return notice ? sendJson(response, 200, { ok: true, notice: noticeForClient(notice) }) : sendError(response, 404, '通知不存在或尚未发布');
}
return false;
}
+94
View File
@@ -0,0 +1,94 @@
import sanitizeHtml from 'sanitize-html';
const allowedTags = [
'p', 'br', 'h2', 'h3', 'h4',
'strong', 'em', 'u', 's',
'ul', 'ol', 'li', 'blockquote', 'a',
'figure', 'figcaption', 'img',
'table', 'thead', 'tbody', 'tfoot', 'tr', 'th', 'td'
];
const blockTags = /<\/?(?:p|h[2-4]|ul|ol|li|blockquote|br|figure|figcaption|table|thead|tbody|tfoot|tr|th|td)\b[^>]*>/gi;
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
function decodeTextEntities(value) {
const named = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", '#39': "'", nbsp: ' ' };
const codePoint = (code, radix) => {
const parsed = Number.parseInt(code, radix);
return Number.isInteger(parsed) && parsed >= 0 && parsed <= 0x10ffff && !(parsed >= 0xd800 && parsed <= 0xdfff)
? String.fromCodePoint(parsed)
: '';
};
return String(value)
.replace(/&#x([0-9a-f]+);/gi, (_, code) => codePoint(code, 16))
.replace(/&#(\d+);/g, (_, code) => codePoint(code, 10))
.replace(/&(amp|lt|gt|quot|apos|#39|nbsp);/gi, (_, name) => named[name.toLowerCase()]);
}
export function sanitizeNoticeContent(value) {
const source = String(value ?? '').trim().slice(0, 20000);
return sanitizeHtml(source, {
allowedTags,
allowedAttributes: {
a: ['href', 'target', 'rel'],
figure: ['class'],
img: ['src', 'alt'],
th: ['colspan', 'rowspan'],
td: ['colspan', 'rowspan']
},
allowedClasses: {
figure: [
'image', 'table', 'image-style-inline', 'image-style-block', 'image-style-side',
'image-style-align-left', 'image-style-align-right',
'image-style-block-align-left', 'image-style-block-align-right'
]
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowProtocolRelative: false,
transformTags: {
a(tagName, attributes) {
const safeAttributes = {};
if (attributes.href) safeAttributes.href = attributes.href;
if (attributes.target === '_blank') safeAttributes.target = '_blank';
safeAttributes.rel = 'noopener noreferrer';
return { tagName, attribs: safeAttributes };
},
img(tagName, attributes) {
const safeAttributes = {};
if (/^https?:\/\//i.test(attributes.src || '')) safeAttributes.src = attributes.src;
if (attributes.alt) safeAttributes.alt = attributes.alt;
return { tagName, attribs: safeAttributes };
}
}
});
}
export function noticePlainText(value) {
const sanitized = sanitizeNoticeContent(value).replace(blockTags, ' ');
const withoutTags = sanitizeHtml(sanitized, { allowedTags: [], allowedAttributes: {} });
return decodeTextEntities(withoutTags).replace(/\s+/g, ' ').trim();
}
export function noticeContentHtml(value) {
const source = String(value ?? '').trim();
if (!source) return '';
if (!/<\/?(?:p|h[2-4]|strong|em|u|s|ul|ol|li|blockquote|a|br|figure|figcaption|img|table|thead|tbody|tfoot|tr|th|td)\b/i.test(source)) {
return source
.split(/\r?\n{2,}/)
.map(paragraph => `<p>${escapeHtml(paragraph).replace(/\r?\n/g, '<br>')}</p>`)
.join('');
}
return sanitizeNoticeContent(source);
}
export function noticeForClient(notice) {
return { ...notice, content: sanitizeNoticeContent(notice.content), contentHtml: noticeContentHtml(notice.content) };
}