diff --git a/backend/src/newsletter/markdownPreview.ts b/backend/src/newsletter/markdownPreview.ts index 123395df..1a045ef5 100644 --- a/backend/src/newsletter/markdownPreview.ts +++ b/backend/src/newsletter/markdownPreview.ts @@ -17,12 +17,17 @@ import { decryptSponsorDocAccessCode, encryptSponsorDocAccessCode, generateSponsorDocAccessCode, + getSponsorDoc, + listSponsorDocSummariesByCardId, recordSponsorDocFailedAttempt, verifySponsorDocCodeConstantTime, } from '../db/sponsorDocs'; import { resolveInteractiveActor } from '../identity/actor'; import { TeamDirectory } from '../identity/directory'; +import { savePublicImage } from '../publicImages'; +import { extractMultipartImageBuffer, publicImagePayload } from '../routes/publicImages'; import { sponsorDocPortalBaseUrl } from '../routes/sponsorDocs'; +import { FileStorageError } from '../storage'; import type { Card, LambdaEvent, LambdaResponse } from '../types'; import { issueLabel, SLOT_POSITIONS } from './issueCard'; import { syncStandaloneMarkdownTask } from './markdownTask'; @@ -98,6 +103,28 @@ function escapeHtml(raw: string): string { .replace(/'/g, '''); } +const KNOWN_COURSE_BANNERS: Readonly> = { + 'ai dev tools zoomcamp': { + alt: 'AI Dev Tools Zoomcamp', + url: 'https://mcusercontent.com/0d7822ab98152f5afc118c176/images/c39d593c-3435-b19e-3a98-d2d110924dc8.png', + }, + 'machine learning zoomcamp': { + alt: 'Machine Learning Zoomcamp', + url: 'https://mcusercontent.com/0d7822ab98152f5afc118c176/images/f6aee0bd-8307-6d8e-2e90-355ccb46929d.png', + }, +}; + +export interface SponsoredGraphicAsset { + url: string; + alt: string; + ctaUrl?: string; +} + +function isHostedImageUrl(rawUrl: string): boolean { + const trimmed = String(rawUrl || '').trim(); + return /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/'); +} + function sanitizeHref(rawUrl: string): string | null { const trimmed = String(rawUrl || '').trim(); if (/^(https?:\/\/|mailto:)/i.test(trimmed)) return trimmed; @@ -148,12 +175,37 @@ export function renderNewsletterMarkdownHtml(markdown: string): string { if (!paragraphLines.length) return; const joined = paragraphLines.join(' '); paragraphLines = []; + const trimmedJoined = joined.trim(); + // Known course banner marker: [Banner: Course name] + const bannerMatch = /^\[Banner:\s*([^\]]+)\]$/i.exec(trimmedJoined); + if (bannerMatch) { + const knownBanner = KNOWN_COURSE_BANNERS[bannerMatch[1].trim().toLowerCase()]; + if (knownBanner) { + blocks.push( + `
${escapeHtml(knownBanner.alt)}
`, + ); + return; + } + } + // Standalone linked image block: [![alt](src)](https://...) + const linkedImageMatch = /^\[!\[([^\]]*)\]\(([^)]+)\)\]\((https?:\/\/[^)]+)\)$/i.exec(trimmedJoined); + if (linkedImageMatch) { + const alt = escapeHtml(linkedImageMatch[1].trim()); + const srcRaw = linkedImageMatch[2].trim(); + const safeHref = sanitizeHref(linkedImageMatch[3].trim()); + if (isHostedImageUrl(srcRaw) && safeHref) { + blocks.push( + `
${alt}${alt ? `
${alt}
` : ''}
`, + ); + return; + } + } // Standalone image block: ![alt](src) - const imageMatch = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(joined.trim()); + const imageMatch = /^!\[([^\]]*)\]\(([^)]+)\)$/.exec(trimmedJoined); if (imageMatch) { const alt = escapeHtml(imageMatch[1].trim()); const srcRaw = imageMatch[2].trim(); - if (/^https?:\/\//i.test(srcRaw) || srcRaw.startsWith('/')) { + if (isHostedImageUrl(srcRaw)) { blocks.push( `
${alt}${alt ? `
${alt}
` : ''}
`, ); @@ -165,7 +217,7 @@ export function renderNewsletterMarkdownHtml(markdown: string): string { return; } // Standalone CTA link block: [Label](https://...) - const ctaMatch = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/i.exec(joined.trim()); + const ctaMatch = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/i.exec(trimmedJoined); if (ctaMatch) { const label = renderInlineMarkdown(escapeHtml(ctaMatch[1].trim())); const safeHref = sanitizeHref(ctaMatch[2].trim()); @@ -247,11 +299,59 @@ export interface ParsedNewsletterIssueMarkdown { bodyHtml: string; } +const SECTION_BOUNDARY_RE = /^(?:\[Divider\]|\[Banner:|-{3,}|\*{3,}|(?:#{1,4}\s+)?(?:EVENTS|RECORDINGS|ARTICLES|JOBS|COMMUNITY|TOOLS|COURSES)\s*$)/i; + +/** + * Insert the sponsor's hosted graphic (linked to the tracked CTA) right after + * the `SPONSORED` marker when the section does not already contain an image. + */ +export function injectSponsoredGraphic( + bodyMarkdown: string, + graphic: SponsoredGraphicAsset | null | undefined, +): string { + if (!graphic || !isHostedImageUrl(graphic.url)) return bodyMarkdown; + const lines = String(bodyMarkdown || '').split('\n'); + const sponsoredIdx = lines.findIndex((line) => /^(?:#{1,4}\s+)?SPONSORED\s*$/i.test(line.trim())); + if (sponsoredIdx < 0) return bodyMarkdown; + + let sectionEnd = lines.length; + for (let i = sponsoredIdx + 1; i < lines.length; i++) { + if (SECTION_BOUNDARY_RE.test(lines[i].trim())) { + sectionEnd = i; + break; + } + } + + let fallbackCtaUrl: string | undefined; + for (let i = sponsoredIdx + 1; i < sectionEnd; i++) { + const trimmed = lines[i].trim(); + if (/!\[[^\]]*\]\([^)]+\)/.test(trimmed)) { + return bodyMarkdown; + } + if (!fallbackCtaUrl) { + const ctaMatch = /^\[([^\]]+)\]\((https?:\/\/[^)]+)\)$/i.exec(trimmed); + if (ctaMatch) fallbackCtaUrl = ctaMatch[2].trim(); + } + } + + const alt = String(graphic.alt || 'Sponsor').replace(/[\[\]]/g, '').trim() || 'Sponsor'; + const imgMd = `![${alt}](${graphic.url})`; + const safeCta = sanitizeHref(graphic.ctaUrl || fallbackCtaUrl || ''); + const lineToInsert = safeCta ? `[${imgMd}](${safeCta})` : imgMd; + + const next = [...lines]; + next.splice(sponsoredIdx + 1, 0, '', lineToInsert); + return next.join('\n'); +} + /** * Parse `issue.md` into frontmatter metadata, `Subject:` / `Preview:` lines, * and rendered HTML email body. */ -export function parseNewsletterIssueMarkdown(rawIssueMd: string): ParsedNewsletterIssueMarkdown { +export function parseNewsletterIssueMarkdown( + rawIssueMd: string, + sponsoredGraphic?: SponsoredGraphicAsset | null, +): ParsedNewsletterIssueMarkdown { const normalized = String(rawIssueMd || '').replace(/\r\n/g, '\n'); const frontmatter: Record = {}; let rest = normalized; @@ -297,12 +397,13 @@ export function parseNewsletterIssueMarkdown(rawIssueMd: string): ParsedNewslett } const bodyMarkdown = bodyLines.join('\n').trim(); + const effectiveBody = injectSponsoredGraphic(bodyMarkdown, sponsoredGraphic); return { frontmatter, subject, previewText, bodyMarkdown, - bodyHtml: renderNewsletterMarkdownHtml(bodyMarkdown), + bodyHtml: renderNewsletterMarkdownHtml(effectiveBody), }; } @@ -404,17 +505,59 @@ async function resolveStaffOrExternalAccess( return { isStaff: false }; } +async function resolveIssueSponsorGraphic( + client: DynamoDBDocumentClient, + issue: NewsletterIssue, + event: LambdaEvent, +): Promise { + const candidateIds: string[] = []; + if (issue.cardId) { + const docs = await listSponsorDocSummariesByCardId(client, issue.cardId); + const preferredOrder = issue.kind === 'standalone' + ? ['newsletter-standalone', 'newsletter-main', 'newsletter-secondary'] + : ['newsletter-main', 'newsletter-standalone', 'newsletter-secondary']; + for (const tpl of preferredOrder) { + for (const doc of docs) { + if (doc.template === tpl && !candidateIds.includes(doc.id)) { + candidateIds.push(doc.id); + } + } + } + } + const slotUrl = issue.slots?.primary?.documentUrl || ''; + const urlMatch = /\/sponsor-docs\/(sdoc-[a-z0-9]+)/i.exec(slotUrl); + if (urlMatch && !candidateIds.includes(urlMatch[1])) { + candidateIds.push(urlMatch[1]); + } + + for (const docId of candidateIds) { + const doc = await getSponsorDoc(client, docId); + if (!doc) continue; + const rawGraphics = String(doc.content?.graphicsUrl || '').trim(); + if (!isHostedImageUrl(rawGraphics)) continue; + const url = rawGraphics.startsWith('/') + ? `${sponsorDocPortalBaseUrl(event)}${rawGraphics}` + : rawGraphics; + const rawCta = String(doc.content?.ctaUrl || '').trim(); + const ctaUrl = sanitizeHref(rawCta) || undefined; + const alt = String(doc.sponsorName || issue.slots?.primary?.sponsor || 'Sponsor').trim() || 'Sponsor'; + return { url, alt, ctaUrl }; + } + return null; +} + function buildPreviewResponsePayload( issue: NewsletterIssue, event: LambdaEvent, options: { viewerRole: 'staff' | 'external'; accessCode?: string | null; + sponsoredGraphic?: SponsoredGraphicAsset | null; }, ) { const previewUrl = newsletterIssuePreviewUrl(event, issue.id); const md: NewsletterMarkdownPackage = issue.markdown || {}; - const parsedIssue = parseNewsletterIssueMarkdown(md.issue || ''); + const parsedIssue = parseNewsletterIssueMarkdown(md.issue || '', options.sponsoredGraphic); const sourcesHtml = renderNewsletterMarkdownHtml(md.sources || ''); const briefHtml = renderNewsletterMarkdownHtml(md.brief || ''); @@ -548,11 +691,13 @@ export async function handleNewsletterMarkdownRoutes( if (access.isStaff) { if (!existing) return json(404, { error: 'Newsletter issue not found', code: 'issue_not_found' }); const ensured = await ensureIssueHasPreviewCode(client, existing); + const sponsoredGraphic = await resolveIssueSponsorGraphic(client, ensured.issue, event); return json( 200, buildPreviewResponsePayload(ensured.issue, event, { viewerRole: 'staff', accessCode: ensured.accessCode, + sponsoredGraphic, }), ); } @@ -604,7 +749,8 @@ export async function handleNewsletterMarkdownRoutes( } await clearSponsorDocRateLimit(client, rateTargetId, ip); - return json(200, buildPreviewResponsePayload(existing, event, { viewerRole: 'external' })); + const sponsoredGraphic = await resolveIssueSponsorGraphic(client, existing, event); + return json(200, buildPreviewResponsePayload(existing, event, { viewerRole: 'external', sponsoredGraphic })); } if (action === 'rotate-code') { @@ -625,11 +771,13 @@ export async function handleNewsletterMarkdownRoutes( } catch { return json(409, { error: 'Issue changed; reload and retry' }); } + const sponsoredGraphic = await resolveIssueSponsorGraphic(client, updated, event); return json( 200, buildPreviewResponsePayload(updated, event, { viewerRole: 'staff', accessCode: generated.code, + sponsoredGraphic, }), ); } @@ -733,11 +881,45 @@ export async function handleNewsletterMarkdownRoutes( // the step leaves the today/overdue queue. Never fails this write. await syncStandaloneMarkdownTask(client, updated, resolved.actor.id || 'staff'); + const sponsoredGraphic = await resolveIssueSponsorGraphic(client, updated, event); return json( 200, buildPreviewResponsePayload(updated, event, { viewerRole: 'staff', accessCode, + sponsoredGraphic, }), ); } + +/** + * Upload a newsletter issue image to durable public hosting (`/api/public-images/:id`) + * and return its public URL for use in `issue.md` / Markdown preview. + */ +export async function handleNewsletterImageUpload( + idOrCardId: string, + event: LambdaEvent, + client: DynamoDBDocumentClient, +): Promise { + const resolved = await resolveInteractiveActor(client, event, 'work-write'); + if (!resolved.ok) return resolved.response; + + const existing = await resolveIssueByIdOrCardId(client, idOrCardId); + if (!existing) { + return json(404, { error: 'Newsletter issue not found', code: 'issue_not_found' }); + } + + try { + const buffer = extractMultipartImageBuffer(event); + const saved = await savePublicImage(buffer); + return json(201, { + issueId: existing.id, + image: publicImagePayload(saved, event), + }); + } catch (err: unknown) { + if (err instanceof FileStorageError) { + return json(err.statusCode, { error: err.message, code: err.code }); + } + throw err; + } +} diff --git a/backend/src/routes/newsletterIssues.ts b/backend/src/routes/newsletterIssues.ts index 8e278d7e..b56df440 100644 --- a/backend/src/routes/newsletterIssues.ts +++ b/backend/src/routes/newsletterIssues.ts @@ -77,6 +77,7 @@ import { filterSponsorStatsReports, } from '../newsletter/statsReport'; import { + handleNewsletterImageUpload, handleNewsletterMarkdownRoutes, publicNewsletterIssueFields, } from '../newsletter/markdownPreview'; @@ -1156,6 +1157,11 @@ export async function handleNewsletterIssueRoutes( const body = parse(event) || {}; return mailchimpMetricsRoute(decodeURIComponent(mailchimpMetrics[1]), body, event, client); } + const imageRoute = path.match(/^\/api\/newsletter-issues\/([^/]+)\/images\/?$/); + if (imageRoute) { + if (method !== 'POST') return json(405, { error: 'Method not allowed' }); + return handleNewsletterImageUpload(decodeURIComponent(imageRoute[1]), event, client); + } const rotateMdCode = path.match(/^\/api\/newsletter-issues\/([^/]+)\/markdown\/rotate-code\/?$/); if (rotateMdCode) { if (method !== 'POST') return json(405, { error: 'Method not allowed' }); diff --git a/backend/tests/newsletter-markdown.test.ts b/backend/tests/newsletter-markdown.test.ts index e1863c74..6cabcd8c 100644 --- a/backend/tests/newsletter-markdown.test.ts +++ b/backend/tests/newsletter-markdown.test.ts @@ -1,5 +1,8 @@ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; import { getClient } from '../src/db/client'; @@ -11,7 +14,9 @@ import { getNewsletterIssue, updateNewsletterIssue, } from '../src/db/newsletterIssues'; +import { createSponsorDoc } from '../src/db/sponsorDocs'; import { createUser } from '../src/db/users'; +import { renderNewsletterMarkdownHtml } from '../src/newsletter/markdownPreview'; import { humanBearer } from './helpers/human-bearer'; import { templateLink } from './helpers/cardTemplate'; @@ -265,4 +270,148 @@ describe('Newsletter Markdown Storage & HTML Preview (/api/newsletter-issues/:id assert.strictEqual(fifth.statusCode, 429); assert.strictEqual(JSON.parse(fifth.body).code, 'rate_limited'); }); + + it('uploads newsletter images to durable public hosting, resolves known course banners, and auto-inserts the sponsor graphic with tracked CTA', async () => { + const prevDir = process.env.UPLOAD_DIR; + const prevProvider = process.env.DATAOPS_FILE_STORAGE_PROVIDER; + const prevBucket = process.env.TASK_FILES_BUCKET; + const dir = mkdtempSync(join(tmpdir(), 'nl-img-test-')); + process.env.UPLOAD_DIR = dir; + process.env.DATAOPS_FILE_STORAGE_PROVIDER = 'local-dev'; + delete process.env.TASK_FILES_BUCKET; + + try { + const card = await createCard(client, { + title: 'Weekly #303', + dueDate: '2026-11-16', + ...templateLink(), + stage: 'Planning', + }); + const createdIssue = await createNewsletterIssue( + client, + buildNewsletterIssue({ + kind: 'weekly', + publicationDate: '2026-11-16', + issueNumber: 303, + slots: { primary: { sponsor: 'Astronomer' }, secondary: {} }, + }), + ); + const issue = await updateNewsletterIssue(client, createdIssue, { cardId: card.id }); + + // 1. Upload valid PNG via POST /api/newsletter-issues/:id/images + const png = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ZkAAAAASUVORK5CYII=', + 'base64', + ); + const boundary = '----nl-img-boundary'; + const partHeader = Buffer.from( + `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="sponsor.png"\r\nContent-Type: image/png\r\n\r\n`, + 'binary', + ); + const partFooter = Buffer.from(`\r\n--${boundary}--\r\n`, 'binary'); + const multipartBody = Buffer.concat([partHeader, png, partFooter]).toString('base64'); + + const uploadRes = await handler( + { + httpMethod: 'POST', + path: `/api/newsletter-issues/${issue.id}/images`, + headers: { + authorization: `Bearer ${staffToken}`, + 'x-user-id': staffUserId, + host: 'ops.dtcdev.click', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + body: multipartBody, + isBase64Encoded: true, + }, + {} as never, + ); + assert.strictEqual(uploadRes.statusCode, 201, uploadRes.body); + const uploaded = JSON.parse(uploadRes.body); + assert.strictEqual(uploaded.issueId, issue.id); + assert.match(uploaded.image.url, /^https:\/\/ops\.dtcdev\.click\/api\/public-images\/[0-9a-f-]+\.png$/); + + // Invalid image rejected loudly + const badBody = Buffer.concat([ + Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="bad.txt"\r\n\r\n`, 'binary'), + Buffer.from('not an image', 'utf8'), + partFooter, + ]).toString('base64'); + const badRes = await handler( + { + httpMethod: 'POST', + path: `/api/newsletter-issues/${issue.id}/images`, + headers: { + authorization: `Bearer ${staffToken}`, + 'x-user-id': staffUserId, + host: 'ops.dtcdev.click', + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + body: badBody, + isBase64Encoded: true, + }, + {} as never, + ); + assert.strictEqual(badRes.statusCode, 400); + assert.strictEqual(JSON.parse(badRes.body).code, 'unsupported_image_format'); + + // 2. Link a newsletter-main sponsor doc with hosted graphicsUrl and tracked ctaUrl + const trackedCta = 'https://www.astronomer.io/events/webinars/test/?utm_source=data-talks-club'; + await createSponsorDoc(client, { + title: '2026-11-16 - Astronomer', + sponsorName: 'Astronomer', + template: 'newsletter-main', + cardId: card.id, + content: { + title: 'How to run AI pipelines', + graphicsUrl: uploaded.image.url, + ctaUrl: trackedCta, + ctaText: 'Register now', + }, + createdBy: staffUserId, + }); + + const issueMd = [ + 'SPONSORED', + '', + '## How to run AI pipelines', + '', + `[Register now](${trackedCta})`, + '', + '[Banner: Production AI Agents for Generative Media (not made yet; fallback heading: Course Start)]', + '', + '[Banner: AI Dev Tools Zoomcamp]', + '', + '[Banner: Machine Learning Zoomcamp]', + ].join('\n'); + + const putRes = await staffReq('PUT', `/api/newsletter-issues/${issue.id}/markdown`, { + issue: issueMd, + }); + assert.strictEqual(putRes.statusCode, 200, putRes.body); + const preview = JSON.parse(putRes.body); + const html: string = preview.rendered.issueHtml; + + // Sponsor graphic auto-inserted with tracked CTA on both image and button + assert.ok(html.includes(`')); + assert.ok(!linkedHtml.includes('newsletter-preview-cta__link')); + } finally { + if (prevDir === undefined) delete process.env.UPLOAD_DIR; else process.env.UPLOAD_DIR = prevDir; + if (prevProvider === undefined) delete process.env.DATAOPS_FILE_STORAGE_PROVIDER; else process.env.DATAOPS_FILE_STORAGE_PROVIDER = prevProvider; + if (prevBucket !== undefined) process.env.TASK_FILES_BUCKET = prevBucket; + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/cli/scripts/migrate-sponsor-doc-images.mjs b/cli/scripts/migrate-sponsor-doc-images.mjs new file mode 100644 index 00000000..707b7bd8 --- /dev/null +++ b/cli/scripts/migrate-sponsor-doc-images.mjs @@ -0,0 +1,111 @@ +#!/usr/bin/env node +/** + * Migrate sponsor-doc `data:image/...;base64,...` visual fields to durable + * hosted storage (`/api/public-images/...`) through the DataOps API. + * + * Usage: + * node cli/scripts/migrate-sponsor-doc-images.mjs [--apply] [--replace =] + */ +import { readFileSync } from "node:fs"; +import { basename } from "node:path"; +import { multipart } from "../src/commands/tasks.mjs"; +import { apiFor } from "../src/session.mjs"; +import { enc } from "../src/format.mjs"; + +const DATA_IMAGE_RE = /^data:image\/(?:png|jpeg|jpg|gif|webp);base64,([A-Za-z0-9+/=\s]+)$/i; + +function parseCliArgs(argv) { + let apply = false; + const replacements = new Map(); + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--apply") { + apply = true; + } else if (arg === "--replace") { + const pair = argv[++i] || ""; + const eq = pair.indexOf("="); + if (eq <= 0) throw new Error(`Invalid --replace value "${pair}". Expected =.`); + replacements.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim()); + } else if (arg === "--help" || arg === "-h") { + console.log("Usage: node cli/scripts/migrate-sponsor-doc-images.mjs [--apply] [--replace =]"); + process.exit(0); + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + return { apply, replacements }; +} + +async function main() { + const { apply, replacements } = parseCliArgs(process.argv.slice(2)); + const api = apiFor({}); + const listRes = await api.get("/api/sponsor-docs"); + const docs = Array.isArray(listRes?.docs) ? listRes.docs : []; + let migrated = 0; + let skipped = 0; + let failed = 0; + + for (const summary of docs) { + const detail = await api.get(`/api/sponsor-docs/${enc(summary.id)}`); + const doc = detail?.doc; + if (!doc || !doc.content) continue; + + const replacementPath = replacements.get(doc.id); + const graphicsUrl = typeof doc.content.graphicsUrl === "string" ? doc.content.graphicsUrl.trim() : ""; + const isDataUri = /^data:image\//i.test(graphicsUrl); + + if (!isDataUri && !replacementPath) continue; + + let bytes; + let filename = `${doc.id}.png`; + if (replacementPath) { + bytes = readFileSync(replacementPath); + filename = basename(replacementPath); + } else { + const m = DATA_IMAGE_RE.exec(graphicsUrl); + if (!m) { + console.error(`SKIP ${doc.id} (${doc.title}): non-base64 or unsupported data URI`); + failed++; + continue; + } + bytes = Buffer.from(m[1].replace(/\s+/g, ""), "base64"); + } + + if (!apply) { + console.log(`DRY-RUN ${doc.id} (${doc.title}): would upload ${bytes.length} bytes and update content.graphicsUrl`); + skipped++; + continue; + } + + try { + const { body, contentType } = multipart({}, { name: filename, bytes }); + const uploadRes = await api.post(`/api/sponsor-docs/${enc(doc.id)}/images`, undefined, { + rawBody: body, + contentType, + }); + const hostedUrl = uploadRes?.image?.url; + if (!hostedUrl) throw new Error("Upload did not return image.url"); + + await api.put(`/api/sponsor-docs/${enc(doc.id)}`, { + expectedVersion: doc.version, + content: { + ...doc.content, + graphicsUrl: hostedUrl, + }, + }); + console.log(`MIGRATED ${doc.id} (${doc.title}) -> ${hostedUrl}`); + migrated++; + } catch (err) { + console.error(`FAILED ${doc.id} (${doc.title}): ${err.message}`); + failed++; + } + } + + console.log(`Done. migrated=${migrated} dry_run=${skipped} failed=${failed}`); + if (failed > 0) process.exitCode = 1; +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/cli/skill/SKILL.md b/cli/skill/SKILL.md index 784e52a0..831e06f0 100644 --- a/cli/skill/SKILL.md +++ b/cli/skill/SKILL.md @@ -281,6 +281,7 @@ and prints the shareable HTML preview link (`/newsletter-issues//preview?cod Use `dataops newsletter markdown show [issue|sources|brief]` to read it, `dataops newsletter markdown preview ` to print the shareable link, and `dataops newsletter markdown rotate-code ` to rotate the preview access code. +Use `dataops newsletter image upload ` to upload a newsletter image (PNG, JPEG, GIF, or WebP) to durable public hosting and print its public `https://` URL for `issue.md` and the HTML preview. For a podcast episode: `dataops podcast list --status booked` (or `--season N`), `dataops podcast create --guests "Name[:slug]" --title "..." --recording-date YYYY-MM-DD`, diff --git a/cli/src/cli.mjs b/cli/src/cli.mjs index aa14a056..6c6d8611 100644 --- a/cli/src/cli.mjs +++ b/cli/src/cli.mjs @@ -299,6 +299,10 @@ export async function run(argv, io = console) { print(sponsorDocs.newsletterDocHelp); return EXIT.ok; } + if (commandName === "newsletter" && rest[0] === "image") { + print(newsletter.imageHelp); + return EXIT.ok; + } if (commandName === "webinar" && rest[0] === "doc") { print(sponsorDocs.webinarDocHelp); return EXIT.ok; diff --git a/cli/src/commands/newsletter.mjs b/cli/src/commands/newsletter.mjs index 3fce2a0b..4472d9e1 100644 --- a/cli/src/commands/newsletter.mjs +++ b/cli/src/commands/newsletter.mjs @@ -6,6 +6,7 @@ import { apiFor } from "../session.mjs"; import { planNewsletterSync, readNewsletterCsv, sheetIssues } from "../newsletterSheet.mjs"; import { rewriteVerificationUri } from "./auth.mjs"; import { domainDocCommand } from "./sponsorDocs.mjs"; +import { multipart } from "./tasks.mjs"; export const summary = "Plan newsletter issues, their sponsor slots, and their Cards"; @@ -63,6 +64,9 @@ Usage dataops newsletter markdown show [issue|sources|brief] dataops newsletter markdown preview dataops newsletter markdown rotate-code + dataops newsletter image upload + Upload an image (PNG, JPEG, GIF, or WebP) to durable + public hosting for the issue and print its https:// URL dataops newsletter metrics [primary|secondary] [--sent N] [--opens N] [--clicks N] [--unique-clicks N] [--open-rate PCT] [--click-rate PCT] @@ -840,6 +844,9 @@ Usage dataops newsletter markdown show [issue|sources|brief] dataops newsletter markdown preview dataops newsletter markdown rotate-code + dataops newsletter image upload + Upload an image (PNG, JPEG, GIF, or WebP) to durable + public hosting for the issue and print its https:// URL `; function normalizeMarkdownPayloadUrls(payload, baseUrl) { @@ -1008,12 +1015,82 @@ export async function markdown(args, io) { return fn({ ...args, _: rest }, io); } +export const newsletterImageHelp = `dataops newsletter image - upload durable public images for Newsletter Markdown + +Usage + dataops newsletter image upload + +Uploads a PNG, JPEG, GIF, or WebP image (up to 4 MiB) to durable public +hosting (/api/public-images) and prints its public https:// URL for use +in issue.md and the HTML preview. +`; + +export { newsletterImageHelp as imageHelp }; + +async function imageUpload(args, io) { + const usage = "dataops newsletter image upload "; + const id = positional(args, 0, usage); + const filePath = positional(args, 1, usage); + let bytes; + try { + bytes = readFileSync(filePath); + } catch (err) { + throw new UsageError(`Could not read image file "${filePath}": ${err.message}`); + } + if (!bytes || bytes.length === 0) { + throw new UsageError(`Image file "${filePath}" is empty.`); + } + const api = apiFor(args); + const { body, contentType } = multipart({}, { name: basename(filePath), bytes }); + const rawResult = await api.post(`/api/newsletter-issues/${enc(id)}/images`, undefined, { + rawBody: body, + contentType, + }); + const imageObj = rawResult?.image + ? { + ...rawResult.image, + url: rawResult.image.url ? rewriteVerificationUri(rawResult.image.url, api.base) : rawResult.image.url, + } + : rawResult?.image; + const result = { ...rawResult, image: imageObj }; + if (!args.json) { + if (!imageObj?.url) { + throw new UsageError("Upload succeeded but did not return a public image URL."); + } + io.print(imageObj.url); + } + return result; +} + +const imageSubcommands = { + upload: imageUpload, +}; + +export async function image(args, io) { + const [action, ...rest] = args._ || []; + if (!action || action === "help") { + if (!action) { + throw new UsageError("Missing image action. Usage: dataops newsletter image upload "); + } + io.print(newsletterImageHelp); + return undefined; + } + const fn = imageSubcommands[action]; + if (!fn) { + throw new UsageError( + `Unknown subcommand: dataops newsletter image ${action}. One of: ${Object.keys(imageSubcommands).join(", ")}.`, + ); + } + return fn({ ...args, _: rest }, io); +} + export const commands = { list, show, create, update, doc, + image, markdown, metrics, "pull-mailchimp": pullMailchimp, diff --git a/cli/test/newsletter-image.test.mjs b/cli/test/newsletter-image.test.mjs new file mode 100644 index 00000000..2a5230f0 --- /dev/null +++ b/cli/test/newsletter-image.test.mjs @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, test } from "node:test"; + +import { captureIo, fakePortal, isolateConfig } from "./helpers.mjs"; + +isolateConfig(); +const realFetch = globalThis.fetch; +const { run } = await import("../src/cli.mjs"); + +afterEach(() => { + globalThis.fetch = realFetch; +}); + +describe("newsletter image upload", () => { + test("uploads an image via POST /api/newsletter-issues/:id/images and prints the durable https URL", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "dataops-nl-img-")); + try { + const imgPath = path.join(dir, "sponsor.png"); + const pngBytes = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X2ZkAAAAASUVORK5CYII=", + "base64", + ); + writeFileSync(imgPath, pngBytes); + + const calls = fakePortal({ + "POST /api/newsletter-issues/issue-303/images": (req) => { + const ct = req.headers["content-type"] || req.headers["Content-Type"] || ""; + assert.match(ct, /^multipart\/form-data; boundary=/); + assert.ok(Buffer.from(req.body || "").includes(pngBytes)); + return { + status: 201, + body: { + issueId: "issue-303", + image: { + id: "11111111-1111-4111-8111-111111111111.png", + path: "/api/public-images/11111111-1111-4111-8111-111111111111.png", + contentType: "image/png", + sizeBytes: pngBytes.length, + url: "https://portal.test/api/public-images/11111111-1111-4111-8111-111111111111.png", + }, + }, + }; + }, + }); + + const io = captureIo(); + const code = await run(["newsletter", "image", "upload", "issue-303", imgPath], io); + assert.equal(code, 0, io.errors.join("\n")); + assert.equal(calls.length, 1); + assert.equal( + io.text().trim(), + "https://portal.test/api/public-images/11111111-1111-4111-8111-111111111111.png", + ); + + const jsonIo = captureIo(); + const jsonCode = await run(["newsletter", "image", "upload", "issue-303", imgPath, "--json"], jsonIo); + assert.equal(jsonCode, 0, jsonIo.errors.join("\n")); + const parsed = jsonIo.json(); + assert.equal(parsed.issueId, "issue-303"); + assert.equal( + parsed.image.url, + "https://portal.test/api/public-images/11111111-1111-4111-8111-111111111111.png", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + test("fails loudly when the file is missing or empty, and prints help for --help", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "dataops-nl-img-err-")); + try { + fakePortal({}); + const missingIo = captureIo(); + const missingCode = await run( + ["newsletter", "image", "upload", "issue-303", path.join(dir, "nope.png")], + missingIo, + ); + assert.notEqual(missingCode, 0); + assert.match(missingIo.errors.join("\n"), /Could not read image file/); + + const emptyPath = path.join(dir, "empty.png"); + writeFileSync(emptyPath, Buffer.alloc(0)); + const emptyIo = captureIo(); + const emptyCode = await run(["newsletter", "image", "upload", "issue-303", emptyPath], emptyIo); + assert.notEqual(emptyCode, 0); + assert.match(emptyIo.errors.join("\n"), /empty/i); + + const helpIo = captureIo(); + const helpCode = await run(["newsletter", "image", "--help"], helpIo); + assert.equal(helpCode, 0); + assert.match(helpIo.text(), /dataops newsletter image upload /); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +});