fix(learning): load readme images over https
This commit is contained in:
@@ -0,0 +1,62 @@
|
|||||||
|
# Task: Allow direct HTTPS images in Learning README
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
|
||||||
|
- Task ID: 20260820-direct-readme-client-a4d8e2c7
|
||||||
|
- Mode: Feature
|
||||||
|
- Branch: codex/20260820-direct-readme-client-a4d8e2c7-direct-readme-client
|
||||||
|
- Worktree: D:\Datas\OthersProjects\makelore-direct-readme-client-a4d8e2c7
|
||||||
|
- Base commit: 28bfd8bc3e21a3c2aeec7ee7b859abf571f08757
|
||||||
|
- Owner: codex
|
||||||
|
- Status: Ready for Integration
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
- Allow Learning README Markdown image nodes to load credential-free HTTPS URLs directly.
|
||||||
|
- Keep controlled project-media handling, cover behavior, raw-HTML suppression, external
|
||||||
|
link handling, and archive download boundaries unchanged.
|
||||||
|
|
||||||
|
## Intent And Constraints
|
||||||
|
|
||||||
|
- Follow the user's explicit replacement decision even though accepted client ADR-005
|
||||||
|
currently requires publish-time mirrored README media.
|
||||||
|
- Limit HTTPS enablement to README image nodes by opting into the existing guarded
|
||||||
|
`ProjectImage` direct-source path; do not create a Renderer network proxy.
|
||||||
|
- Preserve credential rejection and per-image failure isolation.
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
- Learning project detail now opts README images into the existing credential-free HTTPS
|
||||||
|
direct-loading path, including SVG and any format Electron can render.
|
||||||
|
- Renamed the shared URL predicate from cover-specific to image-generic terminology.
|
||||||
|
- Updated the detail-page regression to use a remote SVG URL and assert the HTTPS opt-in;
|
||||||
|
added direct component coverage proving the opt-in remains required.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Focused Learning tests: `5 passed` across the detail page and `ProjectImage`.
|
||||||
|
- Full unit suite: `176` files, `2058 passed`.
|
||||||
|
- `pnpm run typecheck`: passed.
|
||||||
|
- `pnpm run lint:check`: passed with zero errors and six pre-existing warnings.
|
||||||
|
- `pnpm run build:vite`: passed; existing chunk-size/dynamic-import advisories only.
|
||||||
|
- `git diff --check`: passed.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
- Reconcile ADR-005, Learning architecture/domain/current-state documents, the server
|
||||||
|
contract, and the production Learning commitment in an Integration Gate task.
|
||||||
|
- Package and smoke the matching server/client revisions with real remote README images.
|
||||||
|
|
||||||
|
## Promotion Candidates
|
||||||
|
|
||||||
|
- Target: ADR-005, Learning system overview/domain/current state,
|
||||||
|
`docs/learning-project-catalog-server-contract.md`, and Learning commitments.
|
||||||
|
- Proposal: document sanitized direct HTTPS README images instead of authenticated
|
||||||
|
mirrored media; retain Main-owned archive download and controlled cover/media paths.
|
||||||
|
- Evidence: explicit user direction, focused direct-SVG rendering regressions, full
|
||||||
|
client tests, typecheck, lint, and production build.
|
||||||
|
- Future impact: Renderer image requests go directly to third-party HTTPS origins;
|
||||||
|
image format support and availability are provided by Electron and the origin.
|
||||||
|
- Semantic conflicts: reverses the mirrored-media rule in accepted ADR-005 and its
|
||||||
|
derivative contract and operational checklist.
|
||||||
|
- Human confirmation required: no; the user explicitly selected direct rendering.
|
||||||
@@ -132,7 +132,7 @@ export function isLearningProjectMediaUrl(value: string): boolean {
|
|||||||
return PROJECT_MEDIA_URL_PATTERN.test(value);
|
return PROJECT_MEDIA_URL_PATTERN.test(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSafeLearningCoverUrl(value: string): boolean {
|
export function isSafeLearningImageUrl(value: string): boolean {
|
||||||
if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true;
|
if (PROJECT_MEDIA_URL_PATTERN.test(value)) return true;
|
||||||
try {
|
try {
|
||||||
const url = new URL(value);
|
const url = new URL(value);
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export function LearningProjectDetail() {
|
|||||||
<ProjectImage
|
<ProjectImage
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt || '项目说明图片'}
|
alt={alt || '项目说明图片'}
|
||||||
|
allowHttps
|
||||||
className="my-6 max-h-[34rem] w-full rounded-2xl border border-black/10 object-contain"
|
className="my-6 max-h-[34rem] w-full rounded-2xl border border-black/10 object-contain"
|
||||||
/>
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ImageOff, Loader2 } from 'lucide-react';
|
|||||||
import {
|
import {
|
||||||
fetchLearningProjectMedia,
|
fetchLearningProjectMedia,
|
||||||
isLearningProjectMediaUrl,
|
isLearningProjectMediaUrl,
|
||||||
isSafeLearningCoverUrl,
|
isSafeLearningImageUrl,
|
||||||
} from '@/lib/learning';
|
} from '@/lib/learning';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ type ProjectImageProps = {
|
|||||||
|
|
||||||
export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) {
|
export function ProjectImage({ src, alt, className, allowHttps = false }: ProjectImageProps) {
|
||||||
const controlledMedia = isLearningProjectMediaUrl(src);
|
const controlledMedia = isLearningProjectMediaUrl(src);
|
||||||
const directSource = allowHttps && isSafeLearningCoverUrl(src) && !controlledMedia ? src : null;
|
const directSource = allowHttps && isSafeLearningImageUrl(src) && !controlledMedia ? src : null;
|
||||||
const [failedSource, setFailedSource] = useState<string | null>(null);
|
const [failedSource, setFailedSource] = useState<string | null>(null);
|
||||||
const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({
|
const [state, setState] = useState<{ input: string; source: string; status: 'ready' | 'error' }>({
|
||||||
input: '',
|
input: '',
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ vi.mock('@/lib/learning', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@/pages/Learning/ProjectImage', () => ({
|
vi.mock('@/pages/Learning/ProjectImage', () => ({
|
||||||
ProjectImage: ({ src, alt }: { src: string; alt: string }) => <img src={src} alt={alt} />,
|
ProjectImage: ({ src, alt, allowHttps }: { src: string; alt: string; allowHttps?: boolean }) => (
|
||||||
|
<img src={src} alt={alt} data-allow-https={allowHttps ? 'true' : 'false'} />
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const project = {
|
const project = {
|
||||||
@@ -74,12 +76,12 @@ describe('Learning project pages', () => {
|
|||||||
expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 });
|
expect(fetchLearningProjectsMock).toHaveBeenNthCalledWith(2, { cursor: 'cursor-2', limit: 24 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders Markdown without raw HTML and keeps remote README media on the controlled path', async () => {
|
it('renders Markdown without raw HTML and allows direct HTTPS README images', async () => {
|
||||||
fetchLearningProjectMock.mockResolvedValue({
|
fetchLearningProjectMock.mockResolvedValue({
|
||||||
...project,
|
...project,
|
||||||
archiveFileName: 'robot-arm.zip',
|
archiveFileName: 'robot-arm.zip',
|
||||||
archiveSha256: 'a'.repeat(64),
|
archiveSha256: 'a'.repeat(64),
|
||||||
readmeMarkdown: '# 开始搭建\n\n\n\n<script>bad()</script>\n\n[参考资料](https://example.com/guide)',
|
readmeMarkdown: '# 开始搭建\n\n\n\n<script>bad()</script>\n\n[参考资料](https://example.com/guide)',
|
||||||
});
|
});
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={['/learning/project/robot-arm']}>
|
<MemoryRouter initialEntries={['/learning/project/robot-arm']}>
|
||||||
@@ -90,8 +92,9 @@ describe('Learning project pages', () => {
|
|||||||
expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: '开始搭建' })).toBeInTheDocument();
|
||||||
expect(screen.getByAltText('接线图')).toHaveAttribute(
|
expect(screen.getByAltText('接线图')).toHaveAttribute(
|
||||||
'src',
|
'src',
|
||||||
'/api/learning/projects/robot-arm/media/wiring',
|
'https://images.example.com/wiring.svg',
|
||||||
);
|
);
|
||||||
|
expect(screen.getByAltText('接线图')).toHaveAttribute('data-allow-https', 'true');
|
||||||
expect(document.querySelector('script')).toBeNull();
|
expect(document.querySelector('script')).toBeNull();
|
||||||
fireEvent.click(screen.getByRole('link', { name: '参考资料' }));
|
fireEvent.click(screen.getByRole('link', { name: '参考资料' }));
|
||||||
expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide');
|
expect(openLearningExternalLinkMock).toHaveBeenCalledWith('https://example.com/guide');
|
||||||
|
|||||||
30
tests/unit/learning-project-image.test.tsx
Normal file
30
tests/unit/learning-project-image.test.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { ProjectImage } from '@/pages/Learning/ProjectImage';
|
||||||
|
|
||||||
|
describe('Learning ProjectImage', () => {
|
||||||
|
it('renders a credential-free HTTPS image only when direct loading is allowed', () => {
|
||||||
|
const { rerender } = render(
|
||||||
|
<ProjectImage
|
||||||
|
src="https://images.example.com/diagram.svg"
|
||||||
|
alt="项目结构图"
|
||||||
|
allowHttps
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByAltText('项目结构图')).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://images.example.com/diagram.svg',
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<ProjectImage
|
||||||
|
src="https://images.example.com/diagram.svg"
|
||||||
|
alt="项目结构图"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.queryByAltText('项目结构图')).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('img', { name: '项目结构图加载失败' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user