├── index.html └── main.ts /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Imger [BETA] 5 | 6 | 161 | 162 | 163 |
164 |
165 |
166 |

Imger

167 |

Simple, login-free image hosting

168 |
169 | 170 |
171 |
172 | 173 |

Select or Drag & Drop Files Here

174 |

or drag and drop multiple files

175 |
176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 |
191 | 192 |
193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 |
208 |
209 |
210 | 211 | 537 | 538 | -------------------------------------------------------------------------------- /main.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "https://deno.land/std@0.224.0/http/server.ts"; 2 | import { serveFile } from "https://deno.land/std@0.224.0/http/file_server.ts"; 3 | import { crypto } from "https://deno.land/std@0.224.0/crypto/mod.ts"; 4 | 5 | const KV_CHUNK_SIZE = 50 * 1024; // 50KB, slightly less than 55KB limit 6 | const kv = await Deno.openKv(); 7 | 8 | // Simple in-memory cache (for demonstration) 9 | // In a real-world scenario, consider a more robust LRU cache 10 | const cache = new Map(); 11 | const CACHE_TTL = 60 * 1000; // Cache entries expire after 60 seconds 12 | 13 | interface ImageMeta { 14 | name: string; 15 | type: string; 16 | size: number; 17 | chunks: number; 18 | uploadedAt: number; 19 | md5: string; // Add MD5 hash to metadata 20 | completed: boolean; // Add completion flag 21 | } 22 | 23 | // Function to calculate MD5 hash of a Uint8Array 24 | async function calculateMd5(data: Uint8Array): Promise { 25 | const hashBuffer = await crypto.subtle.digest("MD5", data); 26 | const hashArray = Array.from(new Uint8Array(hashBuffer)); 27 | const md5Hash = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); 28 | return md5Hash; 29 | } 30 | 31 | 32 | // CORS headers helper function 33 | function getCorsHeaders(): HeadersInit { 34 | return { 35 | "Access-Control-Allow-Origin": "*", 36 | "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", 37 | "Access-Control-Allow-Headers": "Content-Type, Authorization", 38 | "Access-Control-Max-Age": "86400", 39 | }; 40 | } 41 | 42 | async function uploadHandler(request: Request): Promise { 43 | try { 44 | const formData = await request.formData(); 45 | const files = formData.getAll("file"); // Get all entries with the name "file" 46 | 47 | if (!files || files.length === 0) { 48 | return new Response("No files uploaded", { 49 | status: 400, 50 | headers: getCorsHeaders() 51 | }); 52 | } 53 | 54 | const results: { name: string; url?: string; error?: string; status: string }[] = []; 55 | const origin = new URL(request.url).origin; 56 | 57 | for (const file of files) { 58 | if (typeof file === "string") { 59 | results.push({ name: "unknown", error: "Invalid file data", status: "error" }); 60 | continue; 61 | } 62 | 63 | try { 64 | const imageBytes = new Uint8Array(await file.arrayBuffer()); 65 | const md5Hash = await calculateMd5(imageBytes); 66 | 67 | // Check if image with this MD5 hash already exists 68 | const existingImageEntry = await kv.get(["md5_to_id", md5Hash]); 69 | 70 | if (existingImageEntry.value) { 71 | // MD5 exists, check if the image is completed 72 | const existingImageId = existingImageEntry.value; 73 | const existingMetaEntry = await kv.get(["images", existingImageId, "meta"]); 74 | 75 | if (existingMetaEntry.value && existingMetaEntry.value.completed) { 76 | // Image is completed, return existing URL 77 | const imageUrl = `${origin}/image/${existingImageId}`; 78 | console.log(`File ${file.name} (MD5: ${md5Hash}) already exists and is completed. Returning existing URL.`); 79 | results.push({ name: file.name, url: imageUrl, status: "cached" }); 80 | } else { 81 | // Image exists but is not completed, or metadata is missing. 82 | // Proceed with storing the new upload, potentially overwriting incomplete data. 83 | console.log(`File ${file.name} (MD5: ${md5Hash}) exists but is not completed. Overwriting.`); 84 | const imageId = existingImageId; // Use the existing ID 85 | 86 | const imageMeta: ImageMeta = { 87 | name: file.name, 88 | type: file.type, 89 | size: imageBytes.byteLength, 90 | chunks: Math.ceil(imageBytes.byteLength / KV_CHUNK_SIZE), 91 | uploadedAt: Date.now(), 92 | md5: md5Hash, // Store MD5 hash in metadata 93 | completed: false, // Initialize completion flag to false 94 | }; 95 | 96 | const kvOperations = kv.atomic(); 97 | 98 | // Store metadata (will overwrite if exists) 99 | kvOperations.set(["images", imageId, "meta"], imageMeta); 100 | 101 | // MD5 to ID mapping already exists, no need to set again in atomic op 102 | 103 | await kvOperations.commit(); 104 | 105 | // Store chunks individually (will overwrite if exists) 106 | for (let i = 0; i < imageMeta.chunks; i++) { 107 | const start = i * KV_CHUNK_SIZE; 108 | const end = Math.min(start + KV_CHUNK_SIZE, imageBytes.byteLength); 109 | const chunk = imageBytes.slice(start, end); 110 | await kv.set(["images", imageId, "chunk", i], chunk); 111 | } 112 | 113 | // Mark image as completed after all chunks are stored 114 | await kv.set(["images", imageId, "meta"], { ...imageMeta, completed: true }); 115 | 116 | // Extract file extension from original name 117 | const fileNameParts = file.name.split('.'); 118 | const fileExtension = fileNameParts.length > 1 ? fileNameParts.pop() : ''; 119 | const imageUrl = `${origin}/image/${imageId}${fileExtension ? '.' + fileExtension : ''}`; 120 | results.push({ name: file.name, url: imageUrl, status: "overwritten" }); 121 | } 122 | } else { 123 | // Image does not exist, store it 124 | const imageId = crypto.randomUUID(); 125 | const imageMeta: ImageMeta = { 126 | name: file.name, 127 | type: file.type, 128 | size: imageBytes.byteLength, 129 | chunks: Math.ceil(imageBytes.byteLength / KV_CHUNK_SIZE), 130 | uploadedAt: Date.now(), 131 | md5: md5Hash, // Store MD5 hash in metadata 132 | completed: false, // Initialize completion flag to false 133 | }; 134 | 135 | const kvOperations = kv.atomic(); 136 | 137 | // Store metadata 138 | kvOperations.set(["images", imageId, "meta"], imageMeta); 139 | 140 | // Store MD5 to ID mapping 141 | kvOperations.set(["md5_to_id", md5Hash], imageId); 142 | 143 | await kvOperations.commit(); 144 | 145 | // Store chunks individually 146 | for (let i = 0; i < imageMeta.chunks; i++) { 147 | const start = i * KV_CHUNK_SIZE; 148 | const end = Math.min(start + KV_CHUNK_SIZE, imageBytes.byteLength); 149 | const chunk = imageBytes.slice(start, end); 150 | await kv.set(["images", imageId, "chunk", i], chunk); 151 | } 152 | 153 | // Mark image as completed after all chunks are stored 154 | await kv.set(["images", imageId, "meta"], { ...imageMeta, completed: true }); 155 | 156 | // Extract file extension from original name 157 | const fileNameParts = file.name.split('.'); 158 | const fileExtension = fileNameParts.length > 1 ? fileNameParts.pop() : ''; 159 | const imageUrl = `${origin}/image/${imageId}${fileExtension ? '.' + fileExtension : ''}`; 160 | results.push({ name: file.name, url: imageUrl, status: "uploaded" }); 161 | } 162 | 163 | } catch (error) { 164 | console.error(`Error processing file ${file.name}:`, error); 165 | results.push({ name: file.name, error: error.message, status: "error" }); 166 | } 167 | } 168 | 169 | // Determine overall status code 170 | const status = results.some(r => r.status === "error") ? 500 : 200; 171 | 172 | return new Response(JSON.stringify(results), { 173 | status: status, 174 | headers: { 175 | "Content-Type": "application/json", 176 | ...getCorsHeaders() 177 | }, 178 | }); 179 | 180 | } catch (error) { 181 | console.error("Upload handler error:", error); 182 | return new Response("Internal Server Error", { 183 | status: 500, 184 | headers: getCorsHeaders() 185 | }); 186 | } 187 | } 188 | 189 | async function serveImage(request: Request, imageId: string): Promise { 190 | try { 191 | // Check cache first 192 | const cachedImage = cache.get(imageId); 193 | if (cachedImage) { 194 | console.log(`Serving image ${imageId} from cache`); 195 | const meta = await kv.get(["images", imageId, "meta"]); 196 | if (meta.value) { 197 | return new Response(cachedImage, { 198 | headers: { 199 | "Content-Type": meta.value.type, 200 | ...getCorsHeaders() 201 | }, 202 | }); 203 | } 204 | } 205 | 206 | const metaEntry = await kv.get(["images", imageId, "meta"]); 207 | if (!metaEntry.value || !metaEntry.value.completed) { 208 | return new Response("Image not found or not yet completed", { 209 | status: 404, 210 | headers: getCorsHeaders() 211 | }); 212 | } 213 | 214 | const imageMeta = metaEntry.value; 215 | const chunks: Uint8Array[] = []; 216 | 217 | for (let i = 0; i < imageMeta.chunks; i++) { 218 | const chunkEntry = await kv.get(["images", imageId, "chunk", i]); 219 | if (!chunkEntry.value) { 220 | // This should not happen if metadata is present, but handle defensively 221 | console.error(`Missing chunk ${i} for image ${imageId}`); 222 | return new Response("Image data incomplete", { 223 | status: 500, 224 | headers: getCorsHeaders() 225 | }); 226 | } 227 | chunks.push(chunkEntry.value); 228 | } 229 | 230 | const fullImage = new Uint8Array(imageMeta.size); 231 | let offset = 0; 232 | for (const chunk of chunks) { 233 | fullImage.set(chunk, offset); 234 | offset += chunk.byteLength; 235 | } 236 | 237 | // Store in cache 238 | cache.set(imageId, fullImage); 239 | // Simple cache expiration (in a real app, use setInterval or similar) 240 | setTimeout(() => { 241 | cache.delete(imageId); 242 | console.log(`Cache expired for image ${imageId}`); 243 | }, CACHE_TTL); 244 | 245 | 246 | return new Response(fullImage, { 247 | headers: { 248 | "Content-Type": imageMeta.type, 249 | ...getCorsHeaders() 250 | }, 251 | }); 252 | 253 | } catch (error) { 254 | console.error("Serve image error:", error); 255 | return new Response("Internal Server Error", { 256 | status: 500, 257 | headers: getCorsHeaders() 258 | }); 259 | } 260 | } 261 | 262 | async function handler(request: Request): Promise { 263 | const url = new URL(request.url); 264 | 265 | // Handle CORS preflight requests 266 | if (request.method === "OPTIONS") { 267 | return new Response(null, { 268 | status: 200, 269 | headers: getCorsHeaders(), 270 | }); 271 | } 272 | 273 | if (request.method === "POST" && url.pathname === "/upload") { 274 | return uploadHandler(request); 275 | } 276 | 277 | if (url.pathname.startsWith("/image/")) { 278 | // Extract UUID from the new URL format /[UUID].[extension] 279 | const pathParts = url.pathname.split("/"); 280 | const filenameWithExtension = pathParts.pop(); // Get the last part, e.g., "a1b2c3d4...jpg" 281 | if (filenameWithExtension) { 282 | const filenameParts = filenameWithExtension.split('.'); 283 | const imageId = filenameParts[0]; // The UUID is the first part 284 | return serveImage(request, imageId); 285 | } 286 | } 287 | 288 | // Serve the index.html file for the root path 289 | if (url.pathname === "/" || url.pathname === "/index.html") { 290 | return serveFile(request, "./index.html"); 291 | } 292 | 293 | return new Response("Not Found", { 294 | status: 404, 295 | headers: getCorsHeaders() 296 | }); 297 | } 298 | 299 | console.log("Listening on http://localhost:8000"); 300 | serve(handler); 301 | --------------------------------------------------------------------------------