├── docs ├── .vitepress │ ├── .gitignore │ ├── theme │ │ ├── index.js │ │ └── custom.css │ └── config.ts ├── metrics │ └── index.md ├── getting_started │ └── index.md ├── index.md ├── usage │ └── index.md ├── encryption │ └── index.md └── config │ └── index.md ├── .mise.toml ├── .gitignore ├── tsconfig.build.json ├── .prettierignore ├── screenshots ├── jasonraimondi.com.png └── github.com_jasonraimondi_url-to-png.png ├── tests ├── helpers │ ├── assets │ │ └── test_img.png │ └── stubs.ts └── app.spec.ts ├── .prettierrc ├── src ├── lib │ ├── storage │ │ ├── _base.ts │ │ ├── stub.ts │ │ ├── couch-db.ts │ │ ├── s3.ts │ │ └── filesystem.ts │ ├── logger.ts │ ├── utils.ts │ ├── schema.ts │ ├── browser_pool.ts │ ├── image_render.ts │ └── factory.ts ├── mod.ts ├── middlewares │ ├── allow_list.ts │ ├── block_list.ts │ └── extract_query_params.ts ├── routes │ └── index.ts ├── main.ts └── app.ts ├── SECURITY.md ├── Dockerfile ├── tsconfig.json ├── index.html ├── LICENSE ├── .github └── workflows │ ├── publish_npm.yml │ └── ci.yml ├── example-encryption.js ├── package.json ├── .env.sample ├── docker-compose.yml └── README.md /docs/.vitepress/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | cache/ 3 | -------------------------------------------------------------------------------- /.mise.toml: -------------------------------------------------------------------------------- 1 | [tools] 2 | node = "20" 3 | pnpm = "9" 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | dist/ 3 | node_modules/ 4 | app.tgz 5 | .env 6 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["tests/**/*.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | docs/.vitepress/cache/ 2 | docs/.vitepress/dist/ 3 | node_modules/ 4 | pnpm-lock.yaml 5 | -------------------------------------------------------------------------------- /screenshots/jasonraimondi.com.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/url-to-png/HEAD/screenshots/jasonraimondi.com.png -------------------------------------------------------------------------------- /tests/helpers/assets/test_img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/url-to-png/HEAD/tests/helpers/assets/test_img.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | arrowParens: avoid 2 | bracketSpacing: true 3 | printWidth: 100 4 | trailingComma: all 5 | tabWidth: 2 6 | semi: true 7 | singleQuote: false 8 | -------------------------------------------------------------------------------- /screenshots/github.com_jasonraimondi_url-to-png.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasonraimondi/url-to-png/HEAD/screenshots/github.com_jasonraimondi_url-to-png.png -------------------------------------------------------------------------------- /docs/.vitepress/theme/index.js: -------------------------------------------------------------------------------- 1 | // .vitepress/theme/index.js 2 | import DefaultTheme from "vitepress/theme"; 3 | import "./custom.css"; 4 | 5 | export default DefaultTheme; 6 | -------------------------------------------------------------------------------- /src/lib/storage/_base.ts: -------------------------------------------------------------------------------- 1 | export interface ImageStorage { 2 | fetchImage(imageId: string): Promise; 3 | storeImage(imageId: string, image: Buffer): Promise; 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/logger.ts: -------------------------------------------------------------------------------- 1 | import { Logger, pino } from "pino"; 2 | 3 | let level: string = process.env.LOG_LEVEL ?? "info"; 4 | 5 | if (process.env.NODE_ENV === "test") { 6 | level = "warn"; 7 | } 8 | 9 | export const logger: Logger = pino({ name: "url-to-png", level }); 10 | -------------------------------------------------------------------------------- /docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | ul.table-of-contents { 2 | list-style-type: none; 3 | margin: 1rem 0 0 0.5rem; 4 | padding: 0; 5 | line-height: 1; 6 | } 7 | 8 | ul.table-of-contents ul { 9 | margin-left: 1rem; 10 | } 11 | 12 | .table-of-contents li { 13 | text-indent: -5px; 14 | } 15 | .table-of-contents li:before { 16 | content: "- "; 17 | text-indent: -5px; 18 | } 19 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Latest Version | Security Updates | 6 | | ------- | ------------------ | ------------------ | 7 | | 2.x | :white_check_mark: | :white_check_mark: | 8 | | 1.x | | | 9 | 10 | ## Reporting a Vulnerability 11 | 12 | If you discover any security related issues, please email security@raimondi.dev or use the issue tracker. 13 | -------------------------------------------------------------------------------- /src/lib/storage/stub.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../logger.js"; 2 | import { ImageStorage } from "./_base.js"; 3 | 4 | export class StubStorageProvider implements ImageStorage { 5 | async fetchImage(imageId: string): Promise { 6 | logger.debug(`Stub fetch image: ${imageId}`); 7 | return null; 8 | } 9 | 10 | async storeImage(imageId: string): Promise { 11 | logger.debug(`Stub store image: ${imageId}`); 12 | return true; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /docs/metrics/index.md: -------------------------------------------------------------------------------- 1 | # Metrics 2 | 3 | The metrics endpoint provides information about the browser pool, such as the spare resource capacity, pool size, available and borrowed instances, pending requests, and the maximum and minimum pool size. 4 | 5 | You can enable metrics by setting `METRICS=true`. This will expose a `/metrics` endpoint for Prometheus to scrape. 6 | 7 | ```json 8 | { 9 | "poolMetrics": { 10 | "spareResourceCapacity": 8, 11 | "size": 2, 12 | "available": 2, 13 | "borrowed": 0, 14 | "pending": 0, 15 | "max": 10, 16 | "min": 2 17 | } 18 | } 19 | ``` 20 | -------------------------------------------------------------------------------- /src/mod.ts: -------------------------------------------------------------------------------- 1 | export { ImageRenderService, ImageRenderInterface, WaitForOptions } from "./lib/image_render.js"; 2 | export { BrowserPool, BrowserPoolConstructorArgs } from "./lib/browser_pool.js"; 3 | export { PlainConfigSchema, IConfigAPI } from "./lib/schema.js"; 4 | 5 | export { ImageStorage } from "./lib/storage/_base.js"; 6 | export { CouchDbStorageProvider } from "./lib/storage/couch-db.js"; 7 | export { FileSystemStorageProvider } from "./lib/storage/filesystem.js"; 8 | export { AmazonS3StorageProvider } from "./lib/storage/s3.js"; 9 | export { StubStorageProvider } from "./lib/storage/stub.js"; 10 | -------------------------------------------------------------------------------- /src/middlewares/allow_list.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { HTTPException } from "hono/http-exception"; 3 | 4 | import { AppEnv } from "../app.js"; 5 | import { logger } from "../lib/logger.js"; 6 | 7 | export function handleAllowListMiddleware(allowList: string[]) { 8 | return async (c: Context, next: () => Promise) => { 9 | const input = c.get("input"); 10 | const newurl = new URL(input.url).host; 11 | logger.info(`URL new: ${newurl}`); 12 | const isValidDomain = allowList.includes(newurl); 13 | 14 | if (!isValidDomain) { 15 | logger.warn(`Blocked request to ${input.url} - not in allowlist`); 16 | throw new HTTPException(403, { message: "Access to this URL is forbidden" }); 17 | } 18 | 19 | await next(); 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /tests/helpers/stubs.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs/promises"; 2 | 3 | import { ImageRenderInterface } from "../../src/lib/image_render.js"; 4 | 5 | export class StubImageRenderService implements ImageRenderInterface { 6 | static readonly POOL_METRICS = { 7 | borrowed: 0, 8 | max: 1, 9 | min: 1, 10 | pending: 0, 11 | size: 1, 12 | spareResourceCapacity: 0, 13 | available: 1, 14 | }; 15 | 16 | async drainBrowserPool(): Promise {} 17 | 18 | async screenshot(): Promise { 19 | const { join } = await import("path"); 20 | const filePath = join(__dirname, "./assets/test_img.png"); 21 | return await fs.readFile(filePath); 22 | } 23 | 24 | poolMetrics(): Record { 25 | return StubImageRenderService.POOL_METRICS; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/middlewares/block_list.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { HTTPException } from "hono/http-exception"; 3 | 4 | import { AppEnv } from "../app.js"; 5 | import { logger } from "../lib/logger.js"; 6 | 7 | export function handleBlockListMiddleware(blockList: string[]) { 8 | return async (c: Context, next: () => Promise) => { 9 | const input = c.get("input"); 10 | const urlHost = new URL(input.url).host; 11 | logger.info(`Request URL host: ${urlHost}`); 12 | 13 | // Check if the host is in the block list 14 | if (blockList.includes(urlHost)) { 15 | logger.warn(`Blocked request to ${input.url} - host is in block list`); 16 | throw new HTTPException(403, { message: "Access to this URL is forbidden" }); 17 | } 18 | 19 | await next(); 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM mcr.microsoft.com/playwright:v1.40.0-jammy as baserepo 2 | ENV NODE_ENV='production' 3 | WORKDIR /app 4 | RUN npm install -g pnpm \ 5 | && chown -R pwuser:pwuser /app 6 | 7 | 8 | FROM baserepo as builder 9 | WORKDIR / 10 | USER pwuser 11 | WORKDIR /app 12 | COPY package.json pnpm-lock.yaml /app/ 13 | RUN pnpm install --production false 14 | COPY tsconfig.json tsconfig.build.json /app/ 15 | COPY src /app/src 16 | RUN pnpm build 17 | 18 | 19 | FROM baserepo 20 | ENV DOCKER=1 21 | USER pwuser 22 | COPY --from=builder --chown=pwuser:pwuser /app/package.json /app/pnpm-lock.yaml /app/ 23 | RUN pnpm install --production && pnpm exec playwright install chromium 24 | COPY --from=builder --chown=pwuser:pwuser /app/dist /app/dist 25 | EXPOSE 3000 26 | CMD ["node", "-r", "dotenv/config", "dist/main.js"] 27 | -------------------------------------------------------------------------------- /docs/getting_started/index.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Ways to Install 4 | 5 | ### Docker 6 | 7 | You can run URL-to-PNG using Docker with the following command: 8 | 9 | ```bash 10 | docker run --rm -p 3089:3089 ghcr.io/jasonraimondi/url-to-png 11 | ``` 12 | 13 | ### Local Development 14 | 15 | To run URL-to-PNG locally, follow these steps: 16 | 17 | 1. Clone the repository: 18 | 19 | ```bash 20 | git clone git@github.com:jasonraimondi/url-to-png.git 21 | cd url-to-png 22 | ``` 23 | 24 | 2. Install dependencies: 25 | 26 | ```bash 27 | pnpm install 28 | ``` 29 | 30 | 3. Install Playwright browsers (if needed): 31 | 32 | ```bash 33 | pnpm exec playwright install chromium 34 | ``` 35 | 36 | 4. Start the development server: 37 | ```bash 38 | pnpm dev 39 | ``` 40 | 41 | The Docker image is also available on [DockerHub](https://hub.docker.com/r/jasonraimondi/url-to-png/). 42 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | 5 | "target": "ESNext", 6 | "module": "NodeNext", 7 | "moduleResolution": "NodeNext", 8 | 9 | /* Advanced Options */ 10 | "experimentalDecorators": true, 11 | "emitDecoratorMetadata": true, 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true, 14 | 15 | /* Output Options */ 16 | "removeComments": true, 17 | "sourceMap": true, 18 | "types": [], 19 | 20 | /* STRICT */ 21 | "strict": true, 22 | "strictPropertyInitialization": true, 23 | "strictNullChecks": true, 24 | "strictFunctionTypes": true, 25 | "strictBindCallApply": true, 26 | "noFallthroughCasesInSwitch": true, 27 | "noImplicitAny": true, 28 | "noImplicitThis": true, 29 | "noUnusedLocals": true, 30 | "noUnusedParameters": true, 31 | "forceConsistentCasingInFileNames": true 32 | }, 33 | "include": ["src/**/*.ts", "tests/**/*.ts"] 34 | } 35 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export function formatUrlList(allowList: string): string[] { 2 | return allowList.split(",").map(url => { 3 | url = url.trim().replace(/https?:\/\//g, ""); 4 | return new URL(`http://${url}`).host; 5 | }); 6 | } 7 | 8 | export function configToString(configAPI: URLSearchParams) { 9 | let result = ""; 10 | for (const [key, value] of configAPI) { 11 | if (key === "url") continue; 12 | result += `_${key}-${value}`; 13 | } 14 | return result; 15 | } 16 | 17 | export function slugify(text: string) { 18 | return text 19 | .toString() 20 | .toLowerCase() 21 | .replace(/\s+/g, "-") // Replace spaces with - 22 | .replace(/[^\w\-]+/g, "") // Remove all non-word chars 23 | .replace(/\-\-+/g, "-") // Replace multiple - with single - 24 | .replace(/^-+/, "") // Trim - from start of text 25 | .replace(/-+$/, ""); // Trim - from end of text 26 | } 27 | 28 | const POSITIVE_NUMBER_REGEX = /^\d+$/; 29 | 30 | export const isValidNum = (value?: string) => value && POSITIVE_NUMBER_REGEX.test(value); 31 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 10 | 13 | 16 | 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2024 Jason Raimondi 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/lib/storage/couch-db.ts: -------------------------------------------------------------------------------- 1 | import md5 from "md5"; 2 | import couchDBNano from "nano"; 3 | 4 | import { ImageStorage } from "./_base.js"; 5 | 6 | export class CouchDbStorageProvider implements ImageStorage { 7 | constructor(private readonly couchDB: couchDBNano.ServerScope) {} 8 | 9 | get images() { 10 | return this.couchDB.use(process.env.COUCHDB_DATABASE ?? "images"); 11 | } 12 | 13 | public async fetchImage(imageId: string): Promise { 14 | const imageMd5 = md5(imageId); 15 | try { 16 | return await this.images.attachment.get(imageMd5, `${imageId}.png`); 17 | } catch (err) { 18 | return null; 19 | } 20 | } 21 | 22 | public async storeImage(imageId: string, image: Buffer): Promise { 23 | const images = this.images; 24 | const imageMd5 = md5(imageId); 25 | try { 26 | await images.attachment.get(imageMd5, `${imageId}.png`); 27 | return true; 28 | } catch (err) { 29 | await images.attachment.insert(imageMd5, `${imageId}.png`, image, "image/png"); 30 | } 31 | 32 | return true; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/publish_npm.yml: -------------------------------------------------------------------------------- 1 | name: Publish to NPM 2 | 3 | on: 4 | release: 5 | types: [released, prereleased] 6 | 7 | jobs: 8 | publish: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - uses: pnpm/action-setup@v2 13 | with: 14 | version: 9 15 | - uses: actions/setup-node@v3 16 | with: 17 | node-version: 20 18 | cache: pnpm 19 | cache-dependency-path: pnpm-lock.yaml 20 | registry-url: "https://registry.npmjs.org" 21 | - run: pnpm i --frozen-lockfile --production false 22 | - run: pnpm build 23 | - name: Check Release type and Publish 24 | run: | 25 | if ${{ github.event.release.prerelease }}; then 26 | echo "Publishing pre-release..." 27 | pnpm publish --verbose --access=public --no-git-checks --tag next 28 | else 29 | echo "Publishing release..." 30 | pnpm publish --verbose --access=public --no-git-checks 31 | fi 32 | env: 33 | NODE_AUTH_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 34 | -------------------------------------------------------------------------------- /example-encryption.js: -------------------------------------------------------------------------------- 1 | import crypto from "crypto"; 2 | import { StringEncrypter } from "@jmondi/string-encrypt-decrypt"; 3 | 4 | async function createKey() { 5 | const cryptoKey = await crypto.subtle.generateKey( 6 | { 7 | name: "AES-GCM", 8 | length: 256, 9 | }, 10 | true, 11 | ["encrypt", "decrypt"], 12 | ); 13 | 14 | const exportedKey = await crypto.subtle.exportKey("jwk", cryptoKey); 15 | 16 | return JSON.stringify(exportedKey); 17 | } 18 | 19 | async function createEncryptedString(cryptoString) { 20 | const stringEncrypter = await StringEncrypter.fromCryptoString(cryptoString); 21 | 22 | const encryptedString = await stringEncrypter.encrypt( 23 | JSON.stringify({ 24 | url: "https://jasonraimondi.com", 25 | isFullHeight: true, 26 | forceReload: true, 27 | }), 28 | ); 29 | 30 | return encryptedString; 31 | } 32 | 33 | async function main() { 34 | const CRYPTO_KEY = await createKey(); 35 | const hash = await createEncryptedString(CRYPTO_KEY); 36 | 37 | return { 38 | CRYPTO_KEY, 39 | exampleUrl: `http://localhost:3039/?hash=${hash}`, 40 | hash, 41 | }; 42 | } 43 | 44 | main().then(console.log); 45 | -------------------------------------------------------------------------------- /src/lib/storage/s3.ts: -------------------------------------------------------------------------------- 1 | import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; 2 | 3 | import { ImageStorage } from "./_base.js"; 4 | 5 | export class AmazonS3StorageProvider implements ImageStorage { 6 | constructor( 7 | private readonly s3: S3Client, 8 | private readonly BUCKET_NAME: string, 9 | ) {} 10 | 11 | public async fetchImage(imageId: string) { 12 | const params = new GetObjectCommand({ 13 | Bucket: this.BUCKET_NAME, 14 | Key: this.getImageId(imageId), 15 | }); 16 | try { 17 | const response = await this.s3.send(params); 18 | const body = response.Body; 19 | if (body instanceof Uint8Array) { 20 | return Buffer.from(body); 21 | } 22 | } catch (e) { 23 | // image not found, return null 24 | } 25 | return null; 26 | } 27 | 28 | public async storeImage(imageId: string, image: Buffer) { 29 | try { 30 | const data = new PutObjectCommand({ 31 | Body: image, 32 | Bucket: this.BUCKET_NAME, 33 | Key: this.getImageId(imageId), 34 | }); 35 | await this.s3.send(data); 36 | return true; 37 | } catch (err) { 38 | return false; 39 | } 40 | } 41 | 42 | private getImageId(imageId: string) { 43 | return imageId + ".png"; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import { Context } from "hono"; 2 | import { HTTPException } from "hono/http-exception"; 3 | 4 | import { AppEnv } from "../app.js"; 5 | import { ImageRenderInterface } from "../lib/image_render.js"; 6 | import { logger } from "../lib/logger.js"; 7 | 8 | import { ImageStorage } from "../lib/storage/_base.js"; 9 | 10 | export function getIndex( 11 | imageStorageService: ImageStorage, 12 | imageRenderService: ImageRenderInterface, 13 | ) { 14 | return async (c: Context) => { 15 | const { url, ...input } = c.get("input"); 16 | const imageId = c.get("imageId"); 17 | 18 | let imageBuffer: Buffer | null = await imageStorageService.fetchImage(imageId); 19 | 20 | if (imageBuffer === null || input.forceReload) { 21 | try { 22 | imageBuffer = await imageRenderService.screenshot(url, input); 23 | } catch (err: any) { 24 | throw new HTTPException(500, { message: err.message }); 25 | } 26 | 27 | try { 28 | await imageStorageService.storeImage(imageId, imageBuffer); 29 | } catch (err) { 30 | logger.error("Error storing image", err); 31 | } 32 | } 33 | 34 | if (imageBuffer === null) { 35 | throw new HTTPException(500, { message: "Error rendering image" }); 36 | } 37 | 38 | return c.body(imageBuffer, 200, { 39 | "Content-Type": "image/png", 40 | "Cache-Control": process.env.CACHE_CONTROL ?? "public, max-age=86400, immutable", 41 | }); 42 | }; 43 | } 44 | -------------------------------------------------------------------------------- /src/lib/schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | // https://github.com/colinhacks/zod/issues/2985#issuecomment-1905652037 4 | const zodStringBool = z 5 | .string() 6 | .toLowerCase() 7 | .transform(x => x === "true") 8 | .pipe(z.boolean()); 9 | 10 | const urlSchema = z 11 | .string() 12 | .url() 13 | .refine( 14 | val => { 15 | try { 16 | const url = new URL(val); 17 | return url.protocol === "http:" || url.protocol === "https:"; 18 | } catch (err) { 19 | return false; 20 | } 21 | }, 22 | { 23 | message: "must start with http or https", 24 | }, 25 | ); 26 | 27 | export const PlainConfigSchema = z.object({ 28 | url: urlSchema, 29 | width: z.coerce.number().nullish(), 30 | height: z.coerce.number().nullish(), 31 | viewportWidth: z.coerce.number().nullish(), 32 | viewportHeight: z.coerce.number().nullish(), 33 | viewPortWidth: z.coerce.number().nullish(), // deprecated to be removed in v3.x 34 | viewPortHeight: z.coerce.number().nullish(), // deprecated to be removed in v3.x 35 | forceReload: zodStringBool.nullish(), 36 | isMobile: zodStringBool.nullish(), 37 | isFullPage: zodStringBool.nullish(), 38 | isDarkMode: zodStringBool.nullish(), 39 | deviceScaleFactor: z.coerce.number().nullish(), 40 | }); 41 | export type PlainConfigSchema = z.infer; 42 | export const HashSchema = z.string().startsWith("str-enc:"); 43 | 44 | export type IConfigAPI = Omit; 45 | -------------------------------------------------------------------------------- /src/lib/storage/filesystem.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs/promises"; 2 | import * as path from "path"; 3 | import { logger } from "../logger.js"; 4 | 5 | import { ImageStorage } from "./_base.js"; 6 | 7 | export class FileSystemStorageProvider implements ImageStorage { 8 | constructor(private readonly storagePath: string) { 9 | this.createStorageDirectory().then(); 10 | } 11 | 12 | async fetchImage(imageId: string): Promise { 13 | const imagePath = this.imagePath(imageId); 14 | try { 15 | return await fs.readFile(imagePath); 16 | } catch (error) { 17 | logger.error(`Error fetching image: ${imagePath}:`, error); 18 | return null; 19 | } 20 | } 21 | 22 | async storeImage(imageId: string, image: Buffer): Promise { 23 | const imagePath = this.imagePath(imageId); 24 | try { 25 | await fs.writeFile(imagePath, image); 26 | return true; 27 | } catch (error) { 28 | logger.error(`Error storing image ${imagePath}:`, error); 29 | return false; 30 | } 31 | } 32 | 33 | private imagePath(imageId: string) { 34 | return path.join(this.storagePath, imageId) + ".png"; 35 | } 36 | 37 | private async createStorageDirectory(): Promise { 38 | try { 39 | await fs.access(this.storagePath); 40 | logger.info(`storage directory FOUND: ${this.storagePath}:`); 41 | } catch { 42 | await fs.mkdir(this.storagePath, { recursive: true }); 43 | logger.info(`storage directory NOT FOUND`); 44 | logger.info(`creating directory: ${this.storagePath}:`); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /docs/.vitepress/config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitepress"; 2 | 3 | // https://vitepress.vuejs.org/config/app-configs 4 | export default defineConfig({ 5 | lang: "en-US", 6 | title: "URL-to-PNG", 7 | base: "/url-to-png/", 8 | description: 9 | "Selfhosted. URL-to-PNG utility featuring parallel rendering using Playwright for screenshots and with storage caching via Local, S3, or CouchDB.", 10 | head: [ 11 | [ 12 | "script", 13 | { 14 | "data-domain": "jasonraimondi.github.io/url-to-png", 15 | src: "https://plausible.io/js/script.js", 16 | defer: "true", 17 | }, 18 | ], 19 | ], 20 | themeConfig: { 21 | repo: "jasonraimondi/url-to-png", 22 | docsDir: "docs", 23 | search: { 24 | provider: "local", 25 | }, 26 | editLink: { 27 | pattern: "https://github.com/jasonraimondi/url-to-png/edit/main/docs/:path", 28 | text: "Edit this page on GitHub", 29 | }, 30 | nav: [ 31 | { text: "Github", link: "https://github.com/jasonraimondi/url-to-png" }, 32 | { text: "Getting Started", link: "/getting_started/" }, 33 | ], 34 | sidebar: [ 35 | { 36 | items: [ 37 | { text: "/", link: "/" }, 38 | { text: "Getting Started", link: "/getting_started/" }, 39 | { text: "Usage", link: "/usage/" }, 40 | { text: "Configuration", link: "/config/" }, 41 | { text: "Encryption", link: "/encryption/" }, 42 | { text: "Metrics", link: "/metrics/" }, 43 | ], 44 | }, 45 | ], 46 | footer: { 47 | message: "Released under the MIT License.", 48 | copyright: "Copyright © 2024 Jason Raimondi", 49 | }, 50 | }, 51 | }); 52 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@jmondi/url-to-png", 3 | "version": "2.1.2", 4 | "description": "Start with a url, end up with a png. The Module.", 5 | "type": "module", 6 | "main": "dist/mod.js", 7 | "scripts": { 8 | "clean": "rm -rf dist/*", 9 | "serve": "node -r dotenv/config dist/main.js", 10 | "dev": "tsx --watch src/main.ts | pino-pretty", 11 | "compile": "tsc --project tsconfig.build.json", 12 | "build": "run-s clean compile", 13 | "format": "prettier --write .", 14 | "test": "vitest run", 15 | "test:watch": "vitest", 16 | "test:coverage": "vitest run --coverage", 17 | "docs:dev": "vitepress dev docs", 18 | "docs:build": "vitepress build docs", 19 | "docs:serve": "vitepress serve docs" 20 | }, 21 | "engines": { 22 | "node": ">= 20", 23 | "pnpm": ">= 9.0.0" 24 | }, 25 | "files": [ 26 | "dist", 27 | "src" 28 | ], 29 | "engineStrict": true, 30 | "dependencies": { 31 | "@aws-sdk/client-s3": "^3.583.0", 32 | "@hono/node-server": "^1.11.1", 33 | "@jmondi/string-encrypt-decrypt": "^0.0.6", 34 | "dotenv": "^16.4.5", 35 | "generic-pool": "^3.9.0", 36 | "hono": "^4.3.10", 37 | "md5": "^2.3.0", 38 | "nano": "^10.1.3", 39 | "pino": "^9.1.0", 40 | "playwright": "^1.44.1", 41 | "sharp": "^0.33.4", 42 | "zod": "^3.23.8", 43 | "zod-ff": "^1.4.0" 44 | }, 45 | "devDependencies": { 46 | "@types/md5": "^2.3.5", 47 | "@types/node": "^20.12.12", 48 | "@vitest/coverage-v8": "^1.6.0", 49 | "cross-env": "^7.0.3", 50 | "npm-run-all": "^4.1.5", 51 | "pino-pretty": "^11.1.0", 52 | "prettier": "^3.2.5", 53 | "tsx": "^4.11.0", 54 | "typescript": "^5.4.5", 55 | "vitepress": "1.2.2", 56 | "vitest": "^1.6.0", 57 | "vue": "3.4.27" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | 3 | import { serve } from "@hono/node-server"; 4 | import { StringEncrypter } from "@jmondi/string-encrypt-decrypt"; 5 | 6 | import { createApplication } from "./app.js"; 7 | import { BrowserPool } from "./lib/browser_pool.js"; 8 | import { 9 | createBrowserPool, 10 | createImageRenderService, 11 | createImageStorageService, 12 | } from "./lib/factory.js"; 13 | import { logger } from "./lib/logger.js"; 14 | 15 | let server: ReturnType; 16 | 17 | async function main() { 18 | const encryptionService = process.env.CRYPTO_KEY 19 | ? await StringEncrypter.fromCryptoString(process.env.CRYPTO_KEY) 20 | : undefined; 21 | 22 | const imageStorageService = createImageStorageService(); 23 | 24 | const browserPool: BrowserPool = createBrowserPool(); 25 | 26 | const imageRenderService = createImageRenderService(browserPool); 27 | 28 | const app = createApplication( 29 | browserPool, 30 | imageRenderService, 31 | imageStorageService, 32 | encryptionService, 33 | ); 34 | 35 | const port = Number(process.env.PORT) || 3089; 36 | server = serve({ fetch: app.fetch, port }); 37 | 38 | process.on("SIGINT", async () => { 39 | logger.info("Playwright Shutdown [STARTING]"); 40 | logger.info("Playwright Shutdown [DONE]"); 41 | logger.info("Server Shutdown [STARTING]"); 42 | server?.close(); 43 | await browserPool.drain(); 44 | logger.info("Server Shutdown [DONE]"); 45 | logger.info("EXITING..."); 46 | process.exit(0); 47 | }); 48 | 49 | logger.info(`Server is running on port http://localhost:${port}/ping`); 50 | if (process.env.NODE_ENV === "development") { 51 | logger.info(`http://localhost:${port}/?url=https://jasonraimondi.com/resume&isFullPage=true`); 52 | } 53 | } 54 | 55 | main() 56 | .then() 57 | .catch(err => { 58 | logger.error(err); 59 | }); 60 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # URL to PNG 2 | 3 | [![GitHub License](https://img.shields.io/github/license/jasonraimondi/url-to-png)](https://github.com/jasonraimondi/url-to-png/blob/main/LICENSE) 4 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/jasonraimondi/url-to-png/ci.yml?branch=main&style=flat-square)](https://github.com/jasonraimondi/url-to-png) 5 | [![Docker Pulls](https://img.shields.io/docker/pulls/jasonraimondi/url-to-png)](https://hub.docker.com/r/jasonraimondi/url-to-png/tags) 6 | 7 | A URL to PNG generator over HTTP with a fairly simple API accessed via query params passed to the server. 8 | 9 | - Generate PNG images from URLs 10 | - Customizable image dimensions and viewport size 11 | - Support for mobile user agent and dark mode rendering 12 | - Caching of generated images 13 | - Allow list for domain-specific requests 14 | - Configurable Playwright options 15 | - Integration with various storage providers (AWS S3, CouchDB, Filesystem) 16 | - Prometheus metrics endpoint 17 | 18 | ## Getting Started 19 | 20 | Checkout [the docs to getting_started](https://jasonraimondi.github.io/url-to-png/getting_started/) 21 | 22 | ### Docker 23 | 24 | Run the following command: 25 | 26 | ``` 27 | docker run --rm -p 3089:3089 ghcr.io/jasonraimondi/url-to-png 28 | ``` 29 | 30 | On the hub: [Link to DockerHub](https://hub.docker.com/r/jasonraimondi/url-to-png/) 31 | 32 | ### Local Serve 33 | 34 | Serve the project 35 | 36 | ``` 37 | git clone https://github.com/jasonraimondi/url-to-png 38 | cd url-to-png 39 | pnpm install 40 | pnpm exec playwright install chromium 41 | pnpm dev 42 | ``` 43 | 44 | ## Configuration 45 | 46 | Read the [full config options](https://jasonraimondi.github.io/url-to-png/config/) 47 | 48 | ## Encryption 49 | 50 | Learn about [encryption](https://jasonraimondi.github.io/url-to-png/encryption/) 51 | 52 | ## Metrics 53 | 54 | Learn about [metrics](https://jasonraimondi.github.io/url-to-png/metrics/) 55 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # Logging level (debug, info, warn, error) 2 | LOG_LEVEL=debug 3 | 4 | # Node environment (development, production) 5 | NODE_ENV=development 6 | 7 | # Port number for the application to listen on (optional) 8 | #PORT=3089 9 | 10 | # Comma-separated list of allowed domains for screenshots (optional) 11 | #ALLOW_LIST=jasonraimondi.com,github.com 12 | 13 | # Comma-separated list of restricted domains for screenshots (optional) 14 | # BLOCK_LIST=example.com 15 | 16 | # Cache-Control header value for the responses (optional) 17 | #CACHE_CONTROL="public, max-age=86400, immutable" 18 | 19 | # Browser timeout in milliseconds for rendering (optional) 20 | #BROWSER_TIMEOUT=10000 21 | 22 | # Event to wait for before considering the page loaded (optional) 23 | #BROWSER_WAIT_UNTIL=domcontentloaded 24 | 25 | # Idle timeout for database connection pool in milliseconds (optional) 26 | #POOL_IDLE_TIMEOUT_MS=15000 27 | 28 | # Maximum number of connections in the database pool (optional) 29 | #POOL_MAX=10 30 | 31 | # Maximum number of waiting clients for the database pool (optional) 32 | #POOL_MAX_WAITING_CLIENTS=50 33 | 34 | # Minimum number of connections in the database pool (optional) 35 | #POOL_MIN=2 36 | 37 | # Enable or disable metrics collection (optional) 38 | #METRICS=false 39 | 40 | # Encryption key for sensitive data (optional) 41 | #CRYPTO_KEY= 42 | 43 | # Storage provider (stub, s3, couchdb, filesystem) 44 | STORAGE_PROVIDER=stub 45 | 46 | # Configuration for filesystem storage provider (optional) 47 | #STORAGE_PROVIDER=filesystem 48 | #IMAGE_STORAGE_PATH= 49 | 50 | # Configuration for S3 storage provider (optional) 51 | #STORAGE_PROVIDER=s3 52 | #AWS_BUCKET= 53 | #AWS_ACCESS_KEY_ID= 54 | #AWS_SECRET_ACCESS_KEY= 55 | #AWS_DEFAULT_REGION=us-east-1 56 | #AWS_ENDPOINT_URL_S3= 57 | #AWS_FORCE_PATH_STYLE=false 58 | 59 | # Configuration for CouchDB storage provider (optional) 60 | #STORAGE_PROVIDER=couchdb 61 | #COUCH_DB_HOST= 62 | #COUCH_DB_PASS= 63 | #COUCH_DB_PORT= 64 | #COUCH_DB_PROTOCOL= 65 | #COUCH_DB_USER= 66 | #COUCHDB_DATABASE=images 67 | -------------------------------------------------------------------------------- /docs/usage/index.md: -------------------------------------------------------------------------------- 1 | # Usage 2 | 3 | URL-to-PNG provides a single endpoint that accepts various query parameters to customize the generated image. 4 | 5 | ## Query Parameters 6 | 7 | - `url` (required): The valid URL to be captured. 8 | - `width` (optional): The width of the output screenshot. Default is `250`. 9 | - `height` (optional): The height of the output screenshot. Default is `250`. 10 | - `viewportWidth` (optional): The width of the render viewport. Default is `1080`. 11 | - `viewportHeight` (optional): The height of the render viewport. Default is `1080`. 12 | - `forceReload` (optional): Forces a reload of the cached image. Default is `false`. 13 | - `isMobile` (optional): Adds a mobile flag to the user agent. Default is `false`. 14 | - `isFullPage` (optional): Renders the full page instead of the viewport crop. Default is `false`. 15 | - `isDarkMode` (optional): Prefers the dark color scheme. Default is `false`. 16 | - `deviceScaleFactor` (optional): Specifies the device scale factor (can be thought of as DPR). Default is `1`. 17 | 18 | ## Examples 19 | 20 | Here are some example combinations of query parameters: 21 | 22 | ``` 23 | http://localhost:3089?url=https://jasonraimondi.com 24 | http://localhost:3089?url=https://jasonraimondi.com&forceReload=true 25 | http://localhost:3089?url=https://jasonraimondi.com&isFullPage=true 26 | http://localhost:3089?url=https://jasonraimondi.com&isMobile=true 27 | http://localhost:3089?url=https://jasonraimondi.com&isDarkMode=true 28 | http://localhost:3089?url=https://jasonraimondi.com&width=400&height=400 29 | http://localhost:3089?url=https://jasonraimondi.com&viewportHeight=400&viewportWidth=400 30 | http://localhost:3089?url=https://jasonraimondi.com&isFullPage=true&isMobile=true&width=400&height=400&viewportHeight=400&viewportWidth=400 31 | http://localhost:3089?url=https://jasonraimondi.com&isMobile=true&isFullPage=true&viewportWidth=375&width=375&deviceScaleFactor=1 32 | ``` 33 | 34 | Use in your HTML 35 | 36 | ```html 37 | Jason Raimondi's personal home page screenshot 41 | ``` 42 | -------------------------------------------------------------------------------- /src/lib/browser_pool.ts: -------------------------------------------------------------------------------- 1 | import * as genericPool from "generic-pool"; 2 | import { Factory, Options } from "generic-pool"; 3 | import { Browser, chromium } from "playwright"; 4 | import { logger } from "./logger.js"; 5 | 6 | export type BrowserPoolConstructorArgs = { 7 | poolOpts: Options; 8 | browserOpts?: never; 9 | }; 10 | 11 | export type PoolMetrics = { 12 | spareResourceCapacity: number; 13 | size: number; 14 | available: number; 15 | borrowed: number; 16 | pending: number; 17 | max: number; 18 | min: number; 19 | }; 20 | 21 | export class BrowserPool { 22 | private pool: genericPool.Pool; 23 | 24 | private factory: Factory = { 25 | async create(): Promise { 26 | try { 27 | return await chromium.launch({ 28 | args: ["--no-sandbox", "--disable-setuid-sandbox"], 29 | }); 30 | } catch (e: unknown) { 31 | if (e instanceof Error) { 32 | if (e.message.includes("exec playwright install")) { 33 | logger.fatal(e.message); 34 | process.exit(1); 35 | } else { 36 | logger.error(e.message); 37 | } 38 | } 39 | throw e; 40 | } 41 | }, 42 | async destroy(browser: Browser) { 43 | await browser.close(); 44 | }, 45 | }; 46 | 47 | constructor({ poolOpts }: BrowserPoolConstructorArgs = {} as BrowserPoolConstructorArgs) { 48 | // I need to pull options out of here 49 | poolOpts = { 50 | max: Number(process.env.POOL_MAX) || 10, 51 | min: Number(process.env.POOL_MIN) || 2, 52 | maxWaitingClients: Number(process.env.POOL_MAX_WAITING_CLIENTS) || 50, 53 | idleTimeoutMillis: Number(process.env.POOL_IDLE_TIMEOUT_MS) || 15000, 54 | ...poolOpts, 55 | }; 56 | logger.info(poolOpts); 57 | this.pool = genericPool.createPool(this.factory, poolOpts); 58 | } 59 | 60 | get poolMetrics(): PoolMetrics { 61 | return { 62 | spareResourceCapacity: this.pool.spareResourceCapacity, 63 | size: this.pool.size, 64 | available: this.pool.available, 65 | borrowed: this.pool.borrowed, 66 | pending: this.pool.pending, 67 | max: this.pool.max, 68 | min: this.pool.min, 69 | }; 70 | } 71 | 72 | async acquire(): Promise { 73 | return await this.pool.acquire(); 74 | } 75 | 76 | async release(browser: Browser): Promise { 77 | await this.pool.release(browser); 78 | } 79 | 80 | async drain() { 81 | await this.pool.drain(); 82 | await this.pool.clear(); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - next 8 | tags: 9 | - "*" 10 | 11 | jobs: 12 | test: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: pnpm/action-setup@v2 17 | with: 18 | version: 9 19 | - uses: actions/setup-node@v3 20 | with: 21 | node-version: 20 22 | cache: pnpm 23 | cache-dependency-path: pnpm-lock.yaml 24 | - run: pnpm install --frozen-lockfile 25 | - run: pnpm test 26 | 27 | publish-docs: 28 | needs: test 29 | if: github.ref == 'refs/heads/main' 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: pnpm/action-setup@v2 34 | with: 35 | version: 9 36 | - uses: actions/setup-node@v3 37 | with: 38 | node-version: 20 39 | cache: pnpm 40 | cache-dependency-path: pnpm-lock.yaml 41 | - run: pnpm install --frozen-lockfile 42 | - run: pnpm docs:build 43 | - uses: peaceiris/actions-gh-pages@v3 44 | with: 45 | github_token: ${{ secrets.GITHUB_TOKEN }} 46 | publish_dir: docs/.vitepress/dist 47 | 48 | publish-docker: 49 | needs: test 50 | runs-on: ubuntu-latest 51 | steps: 52 | - uses: actions/checkout@v4 53 | - uses: docker/setup-qemu-action@v3 54 | - uses: docker/setup-buildx-action@v3 55 | - uses: docker/login-action@v1 56 | with: 57 | registry: ghcr.io 58 | username: ${{ github.repository_owner }} 59 | password: ${{ secrets.GITHUB_TOKEN }} 60 | 61 | - uses: docker/login-action@v1 62 | with: 63 | registry: docker.io 64 | username: ${{ secrets.DOCKERHUB_USER }} 65 | password: ${{ secrets.DOCKERHUB_TOKEN }} 66 | 67 | - name: Get tag version 68 | id: prep 69 | run: | 70 | if [[ $GITHUB_REF == refs/heads/main ]]; then 71 | echo "version=latest" >> $GITHUB_OUTPUT 72 | elif [[ $GITHUB_REF == refs/heads/next ]]; then 73 | echo "version=nightly" >> $GITHUB_OUTPUT 74 | else 75 | echo "version=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 76 | fi 77 | - uses: docker/build-push-action@v5 78 | with: 79 | context: . 80 | file: Dockerfile 81 | platforms: linux/amd64,linux/arm64 82 | push: true 83 | tags: | 84 | ghcr.io/${{ github.repository_owner }}/url-to-png:${{ steps.prep.outputs.version }} 85 | ${{ secrets.DOCKERHUB_USER }}/url-to-png:${{ steps.prep.outputs.version }} 86 | -------------------------------------------------------------------------------- /docs/encryption/index.md: -------------------------------------------------------------------------------- 1 | ## Generating an Encryption Key 2 | 3 | To enable encryption, first generate a CRYPTO_KEY. You can do this in your browser console using the following JavaScript code: 4 | 5 | ```js 6 | async function createKey() { 7 | const cryptoKey = await window.crypto.subtle.generateKey( 8 | { 9 | name: "AES-GCM", 10 | length: 256, 11 | }, 12 | true, 13 | ["encrypt", "decrypt"], 14 | ); 15 | 16 | const exportedKey = await window.crypto.subtle.exportKey("jwk", cryptoKey); 17 | 18 | return JSON.stringify(exportedKey); 19 | } 20 | 21 | createKey().then(console.log); 22 | 23 | // {"alg":"A256GCM","ext":true,"k":"3A....... 24 | ``` 25 | 26 | ## Setting the Encryption Key 27 | 28 | Set the generated key as your server `CRYPTO_KEY`. This key will be used for encryption and decryption. Wrap the `CRYPTO_KEY` environment variable in single quotes `'{}'`. 29 | 30 | ```env 31 | CRYPTO_KEY='{"alg":"A256GCM","ext":true,"k":"3A.......' 32 | ``` 33 | 34 | ## Encrypted Requests 35 | 36 | When the `CRYPTO_KEY` is set, the server will only receive encrypted requests. An encrypted request can be created using the following code: 37 | 38 | ```js 39 | import { StringEncrypter } from "@jmondi/string-encrypt-decrypt"; 40 | 41 | async function createEncryptedString() { 42 | const stringEncrypter = await StringEncrypter.fromCryptoString(process.env.CRYPTO_STRING); 43 | 44 | const encryptedString = await stringEncrypter.encrypt( 45 | JSON.stringify({ 46 | url: "https://jasonraimondi.com", 47 | isFullHeight: true, 48 | forceReload: true, 49 | }), 50 | ); 51 | 52 | return encryptedString; 53 | } 54 | 55 | createEncryptedString().then(console.log); 56 | ``` 57 | 58 | ## Example 59 | 60 | An example of using encryption can be found in the [example-encryption.js](https://github.com/jasonraimondi/url-to-png/blob/main/example-encryption.js) file. 61 | 62 | Run the example using the following command: 63 | 64 | ```bash 65 | node example-encryption.js 66 | 67 | // { 68 | // CRYPTO_KEY: '{"key_ops":["encrypt","decrypt"],"ext":true,"kty":"oct","k":"M8IkabUR_Nhj3B64AXWB2msQsvCj535krL6gR6Z0LEI","alg":"A256GCM"}', 69 | // exampleUrl: 'http://localhost:3039/?hash=str-enc:DbRfvBa61jg4FU4G75xkvVKS8g/MG2FfKfTTNwVYH4F+DpRBgDFyLyow0yOHgocpSFeFfdgJaet/JwJM+KtmuAcYbSZRJ1ENGNmcyhwZiWfhLdOAyhUlLlu8:sCzLlpiBye9ISIG3', 70 | // hash: 'str-enc:DbRfvBa61jg4FU4G75xkvVKS8g/MG2FfKfTTNwVYH4F+DpRBgDFyLyow0yOHgocpSFeFfdgJaet/JwJM+KtmuAcYbSZRJ1ENGNmcyhwZiWfhLdOAyhUlLlu8:sCzLlpiBye9ISIG3' 71 | // } 72 | ``` 73 | 74 | This formatted version separates the README into logical sections, making it easier to understand and follow the instructions for generating an encryption key, setting it up, and making encrypted requests. 75 | -------------------------------------------------------------------------------- /src/app.ts: -------------------------------------------------------------------------------- 1 | import { StringEncrypter } from "@jmondi/string-encrypt-decrypt"; 2 | import { Hono } from "hono"; 3 | import { secureHeaders } from "hono/secure-headers"; 4 | import { StatusCode } from "hono/utils/http-status"; 5 | 6 | import { BrowserPool } from "./lib/browser_pool.js"; 7 | import { ImageRenderInterface } from "./lib/image_render.js"; 8 | import { logger } from "./lib/logger.js"; 9 | import { PlainConfigSchema } from "./lib/schema.js"; 10 | import { ImageStorage } from "./lib/storage/_base.js"; 11 | import { formatUrlList } from "./lib/utils.js"; 12 | import { handleAllowListMiddleware } from "./middlewares/allow_list.js"; 13 | import { handleBlockListMiddleware } from "./middlewares/block_list.js"; 14 | import { handleExtractQueryParamsMiddleware } from "./middlewares/extract_query_params.js"; 15 | import { getIndex } from "./routes/index.js"; 16 | 17 | export type Variables = { 18 | input: PlainConfigSchema; 19 | imageId: string; 20 | }; 21 | export type AppEnv = { Variables: Variables }; 22 | 23 | export function createApplication( 24 | browserPool: BrowserPool, 25 | imageRenderService: ImageRenderInterface, 26 | imageStorageService: ImageStorage, 27 | stringEncrypter?: StringEncrypter, 28 | ) { 29 | const app = new Hono(); 30 | 31 | app.use( 32 | secureHeaders({ 33 | crossOriginResourcePolicy: "cross-origin", 34 | }), 35 | ); 36 | 37 | if (process.env.METRICS === "true") { 38 | app.get("/metrics", c => 39 | c.json( 40 | process.env.METRICS === "true" 41 | ? { 42 | poolMetrics: browserPool.poolMetrics, 43 | } 44 | : { message: "Metrics are disabled." }, 45 | ), 46 | ); 47 | } 48 | 49 | app.get("/ping", c => c.json("pong")); 50 | 51 | app.onError((err, c) => { 52 | let status: StatusCode = 500; 53 | if ("status" in err && typeof err.status === "number") { 54 | status = err.status as StatusCode; 55 | } 56 | return c.json({ message: err.message }, status); 57 | }); 58 | 59 | app.use("/", handleExtractQueryParamsMiddleware(stringEncrypter)); 60 | 61 | if (process.env.BLOCK_LIST && process.env.BLOCK_LIST.trim() !== "") { 62 | const allowList = formatUrlList(process.env.BLOCK_LIST); 63 | logger.info(`Blocked Domains: ${allowList.join(", ")}`); 64 | app.use("/", handleBlockListMiddleware(allowList)); 65 | } 66 | 67 | if (process.env.ALLOW_LIST && process.env.ALLOW_LIST.trim() !== "") { 68 | const allowList = formatUrlList(process.env.ALLOW_LIST); 69 | logger.info(`Allowed Domains: ${allowList.join(", ")}`); 70 | app.use("/", handleAllowListMiddleware(allowList)); 71 | } 72 | 73 | app.get("/", getIndex(imageStorageService, imageRenderService)); 74 | 75 | return app; 76 | } 77 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | urltopng: 4 | image: jasonraimondi/url-to-png 5 | build: . 6 | ports: 7 | - 3089:3089 8 | environment: 9 | METRICS: true 10 | LOG_LEVEL: debug 11 | NODE_ENV: debug 12 | BROWSER_WAIT_UNTIL: networkidle 13 | # STORAGE_PROVIDER: s3 14 | # AWS_ACCESS_KEY_ID: miniominiominio 15 | # AWS_SECRET_ACCESS_KEY: miniominiominio 16 | # AWS_DEFAULT_REGION: us-east-1 17 | # AWS_ENDPOINT_URL_S3: http://minio:9000 18 | # AWS_FORCE_PATH_STYLE: true 19 | # AWS_BUCKET: url-to-png-uploads 20 | STORAGE_PROVIDER: couchdb 21 | COUCH_DB_PROTOCOL: http 22 | COUCH_DB_HOST: couchdb 23 | COUCH_DB_PORT: 5984 24 | COUCH_DB_USER: admin 25 | COUCH_DB_PASS: password 26 | 27 | couchdb: 28 | image: couchdb 29 | ports: 30 | - 5984:5984 31 | environment: 32 | - COUCHDB_USER=admin 33 | - COUCHDB_PASSWORD=password 34 | volumes: 35 | - couchdb-data:/opt/couchdb/data 36 | 37 | couchdb-sidecar: 38 | image: curlimages/curl 39 | depends_on: 40 | - couchdb 41 | command: | 42 | sh -c " 43 | until curl -f http://admin:password@couchdb:5984/_up; do 44 | echo 'Waiting for CouchDB to be ready...'; 45 | sleep 1; 46 | done; 47 | echo 'CouchDB is ready, creating databases...'; 48 | curl -X PUT http://admin:password@couchdb:5984/_users; 49 | curl -X PUT http://admin:password@couchdb:5984/_replicator; 50 | curl -X PUT http://admin:password@couchdb:5984/_global_changes; 51 | curl -X PUT http://admin:password@couchdb:5984/images; 52 | echo 'Databases created successfully.'; 53 | " 54 | 55 | minio: 56 | image: minio/minio 57 | command: ["server", "/data", "--console-address", ":9001"] 58 | ports: 59 | - 9000:9000 60 | - 9001:9001 61 | volumes: 62 | - minio-config:/root/.minio 63 | - minio-data:/data 64 | environment: 65 | MINIO_ROOT_USER: miniominiominio 66 | MINIO_ROOT_PASSWORD: miniominiominio 67 | MINIO_HTTP_TRACE: /dev/stdout 68 | 69 | minio-mc: 70 | image: minio/mc 71 | depends_on: 72 | - minio 73 | entrypoint: > 74 | /bin/sh -c " 75 | /usr/bin/mc config host rm local; 76 | /usr/bin/mc config host add --quiet --api s3v4 local http://minio:9000 miniominiominio miniominiominio; 77 | /usr/bin/mc rb --force local/url-to-png-uploads/; 78 | /usr/bin/mc mb --quiet local/url-to-png-uploads/; 79 | /usr/bin/mc policy set public local/url-to-png-uploads; 80 | exit 0; 81 | " 82 | 83 | volumes: 84 | couchdb-data: 85 | minio-config: 86 | minio-data: 87 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | URL to PNG logo 3 |
URL to PNG 4 |

5 | 6 | [![GitHub License](https://img.shields.io/github/license/jasonraimondi/url-to-png)](https://github.com/jasonraimondi/url-to-png/blob/main/LICENSE) 7 | [![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/jasonraimondi/url-to-png/ci.yml?branch=main&style=flat-square)](https://github.com/jasonraimondi/url-to-png) 8 | [![Docker Pulls](https://img.shields.io/docker/pulls/jasonraimondi/url-to-png)](https://hub.docker.com/r/jasonraimondi/url-to-png/tags) 9 | 10 | 11 | A URL to PNG generator over HTTP with a fairly simple API accessed via query params passed to the server. 12 | 13 | - Generate PNG images from URLs 14 | - Customizable image dimensions and viewport size 15 | - Support for mobile user agent and dark mode rendering 16 | - Caching of generated images 17 | - Allow list for domain-specific requests 18 | - Configurable Playwright options 19 | - Integration with various storage providers (AWS S3, CouchDB, Filesystem) 20 | - Prometheus metrics endpoint 21 | 22 | ## Examples 23 | 24 | 25 | 26 | 27 | 28 | 29 |
30 | 31 | Use in your HTML 32 | 33 | ```html 34 | 35 | 36 | ``` 37 | 38 | ## Getting Started 39 | 40 | Checkout [the docs to getting_started](https://jasonraimondi.github.io/url-to-png/getting_started/) 41 | 42 | ### Docker 43 | 44 | Run the following command: 45 | 46 | ``` 47 | docker run --rm -p 3089:3089 ghcr.io/jasonraimondi/url-to-png 48 | ``` 49 | 50 | On the hub: [Link to DockerHub](https://hub.docker.com/r/jasonraimondi/url-to-png/) 51 | 52 | ### Local Serve 53 | 54 | Serve the project 55 | 56 | ``` 57 | git clone https://github.com/jasonraimondi/url-to-png 58 | cd url-to-png 59 | pnpm install 60 | pnpm exec playwright install chromium 61 | pnpm dev 62 | ``` 63 | 64 | ## Configuration 65 | 66 | Read the [full config options](https://jasonraimondi.github.io/url-to-png/config/) 67 | 68 | ## Encryption 69 | 70 | Learn about [encryption](https://jasonraimondi.github.io/url-to-png/encryption/) 71 | 72 | ## Metrics 73 | 74 | Learn about [metrics](https://jasonraimondi.github.io/url-to-png/metrics/) 75 | 76 | ## Star History 77 | 78 | [![Star History Chart](https://api.star-history.com/svg?repos=jasonraimondi/url-to-png&type=Timeline)](https://star-history.com/#jasonraimondi/url-to-png&Timeline) 79 | -------------------------------------------------------------------------------- /src/lib/image_render.ts: -------------------------------------------------------------------------------- 1 | import sharp from "sharp"; 2 | 3 | import { BrowserPool } from "./browser_pool.js"; 4 | import { IConfigAPI } from "./schema.js"; 5 | 6 | export type WaitForOptions = { 7 | timeout: number; 8 | waitUntil: "load" | "domcontentloaded" | "networkidle"; 9 | }; 10 | 11 | export interface ImageRenderInterface { 12 | screenshot(url: string, config: IConfigAPI): Promise; 13 | } 14 | 15 | export class ImageRenderService implements ImageRenderInterface { 16 | private readonly NAV_OPTIONS: WaitForOptions; 17 | 18 | constructor( 19 | private readonly browserPool: BrowserPool, 20 | private readonly defaultConfig: IConfigAPI = {}, 21 | navigationOptions: Partial = {}, 22 | ) { 23 | this.NAV_OPTIONS = { 24 | waitUntil: "domcontentloaded", 25 | timeout: Number(process.env.BROWSER_TIMEOUT) || 10000, 26 | ...navigationOptions, 27 | }; 28 | } 29 | 30 | public async screenshot(url: string, config: IConfigAPI = {}): Promise { 31 | let { width, height, ...defaultConfig } = this.defaultConfig; 32 | 33 | if (!config.width && !config.height) { 34 | config.width = width; 35 | 36 | if (!config.isFullPage) { 37 | config.height = height; 38 | } 39 | } 40 | 41 | config = { 42 | ...defaultConfig, 43 | ...config, 44 | }; 45 | 46 | const browser = await this.browserPool.acquire(); 47 | 48 | try { 49 | const page = await browser.newPage({ 50 | viewport: { 51 | width: config.viewportWidth!, 52 | height: config.viewportHeight!, 53 | }, 54 | isMobile: !!config.isMobile, 55 | colorScheme: config.isDarkMode ? "dark" : "light", 56 | deviceScaleFactor: config.deviceScaleFactor ?? 1, 57 | }); 58 | 59 | try { 60 | await page.goto(url, this.NAV_OPTIONS); 61 | 62 | let resizeWidth: number | undefined = undefined; 63 | let resizeHeight: number | undefined = undefined; 64 | 65 | if (typeof config.width === "number") { 66 | resizeWidth = config.width; 67 | } 68 | 69 | if ( 70 | config.isFullPage && 71 | typeof resizeWidth === "undefined" && 72 | typeof config.height === "number" 73 | ) { 74 | resizeHeight = config.height; 75 | } 76 | 77 | return await this.resize( 78 | await page.screenshot({ fullPage: !!config.isFullPage }), 79 | resizeWidth, 80 | resizeHeight, 81 | ); 82 | } finally { 83 | await page.close(); 84 | } 85 | } finally { 86 | await this.browserPool.release(browser); 87 | } 88 | } 89 | 90 | private async resize(image: Buffer, width?: number, height?: number): Promise { 91 | return await sharp(image).resize(width, height, { position: "top" }).toBuffer(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/middlewares/extract_query_params.ts: -------------------------------------------------------------------------------- 1 | import { StringEncrypter } from "@jmondi/string-encrypt-decrypt"; 2 | import { Context } from "hono"; 3 | import { HTTPException } from "hono/http-exception"; 4 | import { parseForm } from "zod-ff"; 5 | 6 | import { AppEnv } from "../app.js"; 7 | 8 | import { PlainConfigSchema } from "../lib/schema.js"; 9 | import { configToString, slugify } from "../lib/utils.js"; 10 | import { logger } from "../lib/logger.js"; 11 | 12 | export function handleExtractQueryParamsMiddleware(encryptionService?: StringEncrypter) { 13 | return async (c: Context, next: () => Promise) => { 14 | const params = new URL(c.req.url).searchParams; 15 | let input: PlainConfigSchema | URLSearchParams; 16 | 17 | if (params.has("hash")) { 18 | if (!encryptionService) { 19 | throw new HTTPException(400, { message: "This server is not configured for encryption" }); 20 | } 21 | const hash = params.get("hash"); 22 | if (typeof hash !== "string" || !hash.startsWith("str-enc:")) { 23 | throw new HTTPException(400, { message: "Invalid hash" }); 24 | } 25 | const decryptedString = await encryptionService.decrypt(hash); 26 | input = JSON.parse(decryptedString); 27 | } else { 28 | if (encryptionService) { 29 | throw new HTTPException(400, { message: "This server must use encryption" }); 30 | } 31 | input = params; 32 | } 33 | 34 | const { validData, errors } = parseForm({ data: input, schema: PlainConfigSchema }); 35 | 36 | if (validData?.viewPortWidth !== undefined) { 37 | logger.warn("'viewPortWidth' is deprecated, please use 'viewportWidth'"); 38 | } 39 | if (validData?.viewPortHeight !== undefined) { 40 | logger.warn("'viewPortHeight' is deprecated, please use 'viewportHeight'"); 41 | } 42 | 43 | if (errors) { 44 | let message: string = "Invalid query parameters: "; 45 | 46 | const specificErrors = Object.entries(errors) 47 | .map(([key, value]) => `(${key} - ${value})`) 48 | .join(" "); 49 | 50 | message = `${message} ${specificErrors}`; 51 | 52 | throw new HTTPException(400, { message, cause: errors }); 53 | } 54 | 55 | if (validData.width && validData.width > 1920) { 56 | validData.width = 1920; 57 | } 58 | 59 | if (validData.height && validData.height > 1920) { 60 | validData.height = 1920; 61 | } 62 | 63 | const viewportWidth = validData.viewportWidth ?? validData.viewPortWidth; 64 | if (viewportWidth && viewportWidth > 1920) { 65 | validData.width = 1920; 66 | } 67 | 68 | const viewportHeight = validData.viewportHeight ?? validData.viewPortHeight; 69 | if (viewportHeight && viewportHeight > 1920) { 70 | validData.width = 1920; 71 | } 72 | 73 | const date = new Date(); 74 | const dateString = date.toLocaleDateString().replace(/\//g, "-"); 75 | const imageId = dateString + "." + slugify(validData.url) + slugify(configToString(params)); 76 | 77 | c.set("input", validData); 78 | c.set("imageId", imageId); 79 | 80 | await next(); 81 | }; 82 | } 83 | -------------------------------------------------------------------------------- /src/lib/factory.ts: -------------------------------------------------------------------------------- 1 | import { S3Client } from "@aws-sdk/client-s3"; 2 | import { Options } from "generic-pool"; 3 | import nano from "nano"; 4 | 5 | import { BrowserPool } from "./browser_pool.js"; 6 | import { ImageRenderService, WaitForOptions } from "./image_render.js"; 7 | import { logger } from "./logger.js"; 8 | import { ImageStorage } from "./storage/_base.js"; 9 | import { CouchDbStorageProvider } from "./storage/couch-db.js"; 10 | import { FileSystemStorageProvider } from "./storage/filesystem.js"; 11 | import { AmazonS3StorageProvider } from "./storage/s3.js"; 12 | import { StubStorageProvider } from "./storage/stub.js"; 13 | import { isValidNum } from "./utils.js"; 14 | 15 | export function createBrowserPool(opts: Options = {}) { 16 | return new BrowserPool({ poolOpts: opts }); 17 | } 18 | 19 | export function createImageRenderService(browserPool: BrowserPool) { 20 | const navigationOptions: Partial = {}; 21 | switch (process.env.BROWSER_WAIT_UNTIL) { 22 | case "load": 23 | case "domcontentloaded": 24 | case "networkidle": 25 | navigationOptions.waitUntil = process.env.BROWSER_WAIT_UNTIL; 26 | break; 27 | default: 28 | break; 29 | } 30 | 31 | const width = isValidNum(process.env.DEFAULT_WIDTH) ? Number(process.env.DEFAULT_WIDTH) : 250; 32 | const height = isValidNum(process.env.DEFAULT_HEIGHT) ? Number(process.env.DEFAULT_HEIGHT) : 250; 33 | 34 | const defaultConfig = { 35 | width, 36 | height, 37 | viewportWidth: isValidNum(process.env.DEFAULT_VIEWPORT_WIDTH) 38 | ? Number(process.env.DEFAULT_VIEWPORT_WIDTH) 39 | : 1080, 40 | viewportHeight: isValidNum(process.env.DEFAULT_VIEWPORT_HEIGHT) 41 | ? Number(process.env.DEFAULT_VIEWPORT_HEIGHT) 42 | : 1080, 43 | isMobile: process.env.DEFAULT_IS_MOBILE === "true", 44 | isFullPage: process.env.DEFAULT_IS_FULL_PAGE === "true", 45 | isDarkMode: process.env.DEFAULT_IS_DARK_MODE === "true", 46 | deviceScaleFactor: isValidNum(process.env.DEFAULT_DEVICE_SCALE_FACTOR) 47 | ? Number(process.env.DEFAULT_DEVICE_SCALE_FACTOR) 48 | : 1, 49 | }; 50 | 51 | return new ImageRenderService(browserPool, defaultConfig, navigationOptions); 52 | } 53 | 54 | export function createImageStorageService(): ImageStorage { 55 | let imageStorage: ImageStorage; 56 | switch (process.env.STORAGE_PROVIDER) { 57 | case "s3": 58 | imageStorage = new AmazonS3StorageProvider( 59 | new S3Client({ 60 | region: process.env.AWS_DEFAULT_REGION ?? "us-east-1", 61 | endpoint: process.env.AWS_ENDPOINT_URL_S3, 62 | credentials: { 63 | accessKeyId: process.env.AWS_ACCESS_KEY_ID!, 64 | secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, 65 | }, 66 | forcePathStyle: process.env.AWS_FORCE_PATH_STYLE === "true", 67 | }), 68 | process.env.AWS_BUCKET!, 69 | ); 70 | break; 71 | case "couchdb": 72 | const protocol = process.env.COUCH_DB_PROTOCOL; 73 | const user = process.env.COUCH_DB_USER; 74 | const pass = process.env.COUCH_DB_PASS; 75 | const host = process.env.COUCH_DB_HOST; 76 | const port = process.env.COUCH_DB_PORT; 77 | imageStorage = new CouchDbStorageProvider( 78 | nano(`${protocol}://${user}:${pass}@${host}:${port}`), 79 | ); 80 | break; 81 | case "filesystem": 82 | const filePath = process.env.IMAGE_STORAGE_PATH!; 83 | imageStorage = new FileSystemStorageProvider(filePath); 84 | break; 85 | case "stub": 86 | default: 87 | imageStorage = new StubStorageProvider(); 88 | } 89 | 90 | logger.info(imageStorage.constructor.name); 91 | 92 | return imageStorage; 93 | } 94 | -------------------------------------------------------------------------------- /docs/config/index.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configure various settings through environment variables. 4 | 5 | ## General Settings 6 | 7 | - `LOG_LEVEL`: Logging level (debug, info, warn, error). Default: `debug`. 8 | - `NODE_ENV`: Node environment (development, production). Default: `development`. 9 | - `PORT`: Port number for the application to listen on. Default: `3089`. 10 | - `CACHE_CONTROL`: Cache-Control header value for the responses. Default: `"public, max-age=86400, immutable"`. 11 | 12 | ## AllowBlock List 13 | 14 | - `ALLOW_LIST`: Comma-separated list of **allowed** domains for screenshots. If undefined or empty, all domains are allowed. Example: `ALLOW_LIST=jasonraimondi.com,github.com`. 15 | - `BLOCK_LIST`: Comma-separated list of **restricted** domains for screenshots. If undefined or empty, all domains are allowed. Example: `BLOCK_LIST=localhost,google.com`. 16 | 17 | ## Playwright Options 18 | 19 | - `BROWSER_TIMEOUT`: Browser timeout in milliseconds for rendering. Default: `10000`. 20 | - `BROWSER_WAIT_UNTIL`: Event to wait for before considering the page loaded. Valid options: `'load'`, `'domcontentloaded'`, `'networkidle'`. Default: `'domcontentloaded'`. 21 | 22 | ## Connection Pool Options 23 | 24 | - `POOL_IDLE_TIMEOUT_MS`: Idle timeout for browser connection pool in milliseconds. Default: `15000`. 25 | - `POOL_MAX`: Maximum number of connections in the browser pool. Default: `10`. 26 | - `POOL_MAX_WAITING_CLIENTS`: Maximum number of waiting clients for the browser pool. Default: `50`. 27 | - `POOL_MIN`: Minimum number of connections in the browser pool. Default: `2`. 28 | 29 | ## Default Browser Options 30 | 31 | - `DEFAULT_WIDTH`: Default width of the rendered image in pixels. Default: `250` 32 | - `DEFAULT_HEIGHT`: Default height of the rendered image in pixels. Default: `250` 33 | - `DEFAULT_VIEWPORT_WIDTH`: Default width of the browser viewport in pixels. Default: `1080` 34 | - `DEFAULT_VIEWPORT_HEIGHT`: Default height of the browser viewport in pixels. Default: `1080` 35 | - `DEFAULT_IS_MOBILE`: Whether to emulate a mobile device. Default: `false` 36 | - `DEFAULT_IS_FULL_PAGE`: Whether to capture the full page or clip the page at the viewport height. Default: `false` 37 | - `DEFAULT_IS_DARK_MODE`: Whether to request dark mode. Default: `false` 38 | - `DEFAULT_DEVICE_SCALE_FACTOR`: Default device scale factor (e.g., 2 for retina displays). Default: `1` 39 | 40 | ## Metrics and Encryption 41 | 42 | - `METRICS`: Enable or disable metrics collection. Default: `false`. 43 | - `CRYPTO_KEY`: Encryption key for sensitive data. 44 | 45 | ## Storage Providers 46 | 47 | The project supports multiple storage providers for caching rendered images. The storage provider can be configured using the `STORAGE_PROVIDER` environment variable. The available storage providers are: 48 | 49 | ### Stub Storage Provider (default) 50 | 51 | The stub storage provider is a placeholder storage provider that doesn't actually store or retrieve images, it simply logs debug messages. It can be used for testing or when storage functionality is not required. 52 | 53 | ### Filesystem 54 | 55 | The filesystem storage provider allows storing and retrieving rendered images on the local filesystem. 56 | 57 | - `STORAGE_PROVIDER`: `"filesystem"` 58 | - `IMAGE_STORAGE_PATH`: The directory path where images will be stored 59 | 60 | ### S3 61 | 62 | The S3 compatible storage provider allows storing and retrieving rendered images using Amazon S3. 63 | 64 | - `STORAGE_PROVIDER`: `"s3"` 65 | - `AWS_BUCKET`: The name of the S3 bucket to store images 66 | - `AWS_ACCESS_KEY_ID`: The AWS access key ID 67 | - `AWS_SECRET_ACCESS_KEY`: The AWS secret access key 68 | - `AWS_DEFAULT_REGION`: The AWS region for S3 (default: "us-east-1") 69 | - `AWS_FORCE_PATH_STYLE`: Set to `true` to use path-style URLs for S3 (default: `false`) 70 | - `AWS_ENDPOINT_URL_S3`: The endpoint URL for S3 (optional) 71 | 72 | ### CouchDB 73 | 74 | The CouchDB storage provider allows storing and retrieving rendered images using CouchDB. 75 | 76 | - `STORAGE_PROVIDER`: `"couchdb"` 77 | - `COUCH_DB_PROTOCOL`: The protocol for connecting to CouchDB (e.g., "http" or "https") 78 | - `COUCH_DB_USER`: The CouchDB username 79 | - `COUCH_DB_PASS`: The CouchDB password 80 | - `COUCH_DB_HOST`: The CouchDB host 81 | - `COUCH_DB_PORT`: The CouchDB port 82 | - `COUCHDB_DATABASE`: The name of the CouchDB database to store images (default: "images") 83 | -------------------------------------------------------------------------------- /tests/app.spec.ts: -------------------------------------------------------------------------------- 1 | import { StringEncrypter } from "@jmondi/string-encrypt-decrypt"; 2 | import type { Hono } from "hono"; 3 | import { it, describe, suite, expect, beforeEach } from "vitest"; 4 | 5 | import { type AppEnv, createApplication } from "../src/app.js"; 6 | import { createBrowserPool, createImageStorageService } from "../src/lib/factory.js"; 7 | import { StubImageRenderService } from "./helpers/stubs.js"; 8 | 9 | suite("app", () => { 10 | let app: Hono; 11 | 12 | const browserPool = createBrowserPool(); 13 | const imageStorageService = createImageStorageService(); 14 | const imageRenderService = new StubImageRenderService(); 15 | 16 | beforeEach(() => { 17 | app = createApplication(browserPool, imageRenderService, imageStorageService); 18 | }); 19 | 20 | describe("GET /ping", () => { 21 | it("success", async () => { 22 | const res = await app.request("/ping"); 23 | expect(res.status).toBe(200); 24 | expect(await res.json()).toBe("pong"); 25 | }); 26 | }); 27 | 28 | describe("GET /metrics", () => { 29 | beforeEach(() => { 30 | process.env.METRICS = "true"; 31 | app = createApplication(browserPool, imageRenderService, imageStorageService); 32 | }); 33 | 34 | it("success", async () => { 35 | const res = await app.request("/metrics"); 36 | expect(res.status).toBe(200); 37 | expect(await res.json()).toStrictEqual({ 38 | poolMetrics: { 39 | available: 0, 40 | borrowed: 0, 41 | max: 10, 42 | min: 2, 43 | pending: 0, 44 | size: 2, 45 | spareResourceCapacity: 8, 46 | }, 47 | }); 48 | }); 49 | }); 50 | 51 | describe("GET /?url=", () => { 52 | it("succeeds with minimal", async () => { 53 | const res = await app.request("/?url=https://google.com"); 54 | expect(res.status).toBe(200); 55 | }); 56 | 57 | it("succeeds with resize", async () => { 58 | const res = await app.request("/?url=https://google.com&width=500&height=500"); 59 | expect(res.status).toBe(200); 60 | }); 61 | 62 | it("succeeds with uri encoded url", async () => { 63 | const url = encodeURIComponent("https://jasonraimondi.com"); 64 | const res = await app.request(`/?url=${url}`); 65 | expect(res.status).toBe(200); 66 | }); 67 | 68 | it("throws when invalid domain", async () => { 69 | const res = await app.request("/?url=bar"); 70 | expect(res.status).toBe(400); 71 | expect(await res.text()).toMatch(/Invalid query/gi); 72 | }); 73 | 74 | [ 75 | "file:///etc/passwd&width=4000", 76 | "view-source:file:///home/&width=4000", 77 | "view-source:file:///home/ec2-user/url-to-png/.env", 78 | ].forEach(invalidDomain => { 79 | it(`throws when invalid protocol ${invalidDomain}`, async () => { 80 | const res = await app.request(`/?url=${invalidDomain}`); 81 | expect(res.status).toBe(400); 82 | expect(await res.text()).toMatch(/url - must start with http or https/gi); 83 | }); 84 | }); 85 | }); 86 | 87 | describe("GET /?hash=", () => { 88 | describe("without CRYPTO_KEY", () => { 89 | it("throws when server is not configured for encryption", async () => { 90 | const res = await app.request( 91 | "/?hash=str-enc:a/4xkic0kY8scM3QRJIiLLtQ3NhZxEudhmd7RZDbsuuguXkamhZe0HdW9LmnZxtGCtf0GAPO5II85fE8rSkdFNIbBATyS/INKM0hmw==:a4S74z7c4DQVtijl", 92 | ); 93 | const body = await res.json(); 94 | expect(res.status).toBe(400); 95 | expect(body.message).toMatch(/This server is not configured for encryption/); 96 | }); 97 | }); 98 | 99 | describe("with CRYPTO_KEY", () => { 100 | beforeEach(async () => { 101 | const cryptoKey = 102 | '{"kty":"oct","k":"cq8cebOn49gXxcjoRbjP93z4OpzCkyz4WJSgPnvR4ds","alg":"A256GCM","key_ops":["encrypt","decrypt"],"ext":true}'; 103 | const stringEncrypter = await StringEncrypter.fromCryptoString(cryptoKey); 104 | app = createApplication( 105 | browserPool, 106 | imageRenderService, 107 | imageStorageService, 108 | stringEncrypter, 109 | ); 110 | }); 111 | 112 | it("succeeds!", async () => { 113 | const res = await app.request( 114 | "/?hash=str-enc:a/4xkic0kY8scM3QRJIiLLtQ3NhZxEudhmd7RZDbsuuguXkamhZe0HdW9LmnZxtGCtf0GAPO5II85fE8rSkdFNIbBATyS/INKM0hmw==:a4S74z7c4DQVtijl", 115 | ); 116 | expect(res.status).toBe(200); 117 | }); 118 | }); 119 | }); 120 | }); 121 | --------------------------------------------------------------------------------