
Image Upload
- 1 installs
- 6 repo stars
- Updated January 16, 2026
- iamzifei/image-upload-skill
Uploads local images to hosting providers like Catbox, Imgur, and ImgBB and returns shareable URLs in URL, Markdown, HTML, and BBCode formats.
About
Uploads local images to hosting providers (Catbox by default, plus Imgur, ImgBB, Freeimage, and others) and returns shareable links in multiple formats. A developer uses it to get a public URL for a screenshot or diagram to embed in docs or share.
- Uploads to Catbox by default with no config; supports Imgur, ImgBB, and more
- Returns URL, Markdown, HTML, and BBCode formats
Image Upload by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,980 of 2,715 Automation & Workflows skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/iamzifei/image-upload-skill --skill image-uploadAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 6 |
| Last updated | January 16, 2026 |
| Repository | iamzifei/image-upload-skill ↗ |
What it does
Uploads local images to hosting providers like Catbox, Imgur, and ImgBB and returns shareable URLs in URL, Markdown, HTML, and BBCode formats.
Files
Image Upload Skill
Upload local images to various image hosting providers and get shareable URLs in multiple formats.
Quick Start
Just tell Claude to upload an image:
Upload this image: /path/to/screenshot.pngClaude will upload it to the default provider (Catbox) and return the URL.
Usage Examples
Basic Upload
Upload /tmp/screenshot.pngUpload with Custom Name
Upload /path/to/image.jpg as "my-screenshot"Upload to Specific Provider
Upload /path/to/photo.png to imgurGet Specific Format
Upload this image and give me the markdown: /path/to/diagram.pngSupported Providers
| Provider | Max Size | Config Required | Best For |
|---|---|---|---|
| Catbox | 200MB | No | Default, anonymous, permanent |
| ImgBB | 32MB | API Key | Reliable, good API |
| Imgur | 20MB | Client-ID | Popular, widely supported |
| Freeimage | 64MB | API Key | Large files |
| ImgHippo | 50MB | API Key | Alternative option |
| 20MB | Cookies | China users (legacy) |
Configuration
No Configuration Required (Default)
Catbox.moe works out of the box without any API keys!
Optional: Configure Other Providers
Create a .env file in your project or ~/.claude/ directory:
# Use a different default provider
IMAGE_UPLOAD_PROVIDER=imgbb
# Provider API keys
IMGBB_API_KEY=your_key_here
IMGUR_CLIENT_ID=your_client_id
FREEIMAGE_API_KEY=your_key
IMGHIPPO_API_KEY=your_keyOutput Formats
The skill returns URLs in multiple formats:
- URL: Direct link to the image
- Markdown:
for documentation - HTML:
<img src="url">for web pages - BBCode:
[IMG]url[/IMG]for forums
Programmatic Use
import { uploadImage } from 'image-upload';
const result = await uploadImage('/path/to/image.png', {
provider: 'catbox',
name: 'my-image'
});
console.log(result.url); // https://files.catbox.moe/abc123.png
console.log(result.formatted.markdown); // CLI Usage
# Install dependencies
cd image-upload && npm install
# Upload an image
npx tsx src/index.ts /path/to/image.png
# With options
npx tsx src/index.ts /path/to/image.png --provider imgur --name screenshotError Handling
The skill handles common errors gracefully:
- File not found
- File too large for provider
- Unsupported file type
- Network errors
- Authentication failures
Notes
- Catbox is the recommended default - no signup, no limits, permanent hosting
- ImgBB and Imgur have daily rate limits but are more established
- Weibo is legacy and not recommended for new projects
- All uploads are anonymous unless you configure user accounts
# ============================================
# Image Upload Skill Configuration
# ============================================
# Provider selection (catbox, imgbb, imgur, freeimage, imghippo, weibo)
# Default: catbox - works without any configuration
IMAGE_UPLOAD_PROVIDER=catbox
# ============================================
# Catbox.moe (DEFAULT - No config required)
# ============================================
# Catbox works out of the box without any API key!
# Optional: User hash for managing uploaded files (get from catbox.moe account)
CATBOX_USERHASH=
# ============================================
# ImgBB - https://api.imgbb.com/
# ============================================
# Get your free API key at: https://api.imgbb.com/
IMGBB_API_KEY=
# ============================================
# Imgur - https://api.imgur.com/
# ============================================
# Register an application at: https://api.imgur.com/oauth2/addclient
IMGUR_CLIENT_ID=
# ============================================
# Freeimage.host - https://freeimage.host/
# ============================================
# Get your API key at: https://freeimage.host/page/api
FREEIMAGE_API_KEY=
# ============================================
# ImgHippo - https://www.imghippo.com/
# ============================================
# Get your API key at: https://www.imghippo.com/
IMGHIPPO_API_KEY=
# ============================================
# Weibo (Legacy - requires login)
# ============================================
# Export cookies from browser after logging into weibo.com
# Format: SUB=xxx; SUBP=xxx; ...
WEIBO_COOKIES=
# ============================================
# Optional Settings
# ============================================
# Request timeout in milliseconds (default: 30000)
IMAGE_UPLOAD_TIMEOUT=30000
# dependencies
node_modules/
# build output
dist/
# environment
.env
.env.local
# testing
coverage/
*.jpeg
*.jpg
*.png
*.gif
*.webp
# misc
.DS_Store
*.log
# editors
.vscode/
.idea/
MIT License
Copyright (c) 2017 L&H
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:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
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.
{
"name": "image-upload",
"version": "1.0.0",
"description": "Claude Code skill for uploading images to various hosting providers",
"type": "module",
"main": "dist/index.js",
"bin": {
"image-upload": "dist/index.js"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"test": "vitest run",
"test:watch": "vitest"
},
"keywords": [
"image",
"upload",
"hosting",
"catbox",
"imgbb",
"imgur",
"claude",
"skill"
],
"author": "",
"license": "MIT",
"dependencies": {
"dotenv": "^16.3.1",
"form-data": "^4.0.0",
"node-fetch": "^3.3.2"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.6.0",
"typescript": "^5.3.2",
"vitest": "^1.0.0"
},
"engines": {
"node": ">=18.0.0"
}
}
Image Upload Skill

