feat: 日报新增模板市场,支持社区模板安装与使用

This commit is contained in:
Wxw-Gu
2026-09-08 11:37:38 +08:00
parent 1156362c3c
commit 976aac32c5
35 changed files with 2957 additions and 82 deletions
+274
View File
@@ -0,0 +1,274 @@
import crypto from 'node:crypto'
import { access, mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
tempDirectory: '',
fetch: vi.fn(),
install: vi.fn()
}))
vi.mock('electron', () => ({
app: {
getPath: (name: string) => (name === 'temp' ? mocks.tempDirectory : mocks.tempDirectory)
}
}))
vi.mock('../../src/main/report-template-service', () => ({
reportTemplateService: {
install: mocks.install
}
}))
import { ReportTemplateMarketService } from '../../src/main/report-template-market-service'
import { REPORT_TEMPLATE_CATALOG_URL } from '../../src/shared/report-template-market'
import {
decodeExternalReportTemplateId,
encodeExternalReportTemplateId
} from '../../src/shared/report-templates'
const commit = 'a'.repeat(40)
const packageBytes = Buffer.from('fixture-template-package')
const packageSha256 = crypto.createHash('sha256').update(packageBytes).digest('hex')
const templateId = 'community.github.tracememo.quickread'
const templateVersion = '1.0.0'
const downloadUrl = `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${commit}/packages/${templateId}/${templateVersion}/${templateId}-${templateVersion}.zip`
const previewUrl = `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${commit}/previews/${templateId}/${templateVersion}.png`
const catalog = (
patch: Record<string, unknown> = {}
): {
schemaVersion: string
generatedAt: string
source: { repository: string; commit: string }
status: string
templates: Array<Record<string, unknown>>
} => ({
schemaVersion: '1',
generatedAt: '2026-09-07T08:08:13Z',
source: { repository: 'Wxw-Gu/TraceMemo-Templates', commit },
status: 'published',
templates: [
{
id: templateId,
version: templateVersion,
interfaceVersion: '1',
name: '极简速读',
description: '虚构模板目录条目',
author: 'fixture',
platform: 'mobile',
tags: ['fixture'],
license: 'MIT',
minAppVersion: null,
download: downloadUrl,
sizeBytes: packageBytes.length,
sha256: packageSha256,
preview: previewUrl,
publishedAt: '2026-09-07T08:08:13Z',
status: 'published'
}
],
...patch
})
const responseFor = (body: Buffer | string, status = 200): Response =>
new Response(body, {
status,
headers: { 'content-length': String(Buffer.byteLength(body)) }
})
const installResult = {
id: templateId,
version: templateVersion,
interfaceVersion: '1',
source: 'installed' as const,
name: '极简速读',
author: 'fixture',
entryPath: '/tmp/fixture-template/template.html',
capture: { width: 430, maxWidth: 430, maxHeight: 20_000 },
license: { spdx: 'MIT' }
}
describe('ReportTemplateMarketService', () => {
let service: ReportTemplateMarketService
beforeEach(async () => {
mocks.tempDirectory = await mkdtemp(join(tmpdir(), 'tracememo-template-market-test-'))
service = new ReportTemplateMarketService()
mocks.fetch.mockReset()
mocks.install.mockReset()
vi.stubGlobal('fetch', mocks.fetch)
})
afterEach(async () => {
vi.unstubAllGlobals()
await rm(mocks.tempDirectory, { recursive: true, force: true })
})
it('loads and normalizes a published catalog entry from the fixed raw URL', async () => {
mocks.fetch.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
const result = await service.listCatalog()
expect(result).toEqual({ success: true, catalog: catalog() })
expect(mocks.fetch).toHaveBeenCalledOnce()
expect(mocks.fetch).toHaveBeenCalledWith(
new URL(REPORT_TEMPLATE_CATALOG_URL),
expect.objectContaining({
headers: expect.objectContaining({
Accept: 'application/json, application/zip, image/png',
'User-Agent': 'TraceMemo'
}),
redirect: 'error'
})
)
})
it('rejects incompatible interfaces and non-raw download URLs before install', async () => {
mocks.fetch.mockResolvedValueOnce(
responseFor(JSON.stringify(catalog({ templates: [{ ...catalog().templates[0], interfaceVersion: '2' }] })))
)
const incompatible = await service.listCatalog()
expect(incompatible.success).toBe(false)
expect(incompatible.code).toBe('unsupported_interface')
expect(incompatible.error).toContain('模板接口版本不兼容')
mocks.fetch.mockReset()
mocks.fetch.mockResolvedValueOnce(
responseFor(JSON.stringify(catalog({ templates: [{ ...catalog().templates[0], download: 'https://evil.example/template.zip' }] })))
)
const invalidUrl = await service.listCatalog()
expect(invalidUrl.success).toBe(false)
expect(invalidUrl.code).toBe('invalid_catalog')
expect(invalidUrl.error).toContain('只允许 GitHub raw HTTPS 地址')
expect(mocks.install).not.toHaveBeenCalled()
})
it('rejects a catalog URL from another raw GitHub repository', async () => {
mocks.fetch.mockResolvedValueOnce(
responseFor(
JSON.stringify(
catalog({
templates: [
{
...catalog().templates[0],
download: `https://raw.githubusercontent.com/another-owner/another-repo/${commit}/template.zip`
}
]
})
)
)
)
const result = await service.listCatalog()
expect(result.success).toBe(false)
expect(result.code).toBe('invalid_catalog')
expect(result.error).toContain('只允许 GitHub raw HTTPS 地址')
})
it('rejects package references that are not pinned to the catalog source commit', async () => {
mocks.fetch.mockResolvedValueOnce(
responseFor(
JSON.stringify(
catalog({
templates: [
{
...catalog().templates[0],
download: `https://raw.githubusercontent.com/Wxw-Gu/TraceMemo-Templates/${'b'.repeat(40)}/packages/${templateId}/${templateVersion}/${templateId}-${templateVersion}.zip`
}
]
})
)
)
)
const result = await service.listCatalog()
expect(result.success).toBe(false)
expect(result.code).toBe('invalid_catalog')
expect(result.error).toContain('未固定到目录 source.commit')
})
it('reports a missing catalog version without downloading a package', async () => {
mocks.fetch.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
const result = await service.installFromCatalog(templateId, '9.9.9')
expect(result).toEqual({
success: false,
code: 'catalog_template_not_found',
error: `远端目录不存在:${templateId}@9.9.9`
})
expect(mocks.fetch).toHaveBeenCalledOnce()
expect(mocks.install).not.toHaveBeenCalled()
})
it('rejects a package when downloaded bytes do not match catalog size or SHA-256', async () => {
mocks.fetch
.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
.mockResolvedValueOnce(responseFor(Buffer.from('tampered-package')))
const result = await service.installFromCatalog(templateId, templateVersion)
expect(result).toMatchObject({
success: false,
code: 'download_integrity_failed',
catalogEntry: expect.objectContaining({ id: templateId, version: templateVersion })
})
expect(mocks.fetch).toHaveBeenCalledTimes(2)
expect(mocks.install).not.toHaveBeenCalled()
})
it('writes a verified package to a temporary path, installs it, and removes the temporary directory', async () => {
mocks.fetch
.mockResolvedValueOnce(responseFor(JSON.stringify(catalog())))
.mockResolvedValueOnce(responseFor(packageBytes))
let packagePath = ''
mocks.install.mockImplementationOnce(async (candidatePath: string) => {
packagePath = candidatePath
await expect(readFile(candidatePath)).resolves.toEqual(packageBytes)
return installResult
})
const result = await service.installFromCatalog(templateId, templateVersion)
expect(result).toEqual({ success: true, template: installResult, catalogEntry: catalog().templates[0] })
expect(mocks.install).toHaveBeenCalledOnce()
expect(packagePath).toContain(`${templateId}-${templateVersion}.zip`)
await expect(access(packagePath)).rejects.toMatchObject({ code: 'ENOENT' })
})
})
describe('external report template selection keys', () => {
it('round-trips IDs and versions without collapsing them into built-in IDs', () => {
const selectionId = encodeExternalReportTemplateId(templateId, templateVersion)
expect(selectionId).toBe(`external:${templateId}@${templateVersion}`)
expect(decodeExternalReportTemplateId(selectionId)).toEqual({
id: templateId,
version: templateVersion
})
})
it('rejects malformed, incomplete, or non-string external keys', () => {
for (const value of [
undefined,
null,
'mobile-feed',
'external:',
'external:community.github.example@',
'external:@1.0.0',
'external:community.github.example@1.0.0@extra',
'external:community.github.example'
]) {
expect(decodeExternalReportTemplateId(value)).toBeNull()
}
})
})
+126
View File
@@ -0,0 +1,126 @@
import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
import { ZipArchive } from 'archiver'
const mockPaths = vi.hoisted(() => ({ userData: '' }))
vi.mock('electron', () => ({ app: { getPath: () => mockPaths.userData } }))
const userData = await mkdtemp(join(tmpdir(), 'tracememo-template-service-'))
mockPaths.userData = userData
import { ReportTemplateError, REPORT_TEMPLATE_LIMITS } from '../../src/shared/report-template-package'
import { ReportTemplateService } from '../../src/main/report-template-service'
async function makeZip(files: Record<string, string | Buffer>): Promise<string> {
const zipPath = join(userData, `${Math.random().toString(36).slice(2)}.zip`)
const output = (await import('node:fs')).createWriteStream(zipPath)
const archive = new ZipArchive({ zlib: { level: 6 } })
archive.pipe(output)
for (const [name, content] of Object.entries(files)) archive.append(content, { name })
await archive.finalize()
await new Promise<void>((resolve, reject) => {
output.on('close', () => resolve())
output.on('error', reject)
})
return zipPath
}
const manifest = (patch: Record<string, unknown> = {}): string => JSON.stringify({
protocolVersion: '1.0', kind: 'daily-report', id: 'community.github.example.basic-feed', name: '示例',
author: { name: 'example' }, templateVersion: '1.0.0', interfaceVersion: '1', entry: 'template.html',
capture: { width: 430, maxWidth: 430, maxHeight: 20000 }, license: { spdx: 'MIT' }, ...patch
})
describe('ReportTemplateService', () => {
let service: ReportTemplateService
beforeEach(() => { service = new ReportTemplateService() })
afterEach(async () => { await rm(join(userData, 'report-templates'), { recursive: true, force: true }) })
it('installs, lists, reloads and uninstalls a valid package', async () => {
const zip = await makeZip({ 'manifest.json': manifest(), 'template.html': '<!doctype html><html><head><style>.x{color:red}</style></head><body><h1 class="{{TOPICS_EMPTY_CLASS}}">{{REPORT_TITLE}}</h1></body></html>' })
const installed = await service.install(zip)
expect(installed.source).toBe('installed')
expect((await service.list()).some((item) => item.id === installed.id)).toBe(true)
const reloaded = new ReportTemplateService()
expect((await reloaded.list()).find((item) => item.id === installed.id)?.version).toBe('1.0.0')
await service.uninstall(installed.id, installed.version)
expect((await service.list()).some((item) => item.id === installed.id)).toBe(false)
})
it('rejects missing or incompatible manifest fields', async () => {
const missing = await makeZip({ 'manifest.json': '{}', 'template.html': '<p>x</p>' })
await expect(service.install(missing)).rejects.toMatchObject({ code: 'unsupported_protocol' })
const incompatible = await makeZip({ 'manifest.json': manifest({ interfaceVersion: '2' }), 'template.html': '<p>x</p>' })
await expect(service.install(incompatible)).rejects.toMatchObject({ code: 'unsupported_interface' })
})
it('rejects dangerous HTML, invalid interpolation and duplicate paths', async () => {
const dangerous = await makeZip({ 'manifest.json': manifest(), 'template.html': '<script>alert(1)</script>' })
await expect(service.install(dangerous)).rejects.toBeInstanceOf(ReportTemplateError)
const attr = await makeZip({ 'manifest.json': manifest(), 'template.html': '<div class="{{REPORT_TITLE}}"></div>' })
await expect(service.install(attr)).rejects.toMatchObject({ code: 'invalid_placeholder_context' })
const css = await makeZip({ 'manifest.json': manifest(), 'template.html': '<style>.x{background:url(https://evil.test/a.png)}</style>' })
await expect(service.install(css)).rejects.toMatchObject({ code: 'unsafe_url' })
const missingAsset = await makeZip({ 'manifest.json': manifest(), 'template.html': '<img src="assets/missing.png">' })
await expect(service.install(missingAsset)).rejects.toMatchObject({ code: 'missing_asset' })
const validKinds = await makeZip({ 'manifest.json': manifest(), 'template.html': '<div class="{{TOPICS_EMPTY_CLASS}}">{{TOPICS_MORE_NOTE}}</div>' })
await expect(service.install(validKinds)).resolves.toMatchObject({ id: 'community.github.example.basic-feed' })
const duplicate = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>x</p>', 'TEMPLATE.HTML': '<p>y</p>' })
await expect(service.install(duplicate)).rejects.toMatchObject({ code: 'duplicate_entry' })
})
it('rejects traversal and oversized packages before staging output', async () => {
const traversal = await makeZip({ 'manifest.json': manifest(), '../escape.txt': 'x', 'template.html': '<p>x</p>' })
await expect(service.install(traversal)).rejects.toBeInstanceOf(ReportTemplateError)
const oversized = await makeZip({ 'manifest.json': manifest(), 'template.html': Buffer.alloc(REPORT_TEMPLATE_LIMITS.maxFileBytes + 1) })
await expect(service.install(oversized)).rejects.toMatchObject({ code: 'file_too_large' })
await expect(readFile(join(userData, 'escape.txt'))).rejects.toBeDefined()
})
it('rejects an uninstall reference that could escape the registry directory', async () => {
await expect(service.uninstall('../outside', '1.0.0')).rejects.toMatchObject({
code: 'invalid_template_ref'
})
await expect(
service.uninstall('community.github.example.basic-feed', '../outside')
).rejects.toMatchObject({ code: 'invalid_template_ref' })
})
it('rejects external refs that target built-in templates', async () => {
await expect(service.resolve({ id: 'v1', version: '1.0.0' })).rejects.toMatchObject({
code: 'builtin_template_ref'
})
})
it('checks an expected catalog identity before staging a package', async () => {
const zip = await makeZip({
'manifest.json': manifest(),
'template.html': '<p>{{REPORT_TITLE}}</p>'
})
await expect(
service.install(zip, {
id: 'community.github.example.other-template',
version: '1.0.0'
})
).rejects.toMatchObject({ code: 'catalog_manifest_mismatch' })
})
it('is idempotent for identical versions and rejects content conflicts', async () => {
const first = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>a</p>' })
const second = await makeZip({ 'manifest.json': manifest(), 'template.html': '<p>b</p>' })
const installed = await service.install(first)
await expect(service.install(first)).resolves.toMatchObject({ id: installed.id, version: installed.version })
await expect(service.install(second)).rejects.toMatchObject({ code: 'version_conflict' })
expect(await readdir(join(userData, 'report-templates', 'staging'))).toHaveLength(0)
})
it('rebuilds a damaged index from installed manifests', async () => {
const zip = await makeZip({ 'manifest.json': manifest({ templateVersion: '2.0.0' }), 'template.html': '<p>{{REPORT_TITLE}}</p>' })
const installed = await service.install(zip)
await writeFile(join(userData, 'report-templates', 'index.json'), '{broken', 'utf8')
const recovered = new ReportTemplateService()
await recovered.recover()
expect((await recovered.list()).find((item) => item.id === installed.id && item.version === '2.0.0')?.entryPath).toContain('installed')
})
})