Source & license
Upstream license: GPL-3.0
License TL;DR
You can run it and change it privately. If you give others copies of the program or a modified version, provide the corresponding source under the GPL. You can charge money. Running it as a web service alone doesn’t trigger that source-sharing requirement.
Explain GPL v3 in plain English →Summary of the main license. Separate packages and assets can have different terms.
Inspect repository ↗Read this project’s actual license ↗Repository owner
See the upstream repository for the original creator and contributors.
Maintain this project? Maintainer verification →Cloudflare hosting
Free tier eligible within limits
The documented CattoPic deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs.
Hosting requirements
- Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits.
- Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows.
- Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes.
- Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account.
- Keep total queue operations within 10,000/day, counting writes, reads, deletes, retries and each 64 KB chunk; Free retention is 24 hours.
- Store originals in R2 and limit Images to 5,000 unique transformations/month; paid Images storage is excluded.
- Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations.
Sources checked 01/10/2026
Repository snapshot: e50fcb2. Hosting eligibility reflects the deployment documentation and listed assumptions.
- imgur ↗
**1.0.0** runs the admin UI and the API on **one Cloudflare Worker**. Open the Worker hostname (`workers.dev` or a Custom Domain) and the UI
- cloudinary ↗
**1.0.0** runs the admin UI and the API on **one Cloudflare Worker**. Open the Worker hostname (`workers.dev` or a Custom Domain) and the UI
- workers ↗
{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "cattopic-worker", "main": "./src/worker/index.ts", "compatibility_date": "2026-08-25", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "binding": "ASSETS", "run_worker_first": ["/api/*"] }, "vars": { "ENVIRONMENT": "production", // Image object CDN (R2 public domain). The app itself is t
- d1 ↗
ES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE
- kv ↗
d": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "trigg
- r2 ↗
UBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a8408579982
- queues ↗
{ "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "triggers": { "crons": ["0 * * * *"] }, "observability": { "enabled": true, "logs": { "enabled": true, "head_sam
- images ↗
2_PUBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-del
- free-tier-eligible ↗
{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "cattopic-worker", "main": "./src/worker/index.ts", "compatibility_date": "2026-08-25", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "binding": "ASSETS", "run_worker_first": ["/api/*"] }, "vars": { "ENVIRONMENT": "production", // Image object CDN (R2 public domain). The app itself is t
- free-tier-eligible ↗
ES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE
- free-tier-eligible ↗
d": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "trigg
- free-tier-eligible ↗
up>1, 2, 3, 4</sup> | Duration | CPU time | | --- | --- | --- | --- | | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation | | **Standard** | 10 million included per month <br> +$0.30 per additional million | No charge or limit for duration | 30 million CPU milliseconds included per month<br> +$0.02 per additional million CPU milliseconds<br><br> Max of [5 minutes of CPU time](https://developers.cloudflare.com/workers/platform/limits/#account-plan-limits) per invocation (default: 30 seconds)<br> Max of 15 minutes of CPU time per [Cron Trigger](https://developers.cloudflare.com/workers/configuration/cron-triggers/) or [Queue Consumer](https://developers.cloudflare.co
- free-tier-eligible ↗
oudflare.com/workers/platform/pricing/#workers) | | --- | --- | --- | | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows | | Rows written | 100,000 / day | First 50 million / month included + $1.00 / million rows | | Storage (per GB stored) | 5 GB (total) | First 5 GB included + $0.75 / GB-mo | Track your D1 usage To accurately track your usage, use the [meta object](https://developers.cloudflare.com/d1/worker-api/return-object/), [GraphQL Analytics API](https://developers.cloudflare.com/d1/observability/metrics-analytics/#query-via-the-graphql-api), or the [Cloudflare dashboard ↗︎](https://dash.cloudflare.com/?to=/:account/workers/d1/). Select your D1 database, then vie
- free-tier-eligible ↗
cing/). | | Free plan<sup>1</sup> | Paid plan | | --- | --- | --- | | Keys read | 100,000 / day | 10 million/month, + $0.50/million | | Keys written | 1,000 / day | 1 million/month, + $5.00/million | | Keys deleted | 1,000 / day | 1 million/month, + $5.00/million | | List requests | 1,000 / day | 1 million/month, + $5.00/million | | Stored data | 1 GB | 1 GB, + $0.50/ GB-month | <sup>1</sup> The Workers Free plan includes limited Workers KV usage. All limits reset daily at 00:00 UTC. If you exceed any one of these limits, further operations of that type will fail with an error. Note Workers KV pricing for read, write and delete operations is on a per-key basis. Bulk read operations are billed by the amount
- free-tier-eligible ↗
infrequent access storage) for 1.1 GB, you will be billed for 2 GB. ### Free tier You can use the following amount of storage and operations each month for free. | | Free | | --- | --- | | Storage | 10 GB-month / month | | Class A Operations | 1 million requests / month | | Class B Operations | 10 million requests / month | | Egress (data transfer to Internet) | Free <sup>[1](#user-content-fn-1)</sup> | Caution The free tier only applies to Standard storage, and does not apply to Infrequent Access storage. ### Storage usage Storage is billed using gigabyte-month (GB-month) as the billing metric. A GB-month is calculated by averaging the *peak* storage per day over a billing period (30 days). For examp
- free-tier-eligible ↗
dth) charges. | | Workers Free | Workers Paid | | --- | --- | --- | | Standard operations | 10,000 operations/day included | 1,000,000 operations/month included + $0.40/million operations | | Message retention | 24 hours (non-configurable) | 4 days default, configurable up to 14 days | In most cases, it takes 3 operations to deliver a message: 1 write, 1 read, and 1 delete. Therefore, you can use the following formula to estimate your monthly bill: ```txt ((Number of Messages * 3) - 1,000,000) / 1,000,000 * $0.40 ``` Additionally: - Each retry incurs a read operation. A batch of 10 messages that is retried would incur 10 operations for each retry. - Messages that reach the maximum retries and that are wr
- free-tier-eligible ↗
Images Stored, Images Delivered | Only Paid plans | ## Images Free On the Free plan, you can request up to 5,000 unique transformations each month for free. Once you exceed 5,000 unique transformations: - Existing transformations in cache will continue to be served as expected. - New transformations will return a `9422` error. If your source image is from the same domain where the transformation is served, then you can use the [`onerror` parameter](https://developers.cloudflare.com/images/optimization/features/#onerror) to redirect to the original image. - You will not be charged for exceeding the limits in the Free plan. To request more than 5,000 unique transformations each month, you can purchase an Im
- GPL-3.0 ↗
GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Pub
- architecture ↗
{ "$schema": "./node_modules/wrangler/config-schema.json", "name": "cattopic-worker", "main": "./src/worker/index.ts", "compatibility_date": "2026-08-25", "compatibility_flags": ["nodejs_compat"], "assets": { "not_found_handling": "single-page-application", "binding": "ASSETS", "run_worker_first": ["/api/*"] }, "vars": { "ENVIRONMENT": "production", // Image object CDN (R2 public domain). The app itself is t
- architecture ↗
ES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE
- architecture ↗
d": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "trigg
- architecture ↗
UBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a8408579982
- architecture ↗
{ "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-delete-queue", "max_batch_size": 10, "max_batch_timeout": 5 } ] }, "triggers": { "crons": ["0 * * * *"] }, "observability": { "enabled": true, "logs": { "enabled": true, "head_sam
- architecture ↗
2_PUBLIC_URL": "https://r2.catcat.li", "USE_QUEUE": "true" }, "images": { "binding": "IMAGES" }, "r2_buckets": [ { "binding": "R2_BUCKET", "bucket_name": "cattopic" } ], "d1_databases": [ { "binding": "DB", "database_name": "CattoPic-D1", "database_id": "cb07b667-2a42-4ca1-84ce-49d5114af009", "migrations_dir": "./src/worker/migrations" } ], "kv_namespaces": [ { "binding": "CACHE_KV", "id": "f04a33814498458a84085799828bc7d7" } ], "queues": { "producers": [ { "binding": "DELETE_QUEUE", "queue": "cattopic-delete-queue" } ], "consumers": [ { "queue": "cattopic-del
What it can replace
Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.
Uploading, organizing and serving images, including R2-backed transformations; community features, full digital asset management and video pipelines are excluded.
See supporting source ↗Uploading, organizing and serving images, including R2-backed transformations; community features, full digital asset management and video pipelines are excluded.
See supporting source ↗How it works
The shape of CattoPic on Cloudflare, and how it stacks up against the rented tools it replaces.
Architecture
Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.
View upstream source ↗Configuration and workflow sources
Reviewed commit e50fcb291073. Files were read as data; upstream applications and CI jobs were not executed.
Deployment configuration · 2 files
Cloudflare Workers · compatibility 2026-08-25
cattopic-worker · default
Entrypoint: ./src/worker/index.ts
Static assets: directory not declared · single-page-application · Worker first: ["/api/*"]
Cron triggers (UTC): 0 * * * *
DB→ D1CACHE_KV→ KVR2_BUCKET→ R2IMAGES→ ImagesDELETE_QUEUE→ Queues (producer) · queue cattopic-delete-queuecattopic-delete-queue→ Queues (consumer) · queue cattopic-delete-queueASSETS→ Static assets
Cloudflare Workers · example/template, excluded from overview · compatibility 2026-08-25
cattopic-worker · default
Entrypoint: ./src/worker/index.ts
Static assets: directory not declared · single-page-application · Worker first: ["/api/*"]
Cron triggers (UTC): 0 * * * *
DB→ D1CACHE_KV→ KVR2_BUCKET→ R2IMAGES→ ImagesDELETE_QUEUE→ Queues (producer) · queue cattopic-delete-queuecattopic-delete-queue→ Queues (consumer) · queue cattopic-delete-queueASSETS→ Static assets
Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.
Runtime source · handlers, binding usage and workflow steps
Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.
- L176 · fetch handler exported
- L177 · scheduled handler exported · references DB, CACHE_KV · calls console.log, processPendingDeletionJobs, metadata.getExpiredImages, expiredImages.map, toDeletionTarget, metadata.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateImagesList, cache.invalidateTagsList, cache.invalidateImageDetails, deletionTargets.map, dispatchImageDeletions, console.error
- L178 · queue handler exported · calls handleQueueBatch
- L22 · app.use("/api/*")
- L60 · app.get("/api/random")
- L65 · app.post("/api/validate-api-key")
- L68 · app.post("/api/upload/single")
- L71 · app.get("/api/images")
- L72 · app.get("/api/images/:id")
- L73 · app.put("/api/images/:id")
- L74 · app.delete("/api/images/:id")
- L77 · app.get("/api/tags")
- L78 · app.post("/api/tags")
- L79 · app.put("/api/tags/:name")
- L80 · app.delete("/api/tags/:name")
- L81 · app.post("/api/tags/batch")
- L84 · app.get("/api/config")
- L85 · app.post("/api/cleanup")
- L121 · scheduledHandler calls (conditional paths may differ): console.log, processPendingDeletionJobs, metadata.getExpiredImages, expiredImages.map, toDeletionTarget, metadata.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateImagesList, cache.invalidateTagsList, cache.invalidateImageDetails, deletionTargets.map, dispatchImageDeletions, console.error
- L168 · queueHandler calls (conditional paths may differ): handleQueueBatch
Environment references: c.env.DB · env.DB · env.CACHE_KV
- L2 · jsonResponse calls (conditional paths may differ): JSON.stringify
- L14 · successResponse calls (conditional paths may differ): jsonResponse
- L18 · errorResponse calls (conditional paths may differ): jsonResponse
- L22 · unauthorizedResponse calls (conditional paths may differ): errorResponse
- L26 · notFoundResponse calls (conditional paths may differ): errorResponse
- L8 · errorMessage calls (conditional paths may differ): String
- L16 · processImageDeletionTargets calls (conditional paths may differ): targets.map, storage.deleteImageFilesBatch, metadata.completeDeletionJobsForImages, metadata.recordDeletionJobFailureForImages, errorMessage
- L35 · dispatchImageDeletions calls (conditional paths may differ): env.DELETE_QUEUE.send, targets.slice, console.error, processImageDeletionTargets
- L69 · processPendingDeletionJobs calls (conditional paths may differ): metadata.getPendingDeletionJobs, processImageDeletionTargets
Environment references: env.DB · env.R2_BUCKET · env.USE_QUEUE · env.DELETE_QUEUE
- L21 · uploadSingleHandler calls (conditional paths may differ): c.req.header, parseInt, console.error, errorResponse, c.req.formData, formData.get, parseNumber, parseCompressionOptions, console.log, parseTags, file.arrayBuffer, ImageProcessor.getImageInfo, arrayBuffer, file.slice, ImageProcessor.isSupportedFormat, generateImageId, StorageService.generatePaths, ImageProcessor.getContentType, storage.upload, compression.compress
Environment references: c.env.R2_BUCKET · c.env.DB · c.env.IMAGES · c.env.R2_PUBLIC_URL · c.env.CACHE_KV
- L12 · clampInt calls (conditional paths may differ): Number.isFinite, Math.max, Math.min, Math.trunc
- L17 · isExpired calls (conditional paths may differ): Date.parse, Date.now
- L22 · imagesHandler calls (conditional paths may differ): Math.max, parseNumber, url.searchParams.get, clampInt, sanitizeTagName, validateOrientation, validateImageListFormat, metadata.getImages, images.map, buildImageUrls, Math.ceil, successResponse, console.error, errorResponse
- L68 · imageDetailHandler calls (conditional paths may differ): c.req.param, isValidUUID, errorResponse, CacheKeys.imageDetail, cache.get, isExpired, successResponse, metadata.getImage, notFoundResponse, buildImageUrls, cache.set, console.error
- L125 · updateImageHandler calls (conditional paths may differ): c.req.param, isValidUUID, errorResponse, c.req.json, Array.isArray, filter, body.tags.map, sanitizeTagName, String, Array.from, parseTags, Number, Number.isFinite, Date.now, expiry.toISOString, metadata.updateImage, notFoundResponse, cache.invalidateImageDetail, cache.invalidateImagesList, cache.invalidateTagsList
- L209 · deleteImageHandler calls (conditional paths may differ): c.req.param, isValidUUID, errorResponse, metadataService.getImage, notFoundResponse, toDeletionTarget, metadataService.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateAfterImageChange, cache.invalidateTagsList, c.executionCtx.waitUntil, catch, dispatchImageDeletions, console.error, successResponse
Environment references: c.env.DB · c.env.R2_PUBLIC_URL · c.env.CACHE_KV
- L9 · randomHandler calls (conditional paths may differ): url.searchParams.get, parseTags, c.req.header, isMobileDevice, metadata.getRandomImage, errorResponse, buildImageUrls, getBestFormat, console.error
Environment references: c.env.DB · c.env.R2_PUBLIC_URL
- L12 · normalizeTagRouteParam calls (conditional paths may differ): decodeURIComponent, sanitizeTagName, trim, decoded.toLowerCase
- L32 · tagsHandler calls (conditional paths may differ): CacheKeys.tagsList, cache.get, successResponse, metadata.getAllTags, cache.set, console.error, errorResponse
- L61 · createTagHandler calls (conditional paths may differ): c.req.json, sanitizeTagName, errorResponse, metadata.createTag, cache.invalidateTagsList, successResponse, console.error
- L88 · renameTagHandler calls (conditional paths may differ): normalizeTagRouteParam, c.req.param, c.req.json, sanitizeTagName, errorResponse, metadata.renameTag, cache.invalidateAfterTagChange, metadata.getAllTags, tags.find, successResponse, console.error
- L129 · deleteTagHandler calls (conditional paths may differ): normalizeTagRouteParam, c.req.param, errorResponse, console.log, metadata.getImagePathsByTag, console.error, images.map, toDeletionTarget, metadata.deleteTagWithImages, Promise.all, cache.invalidateAfterTagChange, cache.invalidateImageDetails, imagePaths.map, c.executionCtx.waitUntil, catch, dispatchImageDeletions, successResponse
- L201 · batchTagsHandler calls (conditional paths may differ): c.req.json, Array.isArray, errorResponse, imageIds.filter, stringImageIds.every, Array.from, filter, map, sanitizeTagName, String, metadata.batchUpdateTags, Promise.all, cache.invalidateAfterTagChange, cache.invalidateImageDetails, successResponse, console.error
Environment references: c.env.CACHE_KV · c.env.DB
- L18 · validateApiKeyHandler calls (conditional paths may differ): successResponse
- L24 · configHandler calls (conditional paths may differ): CacheKeys.config, cache.get, successResponse, all, c.env.DB.prepare, JSON.parse, cache.set, console.error
- L68 · cleanupHandler calls (conditional paths may differ): c.executionCtx.waitUntil, catch, processPendingDeletionJobs, console.error, metadata.getExpiredImages, expiredImages.map, toDeletionTarget, metadata.deleteImagesWithDeletionJobs, Promise.all, cache.invalidateImagesList, cache.invalidateTagsList, cache.invalidateImageDetails, deletionTargets.map, dispatchImageDeletions, successResponse, errorResponse
Environment references: c.env.CACHE_KV · c.env.DB
- L6 · handleQueueBatch calls (conditional paths may differ): console.log, processImageDeletionTargets, toDeletionTarget, message.body.imagePaths.map, message.ack, console.error, message.retry
- L219 · parseCompressionOptions calls (conditional paths may differ): parseInt, isNaN, parseNumber, formData.get
- L3 · isValidUUID calls (conditional paths may differ): uuidRegex.test, imageIdRegex.test
- L10 · generateUUID calls (conditional paths may differ): crypto.randomUUID
- L15 · generateImageId calls (conditional paths may differ): replace, slice, now.toISOString, crypto.randomUUID
- L22 · sanitizeTagName calls (conditional paths may differ): substring, replace, trim, tag.toLowerCase
- L30 · parseTags calls (conditional paths may differ): filter, map, tagsString.split, sanitizeTagName
- L38 · parseNumber calls (conditional paths may differ): parseInt, isNaN
- L62 · validateImageListFormat calls (conditional paths may differ): value.toLowerCase
- L80 · isMobileDevice calls (conditional paths may differ): userAgent.toLowerCase, mobileKeywords.some, ua.includes
- L91 · getBestFormat calls (conditional paths may differ): acceptHeader.includes
- L12 · clampInt calls (conditional paths may differ): Number.isFinite, Math.max, Math.min, Math.trunc
- L17 · toPositiveInt calls (conditional paths may differ): Number, Number.isFinite, Math.max, Math.trunc
- L23 · buildPublicUrl calls (conditional paths may differ): baseUrl.endsWith, key.startsWith, key.slice, toString
- L29 · calculateDimensions calls (conditional paths may differ): Math.min, Math.round
- L46 · buildCdnCgiOptionsString calls (conditional paths may differ): clampInt, parts.push, parts.join
- L72 · buildImageUrls calls (conditional paths may differ): buildPublicUrl, toLowerCase, clampInt, toPositiveInt, calculateDimensions, buildCdnCgiOptionsString, buildTransformedUrl, Math.min
Build and deployment pipeline · 1 GitHub Actions workflows
Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.
Triggers: push, workflow_dispatch
Deploy to Cloudflare Workers · no job dependencies declared
- Checkout
actions/checkout@v4 - Setup pnpm
pnpm/action-setup@v4 - Setup Node.js
actions/setup-node@v4 - Install dependencies
pnpm install --frozen-lockfile - Typecheck
pnpm typecheck - Test
pnpm test - Deploy to Cloudflare
pnpm run deploy
build: vite builddeploy: pnpm build && wrangler deploy
Repository README
View original on GitHub ↗Full upstream document by @Yuri-NagaSaki · README.md · snapshot e50fcb2
CattoPic
Self-hosted image host: upload, tags, WebP/AVIF variants, expiry, and a public random-image API.
1.0.0 runs the admin UI and the API on one Cloudflare Worker. Open the Worker hostname (workers.dev or a Custom Domain) and the UI is there. There is no Vercel app and no second frontend deploy.
Image files live in R2 and are served from R2_PUBLIC_URL (object CDN and /cdn-cgi/image). That is storage, not a second app.
What 1.0.0 changes
| Before | After |
|---|---|
| Next.js on Vercel + Hono Worker | One Worker: Vite React SPA (Static Assets) + Hono /api/* |
UI called NEXT_PUBLIC_API_URL |
Same-origin fetch("/api/...") |
Docs described GET /r2/{path} |
Image bytes from R2_PUBLIC_URL only |
Public HTTP routes are unchanged. GET /api/random still returns 302 to an image URL.
Architecture
flowchart TB
subgraph Client
Browser[Browser]
APIClient[API client]
end
subgraph Worker["Cloudflare Worker"]
Assets["Static Assets<br/>/ and /manage"]
Hono["Hono /api/*"]
end
subgraph CF["Cloudflare"]
R2[("R2")]
D1[("D1")]
KV[("KV")]
Queue[Queues]
Images[Images binding]
Cron[Cron]
end
Browser --> Assets
Browser --> Hono
APIClient --> Hono
Hono --> R2
Hono --> D1
Hono --> KV
Hono --> Queue
Hono --> Images
Cron --> Hono
Browser -->|"image bytes"| R2
| Path | Role |
|---|---|
https://<worker>/ |
Upload UI |
https://<worker>/manage |
Gallery / tags |
https://<worker>/api/* |
Hono API (this is the only path that invokes the Worker script) |
Static HTML/JS/CSS does not invoke the Worker. assets.run_worker_first is ["/api/*"] only.
Storage keys
| Variant | Key |
|---|---|
| Original | original/{orientation}/{id}.{ext} |
| WebP | {orientation}/webp/{id}.webp |
| AVIF | {orientation}/avif/{id}.avif |
GIF is original-only. JPEG/PNG larger than 20MB skip the Images binding; variant URLs may use /cdn-cgi/image. Advertised max upload is 70MB.
Features
- JPEG, PNG, GIF, WebP, AVIF upload
- Stored WebP/AVIF for files within the Images
.input()limit (20MB) - Tags, batch tag edits, expiry
- Public
GET /api/randomwithorientation,tags,exclude,format - ZIP upload in the browser (extract, then concurrent
POST /api/upload/single) - Dark mode management UI
Bindings
Configured in wrangler.jsonc:
| Binding | Resource |
|---|---|
R2_BUCKET |
R2 bucket cattopic |
DB |
D1 CattoPic-D1 |
CACHE_KV |
KV cattopic-kv |
DELETE_QUEUE |
Queue cattopic-delete-queue |
IMAGES |
Cloudflare Images |
ASSETS |
Static Assets (the UI) |
USE_QUEUE is true: R2 deletes go through the queue. Forks can set it to false for synchronous deletes.
Commands
Requires Node.js 24+ and pnpm.
git clone https://github.com/Yuri-NagaSaki/CattoPic.git
cd CattoPic
pnpm install
pnpm dev # http://localhost:5173 (UI + API)
pnpm build
pnpm run deploy # vite build && wrangler deploy
pnpm typecheck
pnpm test
Deploy
pnpm wrangler login
pnpm wrangler d1 migrations apply CattoPic-D1 --remote
pnpm wrangler d1 execute CattoPic-D1 --remote --command "
INSERT OR IGNORE INTO api_keys (key, created_at) VALUES ('your-secure-api-key', datetime('now'));
"
pnpm run deploy
Then open https://cattopic-worker.<subdomain>.workers.dev (or your Custom Domain). Paste the API key in the UI.
curl -I "https://cattopic-worker.<subdomain>.workers.dev/api/random"
Enable public access on the R2 bucket and set vars.R2_PUBLIC_URL in wrangler.jsonc.
Forks: copy wrangler.example.jsonc to wrangler.jsonc and fill D1/KV ids.
GitHub Actions uses CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. Config is the committed wrangler.jsonc.
Full steps: DEPLOYMENT.md.
API (short)
Public: GET /api/random (302 to an image URL). Query: orientation, tags, exclude, format. Follow redirects with curl -L if you want the file.
Everything else needs Authorization: Bearer <api-key>.
| Method | Path |
|---|---|
| POST | /api/upload/single |
| GET/PUT/DELETE | /api/images, /api/images/:id |
| GET/POST/PUT/DELETE | /api/tags, /api/tags/:name |
| POST | /api/tags/batch |
Full reference: docs/API_EN.md / docs/API.md.
License
Frequently asked about CattoPic
What is CattoPic?+
CattoPic is a self-hosted Cloudinary/Imgur alternative built on the Cloudflare developer platform. Upload, organize and serve images from R2 with a Cloudflare-hosted gallery.
What does CattoPic replace?+
CattoPic is listed as an alternative to Cloudinary, Imgur. Compare the features and tradeoffs before migrating.
What Cloudflare primitives does CattoPic use?+
CattoPic is built on D1, Images, KV, Queues, R2, Workers.
How much does CattoPic cost to run?+
The documented CattoPic deployment can use Cloudflare Free allowances for a small workload under the request, CPU and service-specific quotas below. This is conditional eligibility, not a measured zero-cost deployment; optional features, domains and external providers can add costs. Workers Free allows 100,000 requests per day shared across the account and 10 ms CPU per invocation; measure CPU-heavy authentication, parsing and rendering before assuming it fits. Keep aggregate D1 use below 5 million rows read/day, 100,000 rows written/day and 5 GB total storage; a request can touch many rows. Keep KV below 100,000 reads/day, 1,000 writes, deletes and list operations/day each, and 1 GB; cache refreshes and backups consume writes. Use R2 Standard storage, at most 10 GB-month, 1 million Class A operations and 10 million Class B operations/month; provision an eligible billing-enabled R2 account. Keep total queue operations within 10,000/day, counting writes, reads, deletes, retries and each 64 KB chunk; Free retention is 24 hours. Store originals in R2 and limit Images to 5,000 unique transformations/month; paid Images storage is excluded. Use a small personal or team workload; domain registration and optional third-party providers are separate costs. Provision your own IDs, secrets and migrations. Check current Cloudflare pricing before deploying.
Is CattoPic open source?+
The upstream repository declares the GPL-3.0 license. Read its terms at https://raw.githubusercontent.com/Yuri-NagaSaki/CattoPic/e50fcb291073fe64df3da149057d6513a778b74e/LICENSE. Source code and contributor credit are available at https://github.com/Yuri-NagaSaki/CattoPic.


Discussion · 0
sign in to comment →