postherousREADME.md41 matches
12- **๐ Email Security**: Allowlist and verification system to prevent unauthorized posts
13- **๐จ Multi-format Support**: HTML and plain text posts
14- **๐ผ๏ธ Image Storage**: Upload images via web interface or email attachments with automatic processing
15- **๐ก RSS Feed**: Full RSS 2.0 support for syndication
16- **๐ WebSub**: Real-time feed updates via WebSub protocol (configured)
58- `ALLOWED_EMAIL_ADDRESSES` - Comma-separated list of allowed email addresses (required for email publishing)
59- `BASE_URL` - Your blog's base URL (e.g., https://myblog.com or just myblog.com) - **Required for custom domains and ActivityPub federation**
60- `UPLOAD_PASSWORD` - Password required for image uploads (required to enable image upload functionality)
6162### Environment Variables (Optional)
129- `GET /` - Main blog interface (with ActivityPub Link header)
130- `GET /post/:slug` - Individual post page (supports content negotiation for ActivityPub)
131- `GET /upload` - Image upload interface โ
132- `GET /images/:filename` - Serve uploaded images โ
133- `GET /rss` - RSS 2.0 feed
134- `GET /api/posts` - JSON API for posts
135- `GET /api/posts/:slug` - JSON API for single post
136- `POST /api/images/upload` - Upload image endpoint โ
137- `GET /api/posts/:slug/images` - Get images for a specific post โ
138- `GET /api/images/user/:email` - Get images uploaded by a user โ
139- `GET /api/images` - Get all images (admin endpoint) โ
140- `DELETE /api/images/:filename` - Delete an image โ
141- `GET /websub` - WebSub subscription endpoint
142- `GET /.well-known/webfinger` - WebFinger discovery for ActivityPub โ
176- **Automatic cleanup**: Expired drafts are automatically removed
177178## ๐ผ๏ธ Image Storage & Management
179180The platform includes comprehensive image storage capabilities for both static assets and email attachments.
181182### Image Upload Methods
1831841. **Web Interface**: Visit `/upload` (requires UPLOAD_PASSWORD) to upload images via drag-and-drop interface
1852. **Email Attachments**: Images attached to blog post emails are automatically processed and stored
186187### Supported Image Formats
188189- JPEG/JPG
193- SVG
194195**File Size Limit**: 5MB per image
196197### Image Storage Features
198199- **Password Protection**: Upload interface protected by UPLOAD_PASSWORD environment variable
200- **Automatic Processing**: Email attachments are automatically extracted and stored
201- **Content Reference Replacement**: Image references in email content are automatically updated to use stored URLs
202- **Metadata Storage**: Original filename, MIME type, file size, alt text, and post associations
203- **Secure Access**: Password-protected upload system prevents unauthorized access
204- **Blob Storage**: Images stored using Val Town's blob storage with unique filenames
205- **URL Generation**: Clean `/images/filename` URLs for serving images
206207### Image API Usage
208209```javascript
210// Upload an image (requires password)
211const formData = new FormData();
212formData.append('image', file);
213formData.append('password', 'your-upload-password');
214formData.append('email', 'your-email@example.com');
215formData.append('altText', 'Description of image');
216formData.append('postSlug', 'my-blog-post'); // optional
217218const response = await fetch('/api/images/upload', {
219method: 'POST',
220body: formData
221});
222223// Get images for a post (no password required)
224const images = await fetch('/api/posts/my-post-slug/images').then(r => r.json());
225226// Get images by user (requires password)
227const userImages = await fetch('/api/images/user/user@example.com?password=your-upload-password').then(r => r.json());
228229// Get all images (requires password)
230const allImages = await fetch('/api/images?password=your-upload-password&limit=50').then(r => r.json());
231```
232233### Email Attachment Processing
234235When you send an email with image attachments:
2362371. **Automatic Detection**: System identifies image attachments by MIME type
2382. **Storage**: Images are stored with unique filenames in blob storage
2393. **Database Records**: Metadata is saved linking images to the post
2404. **Content Updates**: Email content is updated to reference stored image URLs
2415. **Reference Replacement**: Common patterns like `cid:image.jpg` are replaced with proper URLs
242243Example email with attachment:
245To: your-blog@val.town
246From: author@example.com
247Subject: My Post with Images
248Attachments: photo.jpg, diagram.png
249256```html
257<p>Check out this photo:</p>
258<img src="/images/1234567890-abc123-photo.jpg" alt="My photo" />
259<p>And this diagram: </p>
260```
261322- โ **Rich Post Previews**: Posts display with titles, summaries, and full content in Mastodon
323- โ **Content Negotiation**: Individual posts serve both HTML and ActivityPub JSON based on Accept headers
324- โ **Profile Metadata**: Actor profile includes avatar, header image, and custom fields
325- โ **Link Discovery**: Proper HTTP Link headers for ActivityPub discovery
326- โ **Post Permalinks**: Each post has its own ActivityPub Note endpoint
postherousqueries.ts42 matches
1import { sqlite } from "https://esm.town/v/stevekrouse/sqlite";
2import { BlogPost, CreatePostData, DraftPost, CreateDraftPostData, EmailVerification, BlogImage, CreateImageData } from "../../shared/types.ts";
3import { POSTS_TABLE, DRAFT_POSTS_TABLE, EMAIL_VERIFICATIONS_TABLE, SUBSCRIPTIONS_TABLE, FOLLOWERS_TABLE, ACTIVITIES_TABLE, IMAGES_TABLE } from "./schema.ts";
45/**
633}
634635// ===== IMAGE FUNCTIONS =====
636637/**
638* Create a new image record
639*/
640export async function createImage(data: CreateImageData): Promise<BlogImage> {
641const result = await sqlite.execute(`
642INSERT INTO ${IMAGES_TABLE} (
643filename, original_filename, mime_type, file_size, width, height,
644alt_text, post_slug, uploaded_by_email, blob_key
657]);
658659// Get the inserted image
660const image = await getImageByFilename(data.filename);
661if (!image) {
662throw new Error('Failed to create image record');
663}
664
665return image;
666}
667668/**
669* Get an image by filename
670*/
671export async function getImageByFilename(filename: string): Promise<BlogImage | null> {
672const result = await sqlite.execute(`
673SELECT * FROM ${IMAGES_TABLE} WHERE filename = ?
674`, [filename]);
675678}
679680return result.rows[0] as BlogImage;
681}
682683/**
684* Get an image by blob key
685*/
686export async function getImageByBlobKey(blobKey: string): Promise<BlogImage | null> {
687const result = await sqlite.execute(`
688SELECT * FROM ${IMAGES_TABLE} WHERE blob_key = ?
689`, [blobKey]);
690693}
694695return result.rows[0] as BlogImage;
696}
697698/**
699* Get all images for a specific post
700*/
701export async function getImagesByPostSlug(postSlug: string): Promise<BlogImage[]> {
702const result = await sqlite.execute(`
703SELECT * FROM ${IMAGES_TABLE}
704WHERE post_slug = ?
705ORDER BY created_at ASC
706`, [postSlug]);
707708return result.rows as BlogImage[];
709}
710711/**
712* Get all images uploaded by a specific user
713*/
714export async function getImagesByUser(email: string): Promise<BlogImage[]> {
715const result = await sqlite.execute(`
716SELECT * FROM ${IMAGES_TABLE}
717WHERE uploaded_by_email = ?
718ORDER BY created_at DESC
719`, [email]);
720721return result.rows as BlogImage[];
722}
723724/**
725* Get all images (for admin/management purposes)
726*/
727export async function getAllImages(limit: number = 100, offset: number = 0): Promise<BlogImage[]> {
728const result = await sqlite.execute(`
729SELECT * FROM ${IMAGES_TABLE}
730ORDER BY created_at DESC
731LIMIT ? OFFSET ?
732`, [limit, offset]);
733734return result.rows as BlogImage[];
735}
736737/**
738* Update image metadata
739*/
740export async function updateImage(filename: string, updates: Partial<Pick<BlogImage, 'alt_text' | 'post_slug'>>): Promise<BlogImage | null> {
741const setParts: string[] = [];
742const values: any[] = [];
753754if (setParts.length === 0) {
755return getImageByFilename(filename);
756}
757759760await sqlite.execute(`
761UPDATE ${IMAGES_TABLE}
762SET ${setParts.join(', ')}
763WHERE filename = ?
764`, values);
765766return getImageByFilename(filename);
767}
768769/**
770* Delete an image record
771*/
772export async function deleteImage(filename: string): Promise<boolean> {
773const result = await sqlite.execute(`
774DELETE FROM ${IMAGES_TABLE} WHERE filename = ?
775`, [filename]);
776779780/**
781* Generate a unique filename for an image
782*/
783export async function generateUniqueImageFilename(originalFilename: string): Promise<string> {
784const timestamp = Date.now();
785const randomSuffix = Math.random().toString(36).substring(2, 8);
801// Ensure uniqueness
802let counter = 1;
803while (await getImageByFilename(filename)) {
804filename = `${timestamp}-${randomSuffix}-${cleanBaseName}-${counter}${extension}`;
805counter++;
postherousindex.ts100 matches
4deletePostsByAuthorName,
5getActiveFollowers,
6getAllImages,
7getAllPosts,
8getEmailVerificationByToken,
9getFollowerCount,
10getImagesByPostSlug,
11getImagesByUser,
12getPostActivityCounts,
13getPostBySlug,
24import { isEmailAllowed } from "./services/email-security.ts";
25import {
26deleteImageCompletely,
27getImageData,
28getImageUrl,
29storeImageFromFile,
30validateImageFile,
31} from "./services/images.ts";
32import { generateRSSFeed } from "./services/rss.ts";
33import { extractDomain, generateWebFingerResponse, isValidWebFingerResource } from "./services/webfinger.ts";
209
210<!-- Twitter -->
211<meta property="twitter:card" content="summary_large_image">
212<meta property="twitter:url" content="${baseUrl}">
213<meta property="twitter:title" content="Email Blog">
218<link rel="alternate" type="application/activity+json" href="${baseUrl}/actor" />
219<!-- Favicon -->
220<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>๐ง</text></svg>">
221
222<!-- Styles -->
301
302<!-- Twitter -->
303<meta property="twitter:card" content="summary_large_image">
304<meta property="twitter:url" content="${postUrl}">
305<meta property="twitter:title" content="${escapeHtml(post.title)}">
310
311<!-- Favicon -->
312<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>๐ง</text></svg>">
313
314<!-- Styles -->
1250icon: follower.actor_icon_url
1251? {
1252type: "Image",
1253url: follower.actor_icon_url,
1254}
1287icon: follower.actor_icon_url
1288? {
1289type: "Image",
1290url: follower.actor_icon_url,
1291}
1583});
15841585// ===== IMAGE ROUTES =====
15861587// Serve image upload page
1588app.get("/upload", async c => {
1589try {
1602<div class="max-w-md mx-auto bg-white rounded-lg shadow-md p-6 text-center">
1603<h1 class="text-2xl font-bold text-gray-900 mb-4">Upload Disabled</h1>
1604<p class="text-gray-600 mb-4">Image upload is currently disabled.</p>
1605<p class="text-sm text-gray-500">Administrator: Set the UPLOAD_PASSWORD environment variable to enable uploads.</p>
1606<a href="/" class="text-blue-600 hover:text-blue-800 text-sm">โ Back to Blog</a>
1619});
16201621// Serve images
1622app.get("/images/:filename", async c => {
1623try {
1624await ensureDbInitialized();
1625const filename = c.req.param("filename");
16261627const imageData = await getImageData(filename);
1628if (!imageData) {
1629return c.text("Image not found", 404);
1630}
16311632// Return the image data as a proper Response
1633return new Response(imageData.data, {
1634headers: {
1635"Content-Type": imageData.mimeType,
1636"Cache-Control": "public, max-age=31536000", // Cache for 1 year
1637},
1638});
1639} catch (error) {
1640console.error("Error serving image:", error);
1641return c.text("Error serving image", 500);
1642}
1643});
16441645// Upload image endpoint
1646app.post("/api/images/upload", async c => {
1647try {
1648await ensureDbInitialized();
1650// Get the uploaded file and form data
1651const body = await c.req.formData();
1652const file = body.get("image") as File;
1653const password = body.get("password") as string;
1654const uploaderEmail = body.get("email") as string;
16571658if (!file) {
1659return c.json({ error: "No image file provided" }, 400);
1660}
16611676console.log(`โ Valid upload password from ${uploaderEmail}`);
16771678// Store the image
1679const image = await storeImageFromFile(file, uploaderEmail, {
1680altText: altText || undefined,
1681postSlug: postSlug || undefined,
16831684const baseUrl = getBaseUrl(c);
1685const imageUrl = getImageUrl(image.filename, baseUrl);
16861687return c.json({
1688success: true,
1689image: {
1690id: image.id,
1691filename: image.filename,
1692originalFilename: image.original_filename,
1693url: imageUrl,
1694mimeType: image.mime_type,
1695fileSize: image.file_size,
1696altText: image.alt_text,
1697postSlug: image.post_slug,
1698createdAt: image.created_at,
1699},
1700});
1701} catch (error) {
1702console.error("Error uploading image:", error);
1703return c.json({ error: error.message || "Failed to upload image" }, 500);
1704}
1705});
17061707// Get images for a specific post
1708app.get("/api/posts/:slug/images", async c => {
1709try {
1710await ensureDbInitialized();
1711const slug = c.req.param("slug");
17121713const images = await getImagesByPostSlug(slug);
1714const baseUrl = getBaseUrl(c);
17151716return c.json({
1717images: images.map(image => ({
1718id: image.id,
1719filename: image.filename,
1720originalFilename: image.original_filename,
1721url: getImageUrl(image.filename, baseUrl),
1722mimeType: image.mime_type,
1723fileSize: image.file_size,
1724altText: image.alt_text,
1725createdAt: image.created_at,
1726})),
1727});
1728} catch (error) {
1729console.error("Error fetching post images:", error);
1730return c.json({ error: "Failed to fetch images" }, 500);
1731}
1732});
17331734// Get images uploaded by a user
1735app.get("/api/images/user/:email", async c => {
1736try {
1737await ensureDbInitialized();
1744}
17451746const images = await getImagesByUser(email);
1747const baseUrl = getBaseUrl(c);
17481749return c.json({
1750images: images.map(image => ({
1751id: image.id,
1752filename: image.filename,
1753originalFilename: image.original_filename,
1754url: getImageUrl(image.filename, baseUrl),
1755mimeType: image.mime_type,
1756fileSize: image.file_size,
1757altText: image.alt_text,
1758postSlug: image.post_slug,
1759createdAt: image.created_at,
1760})),
1761});
1762} catch (error) {
1763console.error("Error fetching user images:", error);
1764return c.json({ error: "Failed to fetch images" }, 500);
1765}
1766});
17671768// Delete an image
1769app.delete("/api/images/:filename", async c => {
1770try {
1771await ensureDbInitialized();
1777}
17781779const success = await deleteImageCompletely(filename);
17801781if (!success) {
1782return c.json({ error: "Image not found" }, 404);
1783}
17841785return c.json({ success: true, message: "Image deleted successfully" });
1786} catch (error) {
1787console.error("Error deleting image:", error);
1788return c.json({ error: "Failed to delete image" }, 500);
1789}
1790});
17911792// Get all images (admin endpoint)
1793app.get("/api/images", async c => {
1794try {
1795await ensureDbInitialized();
1803const offset = parseInt(c.req.query("offset") || "0");
18041805const images = await getAllImages(limit, offset);
1806const baseUrl = getBaseUrl(c);
18071808return c.json({
1809images: images.map(image => ({
1810id: image.id,
1811filename: image.filename,
1812originalFilename: image.original_filename,
1813url: getImageUrl(image.filename, baseUrl),
1814mimeType: image.mime_type,
1815fileSize: image.file_size,
1816altText: image.alt_text,
1817postSlug: image.post_slug,
1818uploadedByEmail: image.uploaded_by_email,
1819createdAt: image.created_at,
1820})),
1821pagination: {
1822limit,
1823offset,
1824hasMore: images.length === limit,
1825},
1826});
1827} catch (error) {
1828console.error("Error fetching all images:", error);
1829return c.json({ error: "Failed to fetch images" }, 500);
1830}
1831});
postherousimages.ts72 matches
1import { blob } from "https://esm.town/v/std/blob";
2import { createImage, generateUniqueImageFilename, getImageByFilename, deleteImage } from "../database/queries.ts";
3import { BlogImage, CreateImageData, EmailAttachment } from "../../shared/types.ts";
45// Supported image MIME types
6const SUPPORTED_IMAGE_TYPES = [
7'image/jpeg',
8'image/jpg',
9'image/png',
10'image/gif',
11'image/webp',
12'image/svg+xml'
13];
141718/**
19* Check if a MIME type is a supported image format
20*/
21export function isSupportedImageType(mimeType: string): boolean {
22return SUPPORTED_IMAGE_TYPES.includes(mimeType.toLowerCase());
23}
2425/**
26* Validate image file
27*/
28export function validateImageFile(file: { size: number; type: string }): { valid: boolean; error?: string } {
29if (file.size > MAX_FILE_SIZE) {
30return { valid: false, error: `File size too large. Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)}MB` };
31}
3233if (!isSupportedImageType(file.type)) {
34return { valid: false, error: `Unsupported file type. Supported types: ${SUPPORTED_IMAGE_TYPES.join(', ')}` };
35}
363940/**
41* Store an image from a File object (for uploads)
42*/
43export async function storeImageFromFile(
44file: File,
45uploadedByEmail: string,
48postSlug?: string;
49} = {}
50): Promise<BlogImage> {
51// Validate the file
52const validation = validateImageFile(file);
53if (!validation.valid) {
54throw new Error(validation.error);
5657// Generate unique filename
58const filename = await generateUniqueImageFilename(file.name);
59const blobKey = `images/${filename}`;
6061// Read file as array buffer and store in blob
63await blob.set(blobKey, new Uint8Array(arrayBuffer));
6465// Try to get image dimensions (basic approach)
66let width: number | undefined;
67let height: number | undefined;
6869// For now, we'll skip dimension detection as it requires additional libraries
70// In a production environment, you might want to use a library like 'image-size'
7172// Create image record
73const imageData: CreateImageData = {
74filename,
75original_filename: file.name,
84};
8586return await createImage(imageData);
87}
8889/**
90* Store an image from base64 data (for email attachments)
91*/
92export async function storeImageFromBase64(
93base64Data: string,
94originalFilename: string,
99postSlug?: string;
100} = {}
101): Promise<BlogImage> {
102// Convert base64 to Uint8Array
103const binaryString = atob(base64Data);
110111// Validate the file
112const validation = validateImageFile({ size: fileSize, type: mimeType });
113if (!validation.valid) {
114throw new Error(validation.error);
116117// Generate unique filename
118const filename = await generateUniqueImageFilename(originalFilename);
119const blobKey = `images/${filename}`;
120121// Store in blob
122await blob.set(blobKey, bytes);
123124// Create image record
125const imageData: CreateImageData = {
126filename,
127original_filename: originalFilename,
134};
135136return await createImage(imageData);
137}
138139/**
140* Get image data from blob storage
141*/
142export async function getImageData(filename: string): Promise<{ data: Uint8Array; mimeType: string } | null> {
143const imageRecord = await getImageByFilename(filename);
144if (!imageRecord) {
145return null;
146}
147148const blobData = await blob.get(imageRecord.blob_key);
149if (!blobData) {
150return null;
169return {
170data,
171mimeType: imageRecord.mime_type
172};
173}
174175/**
176* Delete an image (both record and blob data)
177*/
178export async function deleteImageCompletely(filename: string): Promise<boolean> {
179const imageRecord = await getImageByFilename(filename);
180if (!imageRecord) {
181return false;
182}
183184// Delete from blob storage
185await blob.delete(imageRecord.blob_key);
186187// Delete from database
188return await deleteImage(filename);
189}
190191/**
192* Process email attachments and extract images
193*/
194export async function processEmailAttachments(
196uploadedByEmail: string,
197postSlug?: string
198): Promise<BlogImage[]> {
199const images: BlogImage[] = [];
200201for (const attachment of attachments) {
202if (isSupportedImageType(attachment.contentType)) {
203try {
204const image = await storeImageFromBase64(
205attachment.content,
206attachment.filename,
209{ postSlug }
210);
211images.push(image);
212console.log(`โ Processed email attachment image: ${attachment.filename} -> ${image.filename}`);
213} catch (error) {
214console.error(`โ Failed to process email attachment ${attachment.filename}:`, error);
217}
218219return images;
220}
221222/**
223* Generate image URL for serving
224*/
225export function getImageUrl(filename: string, baseUrl?: string): string {
226const base = baseUrl || '';
227return `${base}/images/${filename}`;
228}
229230/**
231* Replace image references in content with proper URLs
232*/
233export function replaceImageReferencesInContent(content: string, images: BlogImage[], baseUrl?: string): string {
234let updatedContent = content;
235236for (const image of images) {
237const imageUrl = getImageUrl(image.filename, baseUrl);
238
239// Replace various possible references to the original filename
240const patterns = [
241new RegExp(`cid:${image.original_filename}`, 'gi'),
242new RegExp(`src="${image.original_filename}"`, 'gi'),
243new RegExp(`src='${image.original_filename}'`, 'gi'),
244new RegExp(`\\[${image.original_filename}\\]`, 'gi'),
245new RegExp(`\\(${image.original_filename}\\)`, 'gi')
246];
247248for (const pattern of patterns) {
249if (pattern.source.includes('src=')) {
250updatedContent = updatedContent.replace(pattern, `src="${imageUrl}"`);
251} else if (pattern.source.includes('cid:')) {
252updatedContent = updatedContent.replace(pattern, imageUrl);
253} else {
254updatedContent = updatedContent.replace(pattern, ``);
255}
256}
postherousemail.ts15 matches
4import { CreateDraftPostData, EmailAttachment } from "./shared/types.ts";
5import { isEmailAllowed, generateVerificationToken, sendVerificationEmail, getVerificationExpiration } from "./backend/services/email-security.ts";
6import { processEmailAttachments, replaceImageReferencesInContent } from "./backend/services/images.ts";
78// Helper function to extract email address from display name format
156});
157158// Process email attachments for images
159let processedImages: any[] = [];
160if (email.attachments && email.attachments.length > 0) {
161console.log('๐ Processing email attachments:', {
176}));
177178processedImages = await processEmailAttachments(emailAttachments, authorEmail, slug);
179console.log('โ Processed images from email attachments:', {
180imageCount: processedImages.length,
181images: processedImages.map(img => ({
182filename: img.filename,
183originalFilename: img.original_filename,
186});
187188// Replace image references in content with proper URLs
189if (processedImages.length > 0) {
190const originalContent = content;
191content = replaceImageReferencesInContent(content, processedImages);
192
193if (content !== originalContent) {
194console.log('๐ Updated content with image references');
195}
196}
197} catch (imageError) {
198console.error('โ Error processing email attachments:', {
199error: imageError.message,
200stack: imageError.stack
201});
202// Continue with post creation even if image processing fails
203}
204} else {
postherousauth.ts1 match
1/**
2* Authentication service for image uploads and admin functions
3*/
4
postherousactivitypub.ts8 matches
246// Add avatar/icon for better visual representation
247icon: {
248type: "Image",
249mediaType: "image/png",
250url: `https://posthero.us/images/1752442753496-q8omo6-logo-small.png`,
251},
252// Add header image
253image: {
254type: "Image",
255mediaType: "image/jpeg",
256url: `https://posthero.us/images/1752443393121-bb51qa-logo-header.jpg`,
257},
258// Add some additional fields that Mastodon likes
288element.textContent = newWord;
289const colors = generateColorScheme(newWord);
290element.style.backgroundImage = colors.gradient;
291element.dataset.shadowColor = colors.shadowColor;
292
postherouswebfinger.ts2 matches
119{
120"rel": "http://webfinger.net/rel/avatar",
121"type": "image/png",
122"href": "https://posthero.us/images/1752442753496-q8omo6-logo-small.png",
123},
124],
postherousupload.html116 matches
4<meta charset="UTF-8">
5<meta name="viewport" content="width=device-width, initial-scale=1.0">
6<title>Upload Images - Posterous Blog</title>
7<script src="https://cdn.twind.style" crossorigin></script>
8<style>
12}
13
14.image-preview {
15max-width: 200px;
16max-height: 200px;
27<div class="bg-white rounded-lg shadow-md p-6">
28<div class="mb-6">
29<h1 class="text-3xl font-bold text-gray-900 mb-2">Upload Images</h1>
30<p class="text-gray-600">Upload images to use in your blog posts or as static assets.</p>
31<a href="/" class="text-blue-600 hover:text-blue-800 text-sm">โ Back to Blog</a>
32</div>
40class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
41placeholder="Enter upload password">
42<p class="text-xs text-gray-500 mt-1">Required to upload images (set via UPLOAD_PASSWORD environment variable)</p>
43</div>
4448class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
49placeholder="your-email@example.com">
50<p class="text-xs text-gray-500 mt-1">For tracking who uploaded the image</p>
51</div>
5256class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
57placeholder="my-blog-post">
58<p class="text-xs text-gray-500 mt-1">Associate this image with a specific blog post</p>
59</div>
6063<input type="text" id="altText" name="altText"
64class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
65placeholder="Description of the image">
66<p class="text-xs text-gray-500 mt-1">Helps with accessibility and SEO</p>
67</div>
73<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
74</svg>
75<p class="text-lg text-gray-600 mb-2">Drop images here or click to select</p>
76<p class="text-sm text-gray-500">Supports JPEG, PNG, GIF, WebP, SVG (max 5MB)</p>
77</div>
78<input type="file" id="fileInput" name="image" accept="image/*" class="hidden" multiple>
79</div>
8089<button type="submit" id="uploadBtn"
90class="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed">
91Upload Images
92</button>
93</form>
102</div>
103104<!-- All Uploaded Images -->
105<div id="allImagesSection" class="mt-8">
106<div class="border-t border-gray-200 pt-6">
107<div class="flex items-center justify-between mb-4">
108<h2 class="text-xl font-semibold text-gray-900">๐ All Uploaded Images</h2>
109<button id="refreshImagesBtn"
110class="bg-blue-600 text-white px-3 py-1 rounded text-sm hover:bg-blue-700">
111Refresh
112</button>
113</div>
114<div id="allImagesList" class="space-y-4">
115<div class="text-center py-8 text-gray-500">
116<p>Enter your upload password above to view all uploaded images.</p>
117</div>
118</div>
119<div id="loadMoreImages" class="hidden text-center mt-4">
120<button id="loadMoreBtn"
121class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700">
122Load More Images
123</button>
124</div>
128<!-- URL Preview Info -->
129<div class="mb-6 p-4 bg-blue-50 border border-blue-200 rounded-lg">
130<h3 class="text-sm font-medium text-blue-900 mb-2">๐ Image URL Information</h3>
131<p class="text-sm text-blue-800 mb-2">
132Uploaded images will be accessible at: <code class="bg-blue-100 px-1 rounded">/images/[unique-filename]</code>
133</p>
134<p class="text-xs text-blue-700">
135The system generates unique filenames like: <code class="bg-blue-100 px-1 rounded">1704067200-abc123-my-image.jpg</code>
136</p>
137<div id="urlPreview" class="mt-3 hidden">
138<p class="text-sm font-medium text-blue-900 mb-1">Preview URLs for selected images:</p>
139<div id="urlList" class="space-y-1"></div>
140</div>
141</div>
142143<!-- Image Preview -->
144<div id="imagePreview" class="hidden">
145<h2 class="text-xl font-semibold text-gray-900 mb-4">Selected Images</h2>
146<div id="previewGrid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"></div>
147</div>
153<div class="grid md:grid-cols-2 gap-6">
154<div>
155<h4 class="text-sm font-medium text-gray-800 mb-2">Using Images in Blog Posts</h4>
156<div class="space-y-2 text-sm text-gray-600">
157<div>
158<strong>In HTML emails:</strong>
159<code class="block bg-white p-1 mt-1 rounded text-xs"><img src="/images/filename.jpg" alt="Description" /></code>
160</div>
161<div>
162<strong>In plain text emails:</strong>
163<code class="block bg-white p-1 mt-1 rounded text-xs"></code>
164</div>
165</div>
169<h4 class="text-sm font-medium text-gray-800 mb-2">Email Attachments</h4>
170<div class="space-y-2 text-sm text-gray-600">
171<p>โข Attach images to your blog post emails</p>
172<p>โข System automatically processes and stores them</p>
173<p>โข References like <code class="bg-white px-1 rounded">cid:image.jpg</code> are auto-replaced</p>
174<p>โข Supports JPEG, PNG, GIF, WebP, SVG (max 5MB each)</p>
175</div>
181<div class="grid md:grid-cols-2 gap-4 text-xs text-gray-600">
182<div>
183<code class="bg-white p-1 rounded">GET /images/filename.jpg</code> - Serve image
184</div>
185<div>
186<code class="bg-white p-1 rounded">GET /api/posts/slug/images</code> - Get post images
187</div>
188</div>
201const uploadResults = document.getElementById('uploadResults');
202const resultsList = document.getElementById('resultsList');
203const imagePreview = document.getElementById('imagePreview');
204const previewGrid = document.getElementById('previewGrid');
205const uploadBtn = document.getElementById('uploadBtn');
208209let selectedFiles = [];
210let allImagesOffset = 0;
211const allImagesLimit = 20;
212let hasMoreImages = true;
213214// Generate a preview URL (approximation of what the actual URL will be)
229.replace(/^-|-$/g, '');
230
231return `/images/${timestamp}-${randomSuffix}-${cleanBaseName}${extension}`;
232}
233283e.preventDefault();
284dropZone.classList.remove('drag-over');
285const files = Array.from(e.dataTransfer.files).filter(file => file.type.startsWith('image/'));
286handleFiles(files);
287}
294function handleFiles(files) {
295selectedFiles = files;
296showImagePreview(files);
297updateUrlPreview();
298uploadBtn.disabled = files.length === 0;
299}
300301function showImagePreview(files) {
302if (files.length === 0) {
303imagePreview.classList.add('hidden');
304return;
305}
306307imagePreview.classList.remove('hidden');
308previewGrid.innerHTML = '';
309314previewItem.className = 'relative bg-gray-100 rounded-lg p-2';
315previewItem.innerHTML = `
316<img src="${e.target.result}" alt="${file.name}" class="image-preview w-full h-32 object-cover rounded">
317<p class="text-xs text-gray-600 mt-1 truncate">${file.name}</p>
318<p class="text-xs text-gray-500">${(file.size / 1024).toFixed(1)} KB</p>
330function removeFile(index) {
331selectedFiles.splice(index, 1);
332showImagePreview(selectedFiles);
333updateUrlPreview();
334uploadBtn.disabled = selectedFiles.length === 0;
359}
360361// Load all uploaded images
362async function loadAllImages(reset = false) {
363const password = document.getElementById('password').value;
364if (!password) {
365// Show initial message
366document.getElementById('allImagesList').innerHTML = `
367<div class="text-center py-8 text-gray-500">
368<p>Enter your upload password above to view all uploaded images.</p>
369</div>
370`;
373374if (reset) {
375allImagesOffset = 0;
376hasMoreImages = true;
377document.getElementById('allImagesList').innerHTML = `
378<div class="text-center py-4 text-gray-500">
379<p>Loading images...</p>
380</div>
381`;
383384try {
385const response = await fetch(`/api/images?password=${encodeURIComponent(password)}&limit=${allImagesLimit}&offset=${allImagesOffset}`);
386const data = await response.json();
387388if (!response.ok) {
389if (response.status === 403) {
390document.getElementById('allImagesList').innerHTML = `
391<div class="text-center py-8 text-red-500">
392<p>Invalid password. Please check your upload password.</p>
394`;
395} else {
396document.getElementById('allImagesList').innerHTML = `
397<div class="text-center py-8 text-red-500">
398<p>Error loading images: ${data.error}</p>
399</div>
400`;
403}
404405const allImagesList = document.getElementById('allImagesList');
406
407if (data.images.length === 0 && allImagesOffset === 0) {
408allImagesList.innerHTML = `
409<div class="text-center py-8 text-gray-500">
410<p>No images uploaded yet. Upload some images above to get started!</p>
411</div>
412`;
416// Clear loading message if this is a reset
417if (reset) {
418allImagesList.innerHTML = '';
419}
420421// Add images to the list
422data.images.forEach(image => {
423const imageItem = createImageListItem(image);
424allImagesList.appendChild(imageItem);
425});
426427// Update pagination
428allImagesOffset += data.images.length;
429hasMoreImages = data.pagination.hasMore;
430
431const loadMoreImages = document.getElementById('loadMoreImages');
432if (hasMoreImages) {
433loadMoreImages.classList.remove('hidden');
434} else {
435loadMoreImages.classList.add('hidden');
436}
437438} catch (error) {
439console.error('Error loading images:', error);
440document.getElementById('allImagesList').innerHTML = `
441<div class="text-center py-8 text-red-500">
442<p>Error loading images. Please try again.</p>
443</div>
444`;
446}
447448// Create an image list item
449function createImageListItem(image) {
450const imageItem = document.createElement('div');
451imageItem.className = 'bg-gray-50 border border-gray-200 rounded-lg p-4';
452
453const uploadDate = new Date(image.createdAt).toLocaleDateString('en-US', {
454year: 'numeric',
455month: 'short',
459});
460461imageItem.innerHTML = `
462<div class="flex items-start space-x-4">
463<div class="flex-shrink-0">
464<img src="${image.url}" alt="${image.altText || image.originalFilename}"
465class="w-16 h-16 object-cover rounded border">
466</div>
467<div class="flex-1 min-w-0">
468<div class="flex items-center justify-between mb-2">
469<h3 class="text-sm font-medium text-gray-900 truncate">${image.originalFilename}</h3>
470<span class="text-xs text-gray-500">${(image.fileSize / 1024).toFixed(1)} KB</span>
471</div>
472
473<div class="space-y-2">
474<div>
475<label class="block text-xs font-medium text-gray-700 mb-1">Image URL:</label>
476<div class="flex items-center space-x-2">
477<code class="flex-1 bg-white border border-gray-300 px-2 py-1 rounded text-xs text-gray-900 break-all">${image.url}</code>
478<button onclick="copyToClipboard('${image.url}')"
479class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-2 py-1 rounded text-xs">
480Copy
486<label class="block text-xs font-medium text-gray-700 mb-1">HTML Tag:</label>
487<div class="flex items-center space-x-2">
488<code class="flex-1 bg-white border border-gray-300 px-2 py-1 rounded text-xs text-gray-900 break-all"><img src="${image.url}" alt="${image.altText || image.originalFilename}" /></code>
489<button onclick="copyToClipboard('<img src="${image.url}" alt="${image.altText || image.originalFilename}" />')"
490class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-2 py-1 rounded text-xs">
491Copy
497<label class="block text-xs font-medium text-gray-700 mb-1">Markdown:</label>
498<div class="flex items-center space-x-2">
499<code class="flex-1 bg-white border border-gray-300 px-2 py-1 rounded text-xs text-gray-900 break-all"></code>
500<button onclick="copyToClipboard('')"
501class="bg-gray-100 hover:bg-gray-200 text-gray-800 px-2 py-1 rounded text-xs">
502Copy
509<div class="flex items-center justify-between">
510<div>
511<span>๐ง ${image.uploadedByEmail}</span>
512${image.postSlug ? `<span class="ml-3">๐ ${image.postSlug}</span>` : ''}
513</div>
514<span>๐ ${uploadDate}</span>
519`;
520
521return imageItem;
522}
523524// Event listeners for image management
525document.getElementById('refreshImagesBtn').addEventListener('click', () => {
526loadAllImages(true);
527});
528529document.getElementById('loadMoreBtn').addEventListener('click', () => {
530loadAllImages(false);
531});
532533// Load images when password is entered
534document.getElementById('password').addEventListener('input', (e) => {
535// Debounce the loading
536clearTimeout(window.imageLoadTimeout);
537window.imageLoadTimeout = setTimeout(() => {
538loadAllImages(true);
539}, 500);
540});
545
546if (selectedFiles.length === 0) {
547alert('Please select at least one image to upload.');
548return;
549}
580try {
581const formData = new FormData();
582formData.append('image', file);
583formData.append('password', password);
584formData.append('email', email);
586if (altText) formData.append('altText', altText);
587588const response = await fetch('/api/images/upload', {
589method: 'POST',
590body: formData
622<h3 class="text-sm font-medium text-green-800">${result.file}</h3>
623<div class="mt-2 text-sm text-green-700">
624<p>Successfully uploaded as: <strong>${result.data.image.filename}</strong></p>
625
626<div class="mt-3 space-y-2">
627<div>
628<label class="block text-xs font-medium text-green-800 mb-1">Image URL:</label>
629<div class="flex items-center space-x-2">
630<code class="flex-1 bg-green-50 border border-green-200 px-2 py-1 rounded text-xs text-green-900 break-all">${result.data.image.url}</code>
631<button onclick="copyToClipboard('${result.data.image.url}')"
632class="bg-green-100 hover:bg-green-200 text-green-800 px-2 py-1 rounded text-xs">
633Copy URL
639<label class="block text-xs font-medium text-green-800 mb-1">HTML Tag:</label>
640<div class="flex items-center space-x-2">
641<code class="flex-1 bg-green-50 border border-green-200 px-2 py-1 rounded text-xs text-green-900 break-all"><img src="${result.data.image.url}" alt="${result.data.image.altText || result.data.image.originalFilename}" /></code>
642<button onclick="copyToClipboard('<img src="${result.data.image.url}" alt="${result.data.image.altText || result.data.image.originalFilename}" />')"
643class="bg-green-100 hover:bg-green-200 text-green-800 px-2 py-1 rounded text-xs">
644Copy HTML
650<label class="block text-xs font-medium text-green-800 mb-1">Markdown:</label>
651<div class="flex items-center space-x-2">
652<code class="flex-1 bg-green-50 border border-green-200 px-2 py-1 rounded text-xs text-green-900 break-all"></code>
653<button onclick="copyToClipboard('')"
654class="bg-green-100 hover:bg-green-200 text-green-800 px-2 py-1 rounded text-xs">
655Copy MD
685selectedFiles = [];
686fileInput.value = '';
687imagePreview.classList.add('hidden');
688urlPreview.classList.add('hidden');
689previewGrid.innerHTML = '';
690
691// Refresh the images list to show newly uploaded images
692loadAllImages(true);
693});
694