A Claude Code skill for uploading images to various hosting providers and getting shareable URLs.
Features
- Zero Configuration: Works out of the box with Catbox.moe (no API key needed!)
- Multiple Providers: Support for 6 image hosting services
- Multiple Formats: Get URLs in plain, Markdown, HTML, and BBCode formats
- CLI & Programmatic: Use from command line or import as a module
Provider Comparison
| Provider | Max Size | Auth Required | Daily Limit | China Access | Stability |
|---|---|---|---|---|---|
| Catbox | 200MB | None | Unlimited | OK | Good |
| ImgBB | 32MB | API Key | ~Unlimited | Good | Good |
| Imgur | 20MB | Client-ID | ~1,250 | Blocked | Excellent |
| Freeimage | 64MB | API Key | Unknown | Good | Medium |
| ImgHippo | 50MB | API Key | Unknown | Unknown | Medium |
| 20MB | Cookies | Unknown | Excellent | Poor |
Recommendation
- Default: Use Catbox - no configuration, anonymous, permanent storage
- For production: Consider ImgBB with API key for reliability
- In China: Catbox or Weibo (if you have an account)
- Avoid: Imgur is blocked in China
Installation
As a Claude Code Skill
Claude Code skills are installed in the ~/.claude/skills/ directory. Choose one of the following methods:
Method 1: Symlink (Recommended)
This method is recommended for development as changes to the repository are immediately reflected.
# 1. Clone the repository to your preferred location
git clone git@github.com:iamzifei/image-upload-skill.git
cd image-upload-skill
# 2. Install dependencies and build
npm install
npm run build
# 3. Create the skills directory if it doesn't exist
mkdir -p ~/.claude/skills
# 4. Create a symbolic link to the Claude skills folder
ln -s "$(pwd)" ~/.claude/skills/image-upload
# 5. Verify the symlink was created
ls -la ~/.claude/skills/Method 2: Clone Directly into Skills Folder
# 1. Create the skills directory if it doesn't exist
mkdir -p ~/.claude/skills
# 2. Clone directly into the skills folder
git clone git@github.com:iamzifei/image-upload-skill.git ~/.claude/skills/image-upload
# 3. Install dependencies and build
cd ~/.claude/skills/image-upload
npm install
npm run buildMethod 3: Copy Installation
If you don't need git tracking:
# 1. Clone and build the project
git clone git@github.com:iamzifei/image-upload-skill.git
cd image-upload-skill
npm install
npm run build
# 2. Create the skills directory and copy
mkdir -p ~/.claude/skills
cp -r . ~/.claude/skills/image-uploadVerifying Installation
After installation, verify the skill is properly set up:
# Check that SKILL.md exists in the skills folder
cat ~/.claude/skills/image-upload/SKILL.md
# The output should show the skill metadata with name: image-uploadWhen you start a new Claude Code session, the skill should be automatically available.
Configuration (Optional)
Configure providers by creating a .env file in the skill directory or in ~/.claude/:
# Option 1: In the skill directory
cp ~/.claude/skills/image-upload/.env.example ~/.claude/skills/image-upload/.env
# Option 2: In the Claude config directory (applies to all projects)
cp ~/.claude/skills/image-upload/.env.example ~/.claude/.envEdit the .env file to add your API keys:
# Default provider (catbox works without any config!)
IMAGE_UPLOAD_PROVIDER=catbox
# ImgBB - https://api.imgbb.com/
IMGBB_API_KEY=your_key_here
# Imgur - https://api.imgur.com/oauth2/addclient
IMGUR_CLIENT_ID=your_client_idUpdating the Skill
If you used the symlink method:
cd /path/to/your/image-upload-skill
git pull
npm install
npm run buildIf you cloned directly into the skills folder:
cd ~/.claude/skills/image-upload
git pull
npm install
npm run buildUninstalling
# Remove the skill (works for both symlink and direct installation)
rm -rf ~/.claude/skills/image-uploadUsage
With Claude
Just ask Claude to upload an image:
Upload this screenshot: /path/to/image.pngUpload /tmp/photo.jpg to imgur and give me the markdownCLI
# Basic upload (uses Catbox by default)
npx tsx src/index.ts /path/to/image.png
# Upload to specific provider
npx tsx src/index.ts /path/to/image.png --provider imgur
# Upload with custom name
npx tsx src/index.ts /path/to/image.png --name my-screenshot
# List available providers
npx tsx src/index.ts --list
# Help
npx tsx src/index.ts --helpProgrammatic
import { uploadImage, formatResult } from './src/index.js';
// Simple upload
const result = await uploadImage('/path/to/image.png');
console.log(result.url);
// With options
const result = await uploadImage('/path/to/image.png', {
provider: 'imgbb',
name: 'my-image'
});
// Get formatted output
console.log(result.formatted.markdown); // 
console.log(result.formatted.html); // <img src="https://..." alt="my-image">Configuration
Environment Variables
Create a .env file (see .env.example):
# Default provider (catbox works without any config!)
IMAGE_UPLOAD_PROVIDER=catbox
# Optional: Catbox user hash for file management
CATBOX_USERHASH=
# ImgBB - https://api.imgbb.com/
IMGBB_API_KEY=
# Imgur - https://api.imgur.com/oauth2/addclient
IMGUR_CLIENT_ID=
# Freeimage.host - https://freeimage.host/page/api
FREEIMAGE_API_KEY=
# ImgHippo - https://www.imghippo.com/
IMGHIPPO_API_KEY=
# Weibo (legacy) - export cookies from browser
WEIBO_COOKIES=Getting API Keys
| Provider | Where to Get |
|---|---|
| Catbox | No key needed! |
| ImgBB | https://api.imgbb.com/ |
| Imgur | https://api.imgur.com/oauth2/addclient |
| Freeimage | https://freeimage.host/page/api |
| ImgHippo | https://www.imghippo.com/ |
| Export cookies from browser |
Output Format
Successful uploads return:
{
id: "abc123", // Provider-specific ID
url: "https://...", // Direct image URL
viewerUrl: "https://...", // Viewer page (if available)
deleteUrl: "https://...", // Deletion URL (if available)
formatted: {
url: "https://...",
markdown: "",
html: '<img src="https://..." alt="name">',
bbcode: "[IMG]https://...[/IMG]"
}
}Supported File Types
- JPEG (.jpg, .jpeg)
- PNG (.png)
- GIF (.gif)
- WebP (.webp)
- BMP (.bmp)
- ICO (.ico) - Catbox only
Error Handling
The skill provides clear error messages:
- File not found: Check the path
- File too large: Try a different provider with higher limits
- Unsupported type: Convert to a supported format
- Auth failure: Check your API key / credentials
- Network error: Check your connection
Development
# Install dependencies
npm install
# Run in development mode
npm run dev -- /path/to/image.png
# Build
npm run build
# Run tests
npm testRepository
- GitHub: https://github.com/iamzifei/image-upload-skill
License
MIT
Credits
Inspired by Weibo-Picture-Store Chrome extension.
/**
* Configuration loading and management.
* Loads settings from environment variables and .env file.
*/
import * as dotenv from "dotenv";
import * as path from "path";
import * as fs from "fs";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* Skill configuration structure.
*/
export interface SkillConfig {
/** Selected provider name */
provider: string;
/** Request timeout in milliseconds */
timeout: number;
/** Provider-specific configurations */
providers: {
catbox?: { userHash?: string };
imgbb?: { apiKey: string };
imgur?: { clientId: string };
freeimage?: { apiKey: string };
imghippo?: { apiKey: string };
weibo?: { cookies: string };
};
}
/**
* Default provider when none is configured.
* Catbox works without any API key.
*/
const DEFAULT_PROVIDER = "catbox";
const DEFAULT_TIMEOUT = 30000;
/**
* Attempts to load .env file from multiple locations.
*/
function loadEnvFile(): void {
const envPaths = [
// Current working directory
path.join(process.cwd(), ".env"),
// Skill directory
path.join(__dirname, "..", "..", ".env"),
// User's home directory
path.join(process.env.HOME || "", ".claude", ".env"),
];
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
dotenv.config({ path: envPath });
return;
}
}
// No .env file found - that's okay, we have defaults
}
/**
* Loads configuration from environment variables.
*
* @returns The loaded configuration
*/
export function loadConfig(): SkillConfig {
// Load .env file if available
loadEnvFile();
const config: SkillConfig = {
provider: process.env.IMAGE_UPLOAD_PROVIDER || DEFAULT_PROVIDER,
timeout: parseInt(process.env.IMAGE_UPLOAD_TIMEOUT || String(DEFAULT_TIMEOUT), 10),
providers: {},
};
// Catbox (no config required, but user hash is optional)
if (process.env.CATBOX_USERHASH) {
config.providers.catbox = { userHash: process.env.CATBOX_USERHASH };
} else {
config.providers.catbox = {}; // Always available
}
// ImgBB
if (process.env.IMGBB_API_KEY) {
config.providers.imgbb = { apiKey: process.env.IMGBB_API_KEY };
}
// Imgur
if (process.env.IMGUR_CLIENT_ID) {
config.providers.imgur = { clientId: process.env.IMGUR_CLIENT_ID };
}
// Freeimage.host
if (process.env.FREEIMAGE_API_KEY) {
config.providers.freeimage = { apiKey: process.env.FREEIMAGE_API_KEY };
}
// ImgHippo
if (process.env.IMGHIPPO_API_KEY) {
config.providers.imghippo = { apiKey: process.env.IMGHIPPO_API_KEY };
}
// Weibo
if (process.env.WEIBO_COOKIES) {
config.providers.weibo = { cookies: process.env.WEIBO_COOKIES };
}
return config;
}
/**
* Gets the provider configuration for a specific provider.
*
* @param config - The skill configuration
* @param providerName - The provider name
* @returns The provider configuration or undefined
*/
export function getProviderConfig(
config: SkillConfig,
providerName: string
): Record<string, unknown> | undefined {
return config.providers[providerName as keyof typeof config.providers];
}
/**
* Determines the best available provider based on configuration.
*
* @param config - The skill configuration
* @returns The recommended provider name
*/
export function getRecommendedProvider(config: SkillConfig): string {
// If explicitly configured provider has valid config, use it
const explicitProvider = config.provider.toLowerCase();
if (explicitProvider === "catbox") {
return "catbox"; // Always works
}
const providerConfig = getProviderConfig(config, explicitProvider);
if (providerConfig && Object.keys(providerConfig).length > 0) {
return explicitProvider;
}
// Fall back to catbox (no config required)
return "catbox";
}
#!/usr/bin/env node
/**
* Image Upload Skill - CLI Entry Point
*
* Upload images to various hosting providers from the command line.
*
* Usage:
* npx image-upload <image-path> [--provider <name>] [--name <filename>]
*
* Examples:
* npx image-upload ./screenshot.png
* npx image-upload ./photo.jpg --provider imgur
* npx image-upload ./image.png --name my-image
*/
import { uploadImage, formatResult } from "./upload.js";
import { getAvailableProviders, getProviderInfo } from "./providers/index.js";
import { UploadError } from "./utils/errors.js";
/**
* Parse command line arguments.
*/
function parseArgs(args: string[]): {
imagePath?: string;
provider?: string;
name?: string;
help?: boolean;
list?: boolean;
} {
const result: ReturnType<typeof parseArgs> = {};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--help" || arg === "-h") {
result.help = true;
} else if (arg === "--list" || arg === "-l") {
result.list = true;
} else if (arg === "--provider" || arg === "-p") {
result.provider = args[++i];
} else if (arg === "--name" || arg === "-n") {
result.name = args[++i];
} else if (!arg.startsWith("-")) {
result.imagePath = arg;
}
}
return result;
}
/**
* Print help message.
*/
function printHelp(): void {
console.log(`
Image Upload Skill - Upload images to various hosting providers
Usage:
image-upload <image-path> [options]
Options:
-p, --provider <name> Provider to use (default: catbox)
-n, --name <filename> Custom filename for the upload
-l, --list List available providers
-h, --help Show this help message
Examples:
image-upload ./screenshot.png
image-upload ./photo.jpg --provider imgur
image-upload ./image.png --name my-screenshot
Available Providers:
catbox - Catbox.moe (default, no config required, 200MB max)
imgbb - ImgBB (requires API key, 32MB max)
imgur - Imgur (requires Client-ID, 20MB max)
freeimage - Freeimage.host (requires API key, 64MB max)
imghippo - ImgHippo (requires API key, 50MB max)
weibo - Weibo (requires cookies, 20MB max)
Configuration:
Create a .env file with your API keys. See .env.example for details.
Catbox works without any configuration!
`);
}
/**
* Print provider list.
*/
function printProviders(): void {
console.log("\nAvailable Image Hosting Providers:\n");
console.log("| Provider | Max Size | Config Required | Notes |");
console.log("|-------------|----------|-----------------|--------------------------|");
const providers = getProviderInfo();
for (const p of providers) {
const size = `${Math.round(p.maxFileSize / 1024 / 1024)}MB`;
const config = p.requiresConfig ? "Yes" : "No";
const notes = p.name === "catbox" ? "Default, anonymous" : "";
console.log(
`| ${p.displayName.padEnd(11)} | ${size.padEnd(8)} | ${config.padEnd(15)} | ${notes.padEnd(24)} |`
);
}
console.log("\nTo use a provider, set IMAGE_UPLOAD_PROVIDER in your .env file");
console.log("or use the --provider flag.\n");
}
/**
* Main function.
*/
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
printHelp();
process.exit(0);
}
if (args.list) {
printProviders();
process.exit(0);
}
if (!args.imagePath) {
console.error("Error: No image path provided.\n");
printHelp();
process.exit(1);
}
try {
console.log(`Uploading ${args.imagePath}...`);
const result = await uploadImage(args.imagePath, {
provider: args.provider,
name: args.name,
});
console.log("\nUpload successful!\n");
console.log(formatResult(result));
if (result.viewerUrl) {
console.log(`\nViewer: ${result.viewerUrl}`);
}
if (result.deleteUrl) {
console.log(`Delete: ${result.deleteUrl}`);
}
} catch (error) {
if (error instanceof UploadError) {
console.error(`\nError: ${error.toUserMessage()}`);
if (error.fatal) {
console.error("This is a fatal error. Please check your configuration.");
}
} else {
console.error(`\nError: ${(error as Error).message}`);
}
process.exit(1);
}
}
// Run main function
main();
// Export for programmatic use
export { uploadImage, formatResult } from "./upload.js";
export { createProvider, getAvailableProviders, getProviderInfo } from "./providers/index.js";
export type { UploadResult, UploadOptions } from "./upload.js";
/**
* Catbox.moe image hosting provider.
*
* API: POST https://catbox.moe/user/api.php
* Docs: https://catbox.moe/tools.php
*
* Features:
* - No API key required for anonymous uploads
* - Permanent storage (unless content violates TOS)
* - Max file size: 200MB
* - Optional user hash for file management
*/
import type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
import { createFormattedOutput } from "./types.js";
import { fetchWithRetry } from "../utils/fetch.js";
import { UploadError, ErrorCategory } from "../utils/errors.js";
const API_URL = "https://catbox.moe/user/api.php";
const MAX_FILE_SIZE = 200 * 1024 * 1024; // 200MB
export class CatboxProvider implements ImageProvider {
readonly name = "catbox";
readonly displayName = "Catbox.moe";
readonly requiresConfig = false; // Works without any configuration!
readonly maxFileSize = MAX_FILE_SIZE;
readonly supportedTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
"image/x-icon",
];
private userHash?: string;
constructor(config: ProviderConfig) {
// User hash is optional - allows file management if provided
this.userHash = config.userHash as string | undefined;
}
async upload(
buffer: Buffer,
filename?: string,
mimeType?: string
): Promise<UploadResult> {
// Build form data for multipart upload
const formData = new FormData();
formData.append("reqtype", "fileupload");
// Add user hash if available (for file management)
if (this.userHash) {
formData.append("userhash", this.userHash);
}
// Create blob from buffer with proper MIME type
const blob = new Blob([buffer], { type: mimeType || "application/octet-stream" });
// Ensure filename has the correct extension based on MIME type
// This is critical for Catbox to return a URL with extension that browsers can display
const extension = this.getExtension(mimeType);
const baseName = filename || "image";
const uploadFilename = baseName.endsWith(extension) ? baseName : `${baseName}${extension}`;
formData.append("fileToUpload", blob, uploadFilename);
try {
const response = await fetchWithRetry(API_URL, {
method: "POST",
body: formData,
});
// Catbox returns plain text URL on success
const text = await response.text();
// Check for error responses
if (!text.startsWith("https://")) {
throw new UploadError(
text || "Upload failed - no URL returned",
ErrorCategory.API_ERROR,
false
);
}
const url = text.trim();
const id = url.split("/").pop() || "";
const name = filename || id;
return {
id,
url,
formatted: createFormattedOutput(url, name),
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError(
(error as Error).message || "Upload to Catbox failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
private getExtension(mimeType?: string): string {
const mimeToExt: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/x-icon": ".ico",
};
return mimeToExt[mimeType || ""] || ".png";
}
}
/**
* Freeimage.host image hosting provider.
*
* API: POST https://freeimage.host/api/1/upload/
* Docs: https://freeimage.host/page/api
*
* Features:
* - Requires free API key
* - Max file size: 64MB
* - Supports base64 and URL uploads
*/
import type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
import { createFormattedOutput } from "./types.js";
import { fetchWithRetry } from "../utils/fetch.js";
import { UploadError, ErrorCategory, configError } from "../utils/errors.js";
const API_URL = "https://freeimage.host/api/1/upload/";
const MAX_FILE_SIZE = 64 * 1024 * 1024; // 64MB
export class FreeimageProvider implements ImageProvider {
readonly name = "freeimage";
readonly displayName = "Freeimage.host";
readonly requiresConfig = true;
readonly maxFileSize = MAX_FILE_SIZE;
readonly supportedTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
];
private apiKey: string;
constructor(config: ProviderConfig) {
if (!config.apiKey) {
throw configError(
"Freeimage.host",
"API key (get one free at https://freeimage.host/page/api)"
);
}
this.apiKey = config.apiKey;
}
async upload(
buffer: Buffer,
filename?: string,
_mimeType?: string
): Promise<UploadResult> {
// Freeimage accepts base64 encoded images
const base64Data = buffer.toString("base64");
// Build form data
const formData = new FormData();
formData.append("key", this.apiKey);
formData.append("action", "upload");
formData.append("source", base64Data);
formData.append("format", "json");
try {
const response = await fetchWithRetry(API_URL, {
method: "POST",
body: formData,
});
const json = await response.json() as {
status_code: number;
success?: { message: string; code: number };
error?: { message: string; code: number };
image?: {
name: string;
extension: string;
size: number;
width: number;
height: number;
url: string;
url_viewer: string;
delete_url?: string;
};
};
if (json.status_code !== 200 || !json.image) {
throw new UploadError(
json.error?.message || "Upload failed",
ErrorCategory.API_ERROR,
false
);
}
const { image } = json;
const name = filename || image.name || "image";
return {
id: image.name,
url: image.url,
viewerUrl: image.url_viewer,
deleteUrl: image.delete_url,
size: image.size,
width: image.width,
height: image.height,
formatted: createFormattedOutput(image.url, name),
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError(
(error as Error).message || "Upload to Freeimage.host failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
}
/**
* ImgBB image hosting provider.
*
* API: POST https://api.imgbb.com/1/upload
* Docs: https://api.imgbb.com/
*
* Features:
* - Requires free API key
* - Max file size: 32MB
* - Supports base64 and URL uploads
* - Optional auto-deletion after specified time
*/
import type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
import { createFormattedOutput } from "./types.js";
import { fetchWithRetry, buildURL } from "../utils/fetch.js";
import { UploadError, ErrorCategory, configError } from "../utils/errors.js";
const API_URL = "https://api.imgbb.com/1/upload";
const MAX_FILE_SIZE = 32 * 1024 * 1024; // 32MB
export class ImgBBProvider implements ImageProvider {
readonly name = "imgbb";
readonly displayName = "ImgBB";
readonly requiresConfig = true;
readonly maxFileSize = MAX_FILE_SIZE;
readonly supportedTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/bmp",
];
private apiKey: string;
constructor(config: ProviderConfig) {
if (!config.apiKey) {
throw configError("ImgBB", "API key (get one free at https://api.imgbb.com/)");
}
this.apiKey = config.apiKey;
}
async upload(
buffer: Buffer,
filename?: string,
_mimeType?: string
): Promise<UploadResult> {
// ImgBB accepts base64 encoded images
const base64Data = buffer.toString("base64");
// Build form data
const formData = new FormData();
formData.append("key", this.apiKey);
formData.append("image", base64Data);
if (filename) {
formData.append("name", filename);
}
try {
const response = await fetchWithRetry(API_URL, {
method: "POST",
body: formData,
});
const json = await response.json() as {
success: boolean;
data?: {
id: string;
url: string;
url_viewer?: string;
delete_url?: string;
title?: string;
size?: number;
width?: number;
height?: number;
};
error?: { message: string };
};
if (!json.success || !json.data) {
throw new UploadError(
json.error?.message || "Upload failed",
ErrorCategory.API_ERROR,
false
);
}
const { data } = json;
const name = data.title || filename || "image";
return {
id: data.id,
url: data.url,
viewerUrl: data.url_viewer,
deleteUrl: data.delete_url,
size: data.size,
width: data.width,
height: data.height,
formatted: createFormattedOutput(data.url, name),
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError(
(error as Error).message || "Upload to ImgBB failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
}
/**
* ImgHippo image hosting provider.
*
* API: POST https://api.imghippo.com/v1/upload
* Docs: https://www.imghippo.com/
*
* Features:
* - Requires API key
* - Max file size: 50MB
* - May have Cloudflare verification issues
*/
import type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
import { createFormattedOutput } from "./types.js";
import { fetchWithRetry } from "../utils/fetch.js";
import { UploadError, ErrorCategory, configError } from "../utils/errors.js";
const API_URL = "https://api.imghippo.com/v1/upload";
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
export class ImgHippoProvider implements ImageProvider {
readonly name = "imghippo";
readonly displayName = "ImgHippo";
readonly requiresConfig = true;
readonly maxFileSize = MAX_FILE_SIZE;
readonly supportedTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
private apiKey: string;
constructor(config: ProviderConfig) {
if (!config.apiKey) {
throw configError(
"ImgHippo",
"API key (get one at https://www.imghippo.com/)"
);
}
this.apiKey = config.apiKey;
}
async upload(
buffer: Buffer,
filename?: string,
mimeType?: string
): Promise<UploadResult> {
// Build form data for multipart upload
const formData = new FormData();
formData.append("api_key", this.apiKey);
// Create blob from buffer
const blob = new Blob([buffer], {
type: mimeType || "application/octet-stream",
});
// Ensure filename has the correct extension based on MIME type
// This is critical for the URL to display as an image rather than download
const extension = this.getExtension(mimeType);
const baseName = filename || "image";
const uploadFilename = baseName.endsWith(extension) ? baseName : `${baseName}${extension}`;
formData.append("file", blob, uploadFilename);
try {
const response = await fetchWithRetry(API_URL, {
method: "POST",
body: formData,
});
const json = await response.json() as {
success: boolean;
status: number;
message?: string;
data?: {
id: string;
url: string;
view_url?: string;
delete_url?: string;
title?: string;
width?: number;
height?: number;
size?: number;
};
};
if (!json.success || !json.data) {
throw new UploadError(
json.message || "Upload failed",
ErrorCategory.API_ERROR,
false
);
}
const { data } = json;
const name = data.title || filename || "image";
return {
id: data.id,
url: data.url,
viewerUrl: data.view_url,
deleteUrl: data.delete_url,
size: data.size,
width: data.width,
height: data.height,
formatted: createFormattedOutput(data.url, name),
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError(
(error as Error).message || "Upload to ImgHippo failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
private getExtension(mimeType?: string): string {
const mimeToExt: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
};
return mimeToExt[mimeType || ""] || ".png";
}
}
/**
* Imgur image hosting provider.
*
* API: POST https://api.imgur.com/3/image
* Docs: https://apidocs.imgur.com/
*
* Features:
* - Requires Client-ID (free registration)
* - Max file size: 20MB (images), 200MB (GIFs)
* - Anonymous uploads using Client-ID
* - Rate limits: ~1,250 uploads/day
*/
import type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
import { createFormattedOutput } from "./types.js";
import { fetchWithRetry } from "../utils/fetch.js";
import { UploadError, ErrorCategory, configError } from "../utils/errors.js";
const API_URL = "https://api.imgur.com/3/image";
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB for regular images
export class ImgurProvider implements ImageProvider {
readonly name = "imgur";
readonly displayName = "Imgur";
readonly requiresConfig = true;
readonly maxFileSize = MAX_FILE_SIZE;
readonly supportedTypes = [
"image/jpeg",
"image/png",
"image/gif",
"image/apng",
"image/tiff",
];
private clientId: string;
constructor(config: ProviderConfig) {
if (!config.clientId) {
throw configError(
"Imgur",
"Client-ID (register at https://api.imgur.com/oauth2/addclient)"
);
}
this.clientId = config.clientId;
}
async upload(
buffer: Buffer,
filename?: string,
_mimeType?: string
): Promise<UploadResult> {
// Imgur accepts base64 encoded images
const base64Data = buffer.toString("base64");
// Build form data
const formData = new FormData();
formData.append("image", base64Data);
formData.append("type", "base64");
if (filename) {
formData.append("name", filename);
}
try {
const response = await fetchWithRetry(API_URL, {
method: "POST",
headers: {
Authorization: `Client-ID ${this.clientId}`,
},
body: formData,
});
const json = await response.json() as {
success: boolean;
data?: {
id: string;
link: string;
deletehash?: string;
title?: string;
size?: number;
width?: number;
height?: number;
};
status: number;
};
if (!json.success || !json.data) {
throw new UploadError(
`Upload failed with status ${json.status}`,
ErrorCategory.API_ERROR,
false
);
}
const { data } = json;
const name = data.title || filename || "image";
const deleteUrl = data.deletehash
? `https://imgur.com/delete/${data.deletehash}`
: undefined;
return {
id: data.id,
url: data.link,
viewerUrl: `https://imgur.com/${data.id}`,
deleteUrl,
size: data.size,
width: data.width,
height: data.height,
formatted: createFormattedOutput(data.link, name),
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError(
(error as Error).message || "Upload to Imgur failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
}
/**
* Provider registry and factory.
* Manages available image hosting providers.
*/
import type {
ImageProvider,
ProviderConfig,
ProviderConstructor,
} from "./types.js";
import { CatboxProvider } from "./catbox.js";
import { ImgBBProvider } from "./imgbb.js";
import { ImgurProvider } from "./imgur.js";
import { FreeimageProvider } from "./freeimage.js";
import { ImgHippoProvider } from "./imghippo.js";
import { WeiboProvider } from "./weibo.js";
import { UploadError, ErrorCategory } from "../utils/errors.js";
/**
* Registry of all available providers.
*/
const providerRegistry = new Map<string, ProviderConstructor>();
providerRegistry.set("catbox", CatboxProvider);
providerRegistry.set("imgbb", ImgBBProvider);
providerRegistry.set("imgur", ImgurProvider);
providerRegistry.set("freeimage", FreeimageProvider);
providerRegistry.set("imghippo", ImgHippoProvider);
providerRegistry.set("weibo", WeiboProvider);
/**
* Default provider when none is specified.
* Catbox is chosen because it works without any configuration.
*/
export const DEFAULT_PROVIDER = "catbox";
/**
* Gets a list of all available provider names.
*/
export function getAvailableProviders(): string[] {
return Array.from(providerRegistry.keys());
}
/**
* Gets provider information for display.
*/
export function getProviderInfo(): Array<{
name: string;
displayName: string;
requiresConfig: boolean;
maxFileSize: number;
}> {
return [
{
name: "catbox",
displayName: "Catbox.moe",
requiresConfig: false,
maxFileSize: 200 * 1024 * 1024,
},
{
name: "imgbb",
displayName: "ImgBB",
requiresConfig: true,
maxFileSize: 32 * 1024 * 1024,
},
{
name: "imgur",
displayName: "Imgur",
requiresConfig: true,
maxFileSize: 20 * 1024 * 1024,
},
{
name: "freeimage",
displayName: "Freeimage.host",
requiresConfig: true,
maxFileSize: 64 * 1024 * 1024,
},
{
name: "imghippo",
displayName: "ImgHippo",
requiresConfig: true,
maxFileSize: 50 * 1024 * 1024,
},
{
name: "weibo",
displayName: "Weibo (Legacy)",
requiresConfig: true,
maxFileSize: 20 * 1024 * 1024,
},
];
}
/**
* Creates a provider instance.
*
* @param name - The provider name (e.g., 'catbox', 'imgbb')
* @param config - Provider-specific configuration
* @returns An initialized provider instance
* @throws UploadError if provider is not found
*/
export function createProvider(
name: string,
config: ProviderConfig = {}
): ImageProvider {
const normalizedName = name.toLowerCase();
const ProviderClass = providerRegistry.get(normalizedName);
if (!ProviderClass) {
throw new UploadError(
`Unknown provider: ${name}. Available providers: ${getAvailableProviders().join(", ")}`,
ErrorCategory.CONFIG_ERROR,
true
);
}
return new ProviderClass(config);
}
/**
* Checks if a provider name is valid.
*/
export function isValidProvider(name: string): boolean {
return providerRegistry.has(name.toLowerCase());
}
// Re-export types
export type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
/**
* Type definitions for image hosting providers.
*/
/**
* Result of a successful image upload.
* Includes the raw URL and pre-formatted output strings.
*/
export interface UploadResult {
/** The unique identifier from the provider */
id: string;
/** The direct URL to the uploaded image */
url: string;
/** URL to the viewer/landing page (if available) */
viewerUrl?: string;
/** URL for deletion (if available) */
deleteUrl?: string;
/** File size in bytes */
size?: number;
/** Image width in pixels */
width?: number;
/** Image height in pixels */
height?: number;
/** Pre-formatted output strings */
formatted: {
url: string;
markdown: string;
html: string;
bbcode: string;
};
}
/**
* Configuration for a provider.
* Each provider implementation may require different fields.
*/
export interface ProviderConfig {
apiKey?: string;
clientId?: string;
userHash?: string;
cookies?: string;
[key: string]: unknown;
}
/**
* Abstract interface for all image hosting providers.
* Implementations must handle their own authentication and API specifics.
*/
export interface ImageProvider {
/** Unique identifier for this provider (lowercase) */
readonly name: string;
/** Human-readable display name */
readonly displayName: string;
/** Whether this provider requires configuration (API key, etc.) */
readonly requiresConfig: boolean;
/** Maximum file size in bytes */
readonly maxFileSize: number;
/** Supported MIME types */
readonly supportedTypes: string[];
/**
* Uploads an image to the provider.
*
* @param buffer - The image data as a Buffer
* @param filename - Optional filename for the upload
* @param mimeType - The MIME type of the image
* @returns Promise resolving to the upload result
* @throws UploadError on upload failure
*/
upload(
buffer: Buffer,
filename?: string,
mimeType?: string
): Promise<UploadResult>;
}
/**
* Provider constructor type.
*/
export type ProviderConstructor = new (config: ProviderConfig) => ImageProvider;
/**
* Helper to create formatted output strings.
*/
export function createFormattedOutput(
url: string,
name: string = "image"
): UploadResult["formatted"] {
return {
url: url,
markdown: ``,
html: `<img src="${url}" alt="${name}">`,
bbcode: `[IMG]${url}[/IMG]`,
};
}
/**
* Weibo image hosting provider (Legacy).
*
* API: POST https://picupload.weibo.com/interface/pic_upload.php
*
* Features:
* - Requires Weibo login cookies
* - Max file size: 20MB
* - Fast CDN in China, slow internationally
* - Risk of external links being blocked
*
* Note: This is a legacy provider preserved from the original extension.
* Not recommended for production use due to stability concerns.
*/
import type { ImageProvider, ProviderConfig, UploadResult } from "./types.js";
import { createFormattedOutput } from "./types.js";
import { fetchWithRetry, buildURL } from "../utils/fetch.js";
import { UploadError, ErrorCategory, configError } from "../utils/errors.js";
const API_URL = "https://picupload.weibo.com/interface/pic_upload.php";
const MAX_FILE_SIZE = 20 * 1024 * 1024 - 1; // ~20MB
// Weibo CDN hosts for image URLs
const IMAGE_HOSTS = [
"tvax1.sinaimg.cn",
"tvax2.sinaimg.cn",
"tvax3.sinaimg.cn",
"tvax4.sinaimg.cn",
"tva1.sinaimg.cn",
"tva2.sinaimg.cn",
"tva3.sinaimg.cn",
"tva4.sinaimg.cn",
];
export class WeiboProvider implements ImageProvider {
readonly name = "weibo";
readonly displayName = "Weibo (Legacy)";
readonly requiresConfig = true;
readonly maxFileSize = MAX_FILE_SIZE;
readonly supportedTypes = [
"image/jpeg",
"image/png",
"image/apng",
"image/gif",
];
private cookies: string;
constructor(config: ProviderConfig) {
if (!config.cookies) {
throw configError(
"Weibo",
"cookies (export from browser after logging into weibo.com)"
);
}
this.cookies = config.cookies;
}
async upload(
buffer: Buffer,
filename?: string,
mimeType?: string
): Promise<UploadResult> {
// Build query parameters
const params = {
s: "xml",
ori: "1",
data: "1",
rotate: "0",
wm: "",
app: "miniblog",
mime: mimeType || "image/jpeg",
};
const url = buildURL(API_URL, params);
try {
const response = await fetchWithRetry(url, {
method: "POST",
headers: {
Cookie: this.cookies,
Referer: "https://weibo.com/",
},
body: buffer,
});
const text = await response.text();
// Parse XML response to extract PID
const pidMatch = text.match(/<pid>([^<]+)<\/pid>/);
if (!pidMatch) {
// Check for login required error
if (text.includes("login") || text.includes("请登录")) {
throw new UploadError(
"Weibo session expired. Please update your cookies.",
ErrorCategory.AUTH_FAILURE,
true
);
}
throw new UploadError(
"Failed to parse Weibo response - no PID found",
ErrorCategory.INVALID_RESPONSE,
false
);
}
const pid = pidMatch[1];
const host = IMAGE_HOSTS[Math.floor(Math.random() * IMAGE_HOSTS.length)];
const ext = this.getExtension(mimeType);
const imageUrl = `https://${host}/large/${pid}${ext}`;
const name = filename || pid;
return {
id: pid,
url: imageUrl,
formatted: createFormattedOutput(imageUrl, name),
};
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
throw new UploadError(
(error as Error).message || "Upload to Weibo failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
private getExtension(mimeType?: string): string {
const mimeToExt: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/apng": ".png",
"image/gif": ".gif",
};
return mimeToExt[mimeType || ""] || ".jpg";
}
}
/**
* Core upload logic following the reader -> purifier -> uploader pipeline.
* Adapted from Weibo-Picture-Store src/scripts/weibo/upload.ts
*/
import * as fs from "fs/promises";
import * as path from "path";
import { createProvider, type UploadResult } from "./providers/index.js";
import { loadConfig, getProviderConfig, getRecommendedProvider } from "./config/index.js";
import { detectMimeType } from "./utils/mime.js";
import {
UploadError,
ErrorCategory,
fileSizeError,
fileTypeError,
} from "./utils/errors.js";
/**
* Upload options.
*/
export interface UploadOptions {
/** Provider name (defaults to configured or catbox) */
provider?: string;
/** Custom filename for the upload */
name?: string;
}
/**
* Main upload function - the primary entry point for the skill.
*
* Pipeline: reader -> purifier -> uploader
*
* @param imagePath - Path to the local image file
* @param options - Upload options
* @returns Upload result with URL and formatted strings
*/
export async function uploadImage(
imagePath: string,
options: UploadOptions = {}
): Promise<UploadResult> {
const config = loadConfig();
const providerName = options.provider || getRecommendedProvider(config);
const providerConfig = getProviderConfig(config, providerName) || {};
// Create provider instance
const provider = createProvider(providerName, providerConfig);
// Step 1: Reader - Read the image file
const absolutePath = path.resolve(imagePath);
const buffer = await reader(absolutePath);
// Step 2: Detect MIME type
const mimeType = detectMimeType(buffer);
// Step 3: Purifier - Validate file against provider constraints
await purifier(buffer, mimeType, provider.maxFileSize, provider.supportedTypes, provider.displayName);
// Step 4: Uploader - Upload to the provider
const filename = options.name || path.basename(imagePath, path.extname(imagePath));
const result = await provider.upload(buffer, filename, mimeType);
return result;
}
/**
* Reader phase: Read file from disk.
*
* @param filePath - Absolute path to the file
* @returns File contents as Buffer
*/
async function reader(filePath: string): Promise<Buffer> {
try {
const stats = await fs.stat(filePath);
if (!stats.isFile()) {
throw new UploadError(
`Path is not a file: ${filePath}`,
ErrorCategory.FILE_NOT_FOUND,
false
);
}
return await fs.readFile(filePath);
} catch (error) {
if (error instanceof UploadError) {
throw error;
}
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === "ENOENT") {
throw new UploadError(
`File not found: ${filePath}`,
ErrorCategory.FILE_NOT_FOUND,
false
);
}
throw new UploadError(
`Failed to read file: ${nodeError.message}`,
ErrorCategory.FILE_NOT_FOUND,
false,
error as Error
);
}
}
/**
* Purifier phase: Validate file against provider constraints.
*
* @param buffer - File contents
* @param mimeType - Detected MIME type
* @param maxSize - Provider's max file size
* @param supportedTypes - Provider's supported MIME types
* @param providerName - Provider display name for error messages
*/
async function purifier(
buffer: Buffer,
mimeType: string,
maxSize: number,
supportedTypes: string[],
providerName: string
): Promise<void> {
// Check file type support
if (!mimeType || !supportedTypes.includes(mimeType)) {
throw fileTypeError(mimeType, supportedTypes, providerName);
}
// Check file size
if (buffer.length > maxSize) {
throw fileSizeError(buffer.length, maxSize, providerName);
}
}
/**
* Format the upload result for display.
*
* @param result - Upload result
* @param format - Output format (url, markdown, html, bbcode, all)
* @returns Formatted string
*/
export function formatResult(
result: UploadResult,
format: "url" | "markdown" | "html" | "bbcode" | "all" = "all"
): string {
switch (format) {
case "url":
return result.url;
case "markdown":
return result.formatted.markdown;
case "html":
return result.formatted.html;
case "bbcode":
return result.formatted.bbcode;
case "all":
default:
return [
`URL: ${result.url}`,
`Markdown: ${result.formatted.markdown}`,
`HTML: ${result.formatted.html}`,
`BBCode: ${result.formatted.bbcode}`,
].join("\n");
}
}
// Re-export types
export type { UploadResult } from "./providers/index.js";
/**
* Error types and utilities for the image upload skill.
* Categorizes errors as fatal (stop processing) or non-fatal (skip and continue).
*/
/**
* Error categories for upload operations.
*/
export enum ErrorCategory {
/** File too large - non-fatal, skip this file */
FILE_SIZE_OVERFLOW = "FILE_SIZE_OVERFLOW",
/** Unsupported file type - non-fatal, skip this file */
FILE_TYPE_RESTRICT = "FILE_TYPE_RESTRICT",
/** File not found or cannot be read - non-fatal */
FILE_NOT_FOUND = "FILE_NOT_FOUND",
/** Authentication failure - fatal, stop queue */
AUTH_FAILURE = "AUTH_FAILURE",
/** Network error - could be transient, retry */
NETWORK_ERROR = "NETWORK_ERROR",
/** API error - check response for details */
API_ERROR = "API_ERROR",
/** Invalid response data - fatal */
INVALID_RESPONSE = "INVALID_RESPONSE",
/** Provider not configured - fatal */
CONFIG_ERROR = "CONFIG_ERROR",
}
/**
* Custom error class for upload operations.
* Includes categorization for error handling decisions.
*/
export class UploadError extends Error {
public readonly category: ErrorCategory;
public readonly fatal: boolean;
public readonly cause?: Error;
constructor(
message: string,
category: ErrorCategory,
fatal: boolean = false,
cause?: Error
) {
super(message);
this.name = "UploadError";
this.category = category;
this.fatal = fatal;
this.cause = cause;
// Maintains proper stack trace for where our error was thrown (only available on V8)
if (Error.captureStackTrace) {
Error.captureStackTrace(this, UploadError);
}
}
/**
* Returns a user-friendly error message.
*/
toUserMessage(): string {
switch (this.category) {
case ErrorCategory.FILE_SIZE_OVERFLOW:
return `File too large: ${this.message}`;
case ErrorCategory.FILE_TYPE_RESTRICT:
return `Unsupported file type: ${this.message}`;
case ErrorCategory.FILE_NOT_FOUND:
return `File not found: ${this.message}`;
case ErrorCategory.AUTH_FAILURE:
return `Authentication failed: ${this.message}`;
case ErrorCategory.NETWORK_ERROR:
return `Network error: ${this.message}`;
case ErrorCategory.API_ERROR:
return `API error: ${this.message}`;
case ErrorCategory.INVALID_RESPONSE:
return `Invalid response: ${this.message}`;
case ErrorCategory.CONFIG_ERROR:
return `Configuration error: ${this.message}`;
default:
return this.message;
}
}
}
/**
* Creates a file size overflow error.
*/
export function fileSizeError(
actualSize: number,
maxSize: number,
provider: string
): UploadError {
const actualMB = (actualSize / 1024 / 1024).toFixed(2);
const maxMB = (maxSize / 1024 / 1024).toFixed(0);
return new UploadError(
`File size (${actualMB}MB) exceeds ${provider} limit (${maxMB}MB)`,
ErrorCategory.FILE_SIZE_OVERFLOW,
false
);
}
/**
* Creates a file type restriction error.
*/
export function fileTypeError(
mimeType: string,
supportedTypes: string[],
provider: string
): UploadError {
return new UploadError(
`File type '${mimeType || "unknown"}' is not supported by ${provider}. Supported: ${supportedTypes.join(", ")}`,
ErrorCategory.FILE_TYPE_RESTRICT,
false
);
}
/**
* Creates an authentication error.
*/
export function authError(provider: string, details?: string): UploadError {
const message = details
? `${provider} authentication failed: ${details}`
: `${provider} requires authentication. Please configure API key or credentials.`;
return new UploadError(message, ErrorCategory.AUTH_FAILURE, true);
}
/**
* Creates a configuration error.
*/
export function configError(provider: string, missing: string): UploadError {
return new UploadError(
`${provider} requires ${missing}. Please check your .env configuration.`,
ErrorCategory.CONFIG_ERROR,
true
);
}
/**
* HTTP fetch wrapper with timeout and retry support.
* Adapted from Weibo-Picture-Store src/scripts/sharre/utils.ts
*/
import { UploadError, ErrorCategory } from "./errors.js";
export interface FetchOptions extends RequestInit {
timeout?: number;
}
/**
* Sleep for a specified number of milliseconds.
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Enhanced fetch wrapper with timeout, retry, and error handling.
*
* @param url - The URL to fetch
* @param options - Fetch options including optional timeout
* @param retries - Number of retry attempts for transient errors
* @returns The fetch Response
* @throws UploadError on failure
*/
export async function fetchWithRetry(
url: string,
options: FetchOptions = {},
retries: number = 2
): Promise<Response> {
const { timeout = 30000, ...fetchOptions } = options;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {
...fetchOptions,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
// Don't retry client errors (4xx)
const shouldRetry = response.status >= 500 && retries > 0;
if (shouldRetry) {
await sleep(1000);
return fetchWithRetry(url, options, retries - 1);
}
throw new UploadError(
`HTTP ${response.status}: ${response.statusText}`,
ErrorCategory.NETWORK_ERROR,
false
);
}
return response;
} catch (error) {
clearTimeout(timeoutId);
// Re-throw UploadError as-is
if (error instanceof UploadError) {
if (retries > 0 && !error.fatal) {
await sleep(1000);
return fetchWithRetry(url, options, retries - 1);
}
throw error;
}
// Handle abort (timeout)
if ((error as Error).name === "AbortError") {
if (retries > 0) {
await sleep(1000);
return fetchWithRetry(url, options, retries - 1);
}
throw new UploadError(
`Request timed out after ${timeout}ms`,
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
// Handle other network errors
if (retries > 0) {
await sleep(1000);
return fetchWithRetry(url, options, retries - 1);
}
throw new UploadError(
(error as Error).message || "Network request failed",
ErrorCategory.NETWORK_ERROR,
false,
error as Error
);
}
}
/**
* Build a URL with query parameters.
*
* @param baseUrl - The base URL
* @param params - Query parameters as key-value pairs
* @returns The complete URL with query string
*/
export function buildURL(
baseUrl: string,
params: Record<string, string | number | boolean | undefined>
): string {
const url = new URL(baseUrl);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
/**
* MIME type detection using magic bytes.
* Adapted from Weibo-Picture-Store src/scripts/sharre/bitmap-mime.ts
*/
interface BitmapPatternItem {
pattern: number[];
mask: number[];
ignored: number[];
type: string;
note?: string;
}
const UNKNOWN_BITMAP_MIME = "";
/**
* Magic byte patterns for detecting image types.
* Based on WHATWG MIME Sniffing specification.
*/
const BITMAP_PATTERN_TABLE: BitmapPatternItem[] = [
{
pattern: [0x00, 0x00, 0x01, 0x00],
mask: [0xff, 0xff, 0xff, 0xff],
ignored: [],
type: "image/x-icon",
note: "A Windows Icon signature.",
},
{
pattern: [0x00, 0x00, 0x02, 0x00],
mask: [0xff, 0xff, 0xff, 0xff],
ignored: [],
type: "image/x-icon",
note: "A Windows Cursor signature.",
},
{
pattern: [0x42, 0x4d],
mask: [0xff, 0xff],
ignored: [],
type: "image/bmp",
note: "The string 'BM', a BMP signature.",
},
{
pattern: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61],
mask: [0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
ignored: [],
type: "image/gif",
note: "The string 'GIF87a', a GIF signature.",
},
{
pattern: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61],
mask: [0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
ignored: [],
type: "image/gif",
note: "The string 'GIF89a', a GIF signature.",
},
{
pattern: [
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50,
0x56, 0x50,
],
mask: [
0xff, 0xff, 0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff,
0xff, 0xff,
],
ignored: [],
type: "image/webp",
note: "The string 'RIFF' followed by four bytes followed by the string 'WEBPVP'.",
},
{
pattern: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a],
mask: [0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff],
ignored: [],
type: "image/png",
note: "An error-checking byte followed by the string 'PNG' followed by CR LF SUB LF, the PNG signature.",
},
{
pattern: [0xff, 0xd8, 0xff],
mask: [0xff, 0xff, 0xff],
ignored: [],
type: "image/jpeg",
note: "The JPEG Start of Image marker followed by the indicator byte of another marker.",
},
];
/**
* Matches buffer against a single pattern item.
*/
function isPatternMatch(buffer: Buffer, item: BitmapPatternItem): boolean {
const { pattern, mask, ignored } = item;
if (buffer.length < pattern.length) {
return false;
}
let s = 0;
// Skip ignored bytes at the beginning
while (s < buffer.length) {
if (!ignored.includes(buffer[s])) {
break;
}
s++;
}
// Match pattern with mask
let p = 0;
while (p < pattern.length) {
const maskedData = buffer[s] & mask[p];
if (maskedData !== pattern[p]) {
return false;
}
s++;
p++;
}
return true;
}
/**
* Detects the MIME type of an image from its buffer using magic bytes.
*
* @param buffer - The image file buffer
* @returns The detected MIME type, or empty string if unknown
*/
export function detectMimeType(buffer: Buffer): string {
for (const item of BITMAP_PATTERN_TABLE) {
if (isPatternMatch(buffer, item)) {
return item.type;
}
}
return UNKNOWN_BITMAP_MIME;
}
/**
* Gets file extension for a MIME type.
*
* @param mimeType - The MIME type
* @returns The file extension (with dot), or empty string if unknown
*/
export function getExtensionForMime(mimeType: string): string {
const mimeToExt: Record<string, string> = {
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/x-icon": ".ico",
};
return mimeToExt[mimeType] || "";
}
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
}