mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: add html file upload mode to html-to-image tool
This commit is contained in:
@@ -125,6 +125,56 @@ export async function capturePage(url: string, options: CaptureOptions): Promise
|
|||||||
}) as Promise<Buffer>;
|
}) as Promise<Buffer>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function captureHtml(html: string, options: CaptureOptions): Promise<Buffer> {
|
||||||
|
return queue.add(async () => {
|
||||||
|
let b: Browser;
|
||||||
|
try {
|
||||||
|
b = await getBrowser();
|
||||||
|
} catch (err) {
|
||||||
|
recordCrash();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const page = await b.newPage({
|
||||||
|
viewport: { width: options.viewportWidth, height: options.viewportHeight },
|
||||||
|
isMobile: options.isMobile,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setContent(html, { waitUntil: "load", timeout: PAGE_LOAD_TIMEOUT });
|
||||||
|
await page.waitForLoadState("networkidle", { timeout: NETWORK_IDLE_GRACE }).catch(() => {});
|
||||||
|
|
||||||
|
if (options.fullPage) {
|
||||||
|
const scrollHeight = await page.evaluate(() => document.body.scrollHeight);
|
||||||
|
const targetHeight = Math.min(scrollHeight, MAX_FULL_PAGE_HEIGHT);
|
||||||
|
await page.setViewportSize({
|
||||||
|
width: options.viewportWidth,
|
||||||
|
height: targetHeight,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = options.format === "jpg" ? "jpeg" : options.format;
|
||||||
|
const screenshotOpts: Parameters<typeof page.screenshot>[0] = {
|
||||||
|
type: type as "jpeg" | "png",
|
||||||
|
fullPage: false,
|
||||||
|
};
|
||||||
|
if (options.format !== "png") {
|
||||||
|
screenshotOpts.quality = options.quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await page.screenshot(screenshotOpts)) as Buffer;
|
||||||
|
} catch (err) {
|
||||||
|
if (!b.isConnected()) {
|
||||||
|
recordCrash();
|
||||||
|
browser = null;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}) as Promise<Buffer>;
|
||||||
|
}
|
||||||
|
|
||||||
export async function shutdownBrowser(): Promise<void> {
|
export async function shutdownBrowser(): Promise<void> {
|
||||||
if (browser?.isConnected()) {
|
if (browser?.isConnected()) {
|
||||||
await browser.close().catch(() => {});
|
await browser.close().catch(() => {});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { capturePage, isBrowserAvailable } from "../../lib/browser-service.js";
|
import { captureHtml, capturePage, isBrowserAvailable } from "../../lib/browser-service.js";
|
||||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||||
import { validateFetchUrl } from "../../lib/ssrf.js";
|
import { validateFetchUrl } from "../../lib/ssrf.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -14,15 +14,25 @@ const DEVICE_PRESETS = {
|
|||||||
mobile: { width: 375, height: 812, isMobile: true },
|
mobile: { width: 375, height: 812, isMobile: true },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z
|
||||||
url: z.string().url(),
|
.object({
|
||||||
format: z.enum(["jpg", "png", "webp"]).default("png"),
|
url: z.string().url().optional(),
|
||||||
quality: z.number().min(1).max(100).default(90),
|
html: z.string().min(1).max(5_000_000).optional(),
|
||||||
fullPage: z.boolean().default(false),
|
format: z.enum(["jpg", "png", "webp"]).default("png"),
|
||||||
devicePreset: z.enum(["desktop", "tablet", "mobile", "custom"]).default("desktop"),
|
quality: z.number().min(1).max(100).default(90),
|
||||||
viewportWidth: z.number().min(320).max(3840).default(1280),
|
fullPage: z.boolean().default(false),
|
||||||
viewportHeight: z.number().min(320).max(2160).default(720),
|
devicePreset: z.enum(["desktop", "tablet", "mobile", "custom"]).default("desktop"),
|
||||||
});
|
viewportWidth: z.number().min(320).max(3840).default(1280),
|
||||||
|
viewportHeight: z.number().min(320).max(2160).default(720),
|
||||||
|
})
|
||||||
|
.refine((data) => data.url || data.html, {
|
||||||
|
message: "Either url or html must be provided",
|
||||||
|
path: ["url"],
|
||||||
|
})
|
||||||
|
.refine((data) => !(data.url && data.html), {
|
||||||
|
message: "Provide either url or html, not both",
|
||||||
|
path: ["url"],
|
||||||
|
});
|
||||||
|
|
||||||
export function registerHtmlToImage(app: FastifyInstance) {
|
export function registerHtmlToImage(app: FastifyInstance) {
|
||||||
app.post(
|
app.post(
|
||||||
@@ -50,13 +60,15 @@ export function registerHtmlToImage(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (settings.url) {
|
||||||
await validateFetchUrl(settings.url);
|
try {
|
||||||
} catch (err) {
|
await validateFetchUrl(settings.url);
|
||||||
return reply.status(400).send({
|
} catch (err) {
|
||||||
error: "URL is not allowed",
|
return reply.status(400).send({
|
||||||
details: err instanceof Error ? err.message : "URL validation failed",
|
error: "URL is not allowed",
|
||||||
});
|
details: err instanceof Error ? err.message : "URL validation failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const preset =
|
const preset =
|
||||||
@@ -69,14 +81,18 @@ export function registerHtmlToImage(app: FastifyInstance) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const buffer = await capturePage(settings.url, {
|
const captureOpts = {
|
||||||
format: settings.format,
|
format: settings.format,
|
||||||
quality: settings.quality,
|
quality: settings.quality,
|
||||||
fullPage: settings.fullPage,
|
fullPage: settings.fullPage,
|
||||||
viewportWidth: preset.width,
|
viewportWidth: preset.width,
|
||||||
viewportHeight: preset.height,
|
viewportHeight: preset.height,
|
||||||
isMobile: preset.isMobile,
|
isMobile: preset.isMobile,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
const buffer = settings.html
|
||||||
|
? await captureHtml(settings.html, captureOpts)
|
||||||
|
: await capturePage(settings.url!, captureOpts);
|
||||||
|
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const workspacePath = await createWorkspace(jobId);
|
const workspacePath = await createWorkspace(jobId);
|
||||||
|
|||||||
@@ -18,17 +18,61 @@ export function HtmlToImageSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<div>
|
<div className="flex gap-1 rounded-lg bg-muted p-1">
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.url}</label>
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="url"
|
onClick={() => store.setMode("url")}
|
||||||
value={store.url}
|
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
onChange={(e) => store.setUrl(e.target.value)}
|
store.mode === "url"
|
||||||
placeholder={ts.urlPlaceholder}
|
? "bg-background shadow-sm"
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
/>
|
}`}
|
||||||
|
>
|
||||||
|
{ts.modeUrl}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => store.setMode("html")}
|
||||||
|
className={`flex-1 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||||
|
store.mode === "html"
|
||||||
|
? "bg-background shadow-sm"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ts.modeHtml}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{store.mode === "url" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.url}</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={store.url}
|
||||||
|
onChange={(e) => store.setUrl(e.target.value)}
|
||||||
|
placeholder={ts.urlPlaceholder}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{store.mode === "html" && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-sm font-medium">{ts.htmlFile}</label>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".html,.htm"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
file.text().then((text) => store.setHtmlContent(text));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm file:mr-3 file:rounded-md file:border-0 file:bg-primary file:px-3 file:py-1 file:text-sm file:font-medium file:text-primary-foreground"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="mb-1 block text-sm font-medium">{ts.format}</label>
|
<label className="mb-1 block text-sm font-medium">{ts.format}</label>
|
||||||
<select
|
<select
|
||||||
@@ -128,7 +172,7 @@ export function HtmlToImageSettings() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!store.url || store.capturing}
|
disabled={(store.mode === "url" ? !store.url : !store.htmlContent) || store.capturing}
|
||||||
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 py-2.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{store.capturing && <Loader2 className="h-4 w-4 animate-spin" />}
|
{store.capturing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import { create } from "zustand";
|
|||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
|
||||||
interface HtmlToImageState {
|
interface HtmlToImageState {
|
||||||
|
mode: "url" | "html";
|
||||||
url: string;
|
url: string;
|
||||||
|
htmlContent: string;
|
||||||
format: "jpg" | "png" | "webp";
|
format: "jpg" | "png" | "webp";
|
||||||
quality: number;
|
quality: number;
|
||||||
fullPage: boolean;
|
fullPage: boolean;
|
||||||
@@ -14,7 +16,9 @@ interface HtmlToImageState {
|
|||||||
resultSize: number | null;
|
resultSize: number | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
|
setMode: (mode: "url" | "html") => void;
|
||||||
setUrl: (url: string) => void;
|
setUrl: (url: string) => void;
|
||||||
|
setHtmlContent: (html: string) => void;
|
||||||
setFormat: (format: "jpg" | "png" | "webp") => void;
|
setFormat: (format: "jpg" | "png" | "webp") => void;
|
||||||
setQuality: (quality: number) => void;
|
setQuality: (quality: number) => void;
|
||||||
setFullPage: (fullPage: boolean) => void;
|
setFullPage: (fullPage: boolean) => void;
|
||||||
@@ -26,7 +30,9 @@ interface HtmlToImageState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
|
mode: "url" as const,
|
||||||
url: "",
|
url: "",
|
||||||
|
htmlContent: "",
|
||||||
format: "png" as const,
|
format: "png" as const,
|
||||||
quality: 90,
|
quality: 90,
|
||||||
fullPage: false,
|
fullPage: false,
|
||||||
@@ -42,7 +48,9 @@ const DEFAULTS = {
|
|||||||
export const useHtmlToImageStore = create<HtmlToImageState>((set, get) => ({
|
export const useHtmlToImageStore = create<HtmlToImageState>((set, get) => ({
|
||||||
...DEFAULTS,
|
...DEFAULTS,
|
||||||
|
|
||||||
|
setMode: (mode) => set({ mode, error: null }),
|
||||||
setUrl: (url) => set({ url, error: null }),
|
setUrl: (url) => set({ url, error: null }),
|
||||||
|
setHtmlContent: (htmlContent) => set({ htmlContent, error: null }),
|
||||||
setFormat: (format) => set({ format }),
|
setFormat: (format) => set({ format }),
|
||||||
setQuality: (quality) => set({ quality }),
|
setQuality: (quality) => set({ quality }),
|
||||||
setFullPage: (fullPage) => set({ fullPage }),
|
setFullPage: (fullPage) => set({ fullPage }),
|
||||||
@@ -52,7 +60,8 @@ export const useHtmlToImageStore = create<HtmlToImageState>((set, get) => ({
|
|||||||
|
|
||||||
capture: async () => {
|
capture: async () => {
|
||||||
const state = get();
|
const state = get();
|
||||||
if (!state.url || state.capturing) return;
|
const hasInput = state.mode === "url" ? state.url : state.htmlContent;
|
||||||
|
if (!hasInput || state.capturing) return;
|
||||||
|
|
||||||
set({ capturing: true, error: null, resultUrl: null, resultSize: null });
|
set({ capturing: true, error: null, resultUrl: null, resultSize: null });
|
||||||
|
|
||||||
@@ -61,7 +70,7 @@ export const useHtmlToImageStore = create<HtmlToImageState>((set, get) => ({
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
headers: formatHeaders({ "Content-Type": "application/json" }),
|
headers: formatHeaders({ "Content-Type": "application/json" }),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url: state.url,
|
...(state.mode === "url" ? { url: state.url } : { html: state.htmlContent }),
|
||||||
format: state.format,
|
format: state.format,
|
||||||
quality: state.quality,
|
quality: state.quality,
|
||||||
fullPage: state.fullPage,
|
fullPage: state.fullPage,
|
||||||
|
|||||||
@@ -1006,6 +1006,9 @@ export const ar: TranslationKeys = {
|
|||||||
download: "تحميل رمز QR",
|
download: "تحميل رمز QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1019,6 +1019,9 @@ export const de: TranslationKeys = {
|
|||||||
download: "QR-Code herunterladen",
|
download: "QR-Code herunterladen",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -964,6 +964,9 @@ export const en = {
|
|||||||
download: "Download QR Code",
|
download: "Download QR Code",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1001,6 +1001,9 @@ export const es: TranslationKeys = {
|
|||||||
download: "Descargar codigo QR",
|
download: "Descargar codigo QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1020,6 +1020,9 @@ export const fr: TranslationKeys = {
|
|||||||
download: "Telecharger le code QR",
|
download: "Telecharger le code QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1002,6 +1002,9 @@ export const hi: TranslationKeys = {
|
|||||||
download: "QR कोड डाउनलोड करें",
|
download: "QR कोड डाउनलोड करें",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1014,6 +1014,9 @@ export const id: TranslationKeys = {
|
|||||||
download: "Unduh Kode QR",
|
download: "Unduh Kode QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1013,6 +1013,9 @@ export const it: TranslationKeys = {
|
|||||||
download: "Scarica codice QR",
|
download: "Scarica codice QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -972,6 +972,9 @@ export const ja: TranslationKeys = {
|
|||||||
download: "QRコードをダウンロード",
|
download: "QRコードをダウンロード",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -957,6 +957,9 @@ export const ko: TranslationKeys = {
|
|||||||
download: "QR 코드 다운로드",
|
download: "QR 코드 다운로드",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1016,6 +1016,9 @@ export const nl: TranslationKeys = {
|
|||||||
download: "QR-code downloaden",
|
download: "QR-code downloaden",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1017,6 +1017,9 @@ export const pl: TranslationKeys = {
|
|||||||
download: "Pobierz kod QR",
|
download: "Pobierz kod QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1013,6 +1013,9 @@ export const ptBR: TranslationKeys = {
|
|||||||
download: "Baixar codigo QR",
|
download: "Baixar codigo QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1015,6 +1015,9 @@ export const ru: TranslationKeys = {
|
|||||||
download: "Скачать QR-код",
|
download: "Скачать QR-код",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1012,6 +1012,9 @@ export const sv: TranslationKeys = {
|
|||||||
download: "Ladda ner QR-kod",
|
download: "Ladda ner QR-kod",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -995,6 +995,9 @@ export const th: TranslationKeys = {
|
|||||||
download: "ดาวน์โหลดคิวอาร์โค้ด",
|
download: "ดาวน์โหลดคิวอาร์โค้ด",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1017,6 +1017,9 @@ export const tr: TranslationKeys = {
|
|||||||
download: "QR Kodunu İndir",
|
download: "QR Kodunu İndir",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1015,6 +1015,9 @@ export const uk: TranslationKeys = {
|
|||||||
download: "Завантажити QR-код",
|
download: "Завантажити QR-код",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -1014,6 +1014,9 @@ export const vi: TranslationKeys = {
|
|||||||
download: "Tải xuống mã QR",
|
download: "Tải xuống mã QR",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -949,6 +949,9 @@ export const zhCN: TranslationKeys = {
|
|||||||
download: "下载 QR 码",
|
download: "下载 QR 码",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -947,6 +947,9 @@ export const zhTW: TranslationKeys = {
|
|||||||
download: "下載QR碼",
|
download: "下載QR碼",
|
||||||
},
|
},
|
||||||
"html-to-image": {
|
"html-to-image": {
|
||||||
|
modeUrl: "URL",
|
||||||
|
modeHtml: "HTML File",
|
||||||
|
htmlFile: "HTML File",
|
||||||
url: "URL",
|
url: "URL",
|
||||||
urlPlaceholder: "https://snapotter.com",
|
urlPlaceholder: "https://snapotter.com",
|
||||||
format: "Output Format",
|
format: "Output Format",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ vi.mock("../../apps/api/src/lib/browser-service.js", () => {
|
|||||||
return {
|
return {
|
||||||
isBrowserAvailable: vi.fn().mockReturnValue(true),
|
isBrowserAvailable: vi.fn().mockReturnValue(true),
|
||||||
capturePage: vi.fn().mockResolvedValue(TEST_PNG),
|
capturePage: vi.fn().mockResolvedValue(TEST_PNG),
|
||||||
|
captureHtml: vi.fn().mockResolvedValue(TEST_PNG),
|
||||||
shutdownBrowser: vi.fn(),
|
shutdownBrowser: vi.fn(),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -324,4 +325,38 @@ describe("HTML to Image", () => {
|
|||||||
expect(JSON.parse(res.body).code).toBe("BROWSER_CRASHED");
|
expect(JSON.parse(res.body).code).toBe("BROWSER_CRASHED");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("html content mode", () => {
|
||||||
|
it("captures HTML content with default settings", async () => {
|
||||||
|
const res = await post({ html: "<html><body><h1>Hello</h1></body></html>" }, adminToken);
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const result = JSON.parse(res.body);
|
||||||
|
expect(result.jobId).toBeDefined();
|
||||||
|
expect(result.downloadUrl).toMatch(/\.png$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects empty html string", async () => {
|
||||||
|
const res = await post({ html: "" }, adminToken);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when both url and html are provided", async () => {
|
||||||
|
const res = await post({ url: "https://example.com", html: "<h1>test</h1>" }, adminToken);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects when neither url nor html is provided", async () => {
|
||||||
|
const res = await post({ format: "png" }, adminToken);
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not perform SSRF check for html mode", async () => {
|
||||||
|
const { validateFetchUrl } = await import("../../apps/api/src/lib/ssrf.js");
|
||||||
|
(validateFetchUrl as ReturnType<typeof vi.fn>).mockClear();
|
||||||
|
|
||||||
|
await post({ html: "<html><body>test</body></html>" }, adminToken);
|
||||||
|
|
||||||
|
expect(validateFetchUrl).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user