diff --git a/.env b/.env new file mode 100644 index 0000000..ee1e7e3 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +INSTANT_KEY="meowmeow" diff --git a/.gitignore b/.gitignore index 87e5610..dc84c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,4 @@ yarn-error.log* **/*.tgz **/*.log package-lock.json -**/*.bun \ No newline at end of file +**/*.bun diff --git a/.zed/settings.json b/.zed/settings.json new file mode 100644 index 0000000..4259ffe --- /dev/null +++ b/.zed/settings.json @@ -0,0 +1,7 @@ +// Folder-specific settings +// +// For a full list of overridable settings, and general information on folder-specific settings, +// see the documentation: https://zed.dev/docs/configuring-zed#settings-files +{ + "format_on_save": "off" +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..2fabf28 --- /dev/null +++ b/bun.lock @@ -0,0 +1,66 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "public-photos", + "dependencies": { + "elysia": "latest", + "exifreader": "^4.32.0", + }, + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="], + + "@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="], + + "@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="], + + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + + "@types/node": ["@types/node@24.9.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg=="], + + "@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="], + + "@xmldom/xmldom": ["@xmldom/xmldom@0.9.8", "", {}, "sha512-p96FSY54r+WJ50FIOsCOjyj/wavs8921hG5+kVMmZgKcvIKxMXHTrjNJvRgWa/zuX3B6t2lijLNFaOyuxUH+2A=="], + + "bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="], + + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="], + + "exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="], + + "exifreader": ["exifreader@4.32.0", "", { "optionalDependencies": { "@xmldom/xmldom": "^0.9.4" } }, "sha512-sj1PzjpaPwSE/2MeUqoAYcfc2u7AZOGSby0FzmAkB4jjeCXgDryxzVgMwV+tJKGIkGdWkkWiUWoLSJoPHJ6V5Q=="], + + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], + + "fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="], + + "file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="], + + "strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="], + + "token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="], + + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/package.json b/package.json index 9bbdd13..7d89342 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,13 @@ { - "name": "public-photos", + "name": "project-instant", "version": "1.0.50", "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "dev": "bun run --watch src/index.ts" }, "dependencies": { - "elysia": "latest" + "elysia": "latest", + "exifreader": "^4.32.0" }, "devDependencies": { "bun-types": "latest" diff --git a/src/index.ts b/src/index.ts index 9c1f7a1..ea2a528 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,178 @@ -import { Elysia } from "elysia"; +import { Elysia, t } from "elysia" +import ExifReader from "exifreader" +import { exists, mkdir } from "fs/promises" -const app = new Elysia().get("/", () => "Hello Elysia").listen(3000); +// --- Configuration --- +const FOLDER_PHOTOS = "./photos" +const FILE_METADATA = "./metadata.json" -console.log( - `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` -); +// --- Types --- +// Interface for our metadata entries +interface PhotoMetadata { + id: string + owner_key: string // This will be filtered out for public requests + upload_time: string + original_photo: { filename: string, path: string, exif: any } + edited_photo: { filename: string, path: string, exif: any } +} + +/** + * Loads the metadata.json file. + * Returns an empty array if the file doesn't exist. + */ +async function getMetadata(): Promise { + const file = Bun.file(FILE_METADATA) + if (!(await file.exists())) { + return [] // Return empty array if no metadata file yet + } + try { + return await file.json() + } catch (e) { + console.warn(`Warning: Could not parse 'metadata.json'. Returning empty array.`, e) + return [] + } +} + +/** + * Safely saves the metadata array to metadata.json. + */ +async function saveMetadata(data: PhotoMetadata[]): Promise { + try { + await Bun.write(FILE_METADATA, JSON.stringify(data, null, 2)) + } catch (e) { + console.error("Error: Could not write to 'metadata.json'.", e) + } +} + +/** + * Extracts EXIF data from an uploaded file. + */ +async function getExifData(file: File): Promise { + try { + const buffer = await file.arrayBuffer() + // Set options to not parse maker notes or unknown tags for speed + const tags = ExifReader.load(buffer, { + expanded: true, + includeUnknown: false, + }) + return tags + } catch (e) { + console.error(`Error parsing EXIF for ${file.name}:`, e) + return { error: "Could not parse EXIF data." } + } +} + +// --- Elysia Server --- + +// Load secrets and ensure directories exist before starting +const INSTANT_KEY = process.env.INSTANT_KEY +if (!INSTANT_KEY) throw new Error("INSTANT_KEY is not defined") +if (!await exists(FOLDER_PHOTOS)) await mkdir(FOLDER_PHOTOS) + +console.log("Server starting with valid 'secrets.json'.") + +export const app = new Elysia() + .post("/upload", async ({ body, status }) => { + const { key, id, owner_key, photo, edited_photo } = body + + // 1. Authentication + if (key !== INSTANT_KEY) status(401, "Invalid authentication key") + + // 2. Check for existing ID + const metadata = await getMetadata() + if (metadata.find((m) => m.id === id)) status(409, "This ID already exists") + + try { + // 3. Process and save files + // We use {id}-1 and {id}-2 as requested by "{id}-{number}" + const originalExt = photo.name.split(".").pop() || "jpg" + const editedExt = edited_photo.name.split(".").pop() || "jpg" + + // Using "1" for original and "2" for edited + const originalPath = `${FOLDER_PHOTOS}/${id}-1.${originalExt}` + const editedPath = `${FOLDER_PHOTOS}/${id}-2.${editedExt}` + + // Run file saving and EXIF parsing in parallel + const [originalExif, editedExif] = await Promise.all([ + getExifData(photo), + getExifData(edited_photo), + Bun.write(originalPath, photo), + Bun.write(editedPath, edited_photo), + ]) + + // 4. Create new metadata entry + const newEntry: PhotoMetadata = { + id: id, + owner_key: owner_key, + upload_time: new Date().toISOString(), + original_photo: { + filename: photo.name, + path: originalPath, + exif: originalExif, + }, + edited_photo: { + filename: edited_photo.name, + path: editedPath, + exif: editedExif, + }, + } + + // 5. Save updated metadata + metadata.push(newEntry) + await saveMetadata(metadata) + + return status(201, { success: true, id: newEntry.id }) + } catch (error) { + console.error("File upload processing error:", error) + return status(500, { error: "Failed to process file upload." }) + } + }, + { + // --- Validation Schema --- + // We interpret "Part 1: JSON body" as the text fields of + // the multipart form, which is the standard way to send + // metadata alongside files. + body: t.Object({ + key: t.String(), + id: t.String({ + minLength: 3, + maxLength: 20, + // Simple pattern: letters, numbers, hyphens + pattern: "^[a-zA-Z0-9_-]+$", + error: + "ID must be 3-20 alphanumeric characters, hyphens, or underscores.", + }), + owner_key: t.String({ + minLength: 4, + maxLength: 4, + pattern: "^[0-9]{4}$", // e.g., "2941" + error: "Owner key must be exactly 4 digits.", + }), + photo: t.File({ + type: ["image/jpeg", "image/png", "image/webp", "image/avif", "image/heic", "image/heif"], + error: "Original photo must be a valid image (jpg, png, webp, avif, heic, heif).", + }), + edited_photo: t.File({ + type: ["image/jpeg", "image/png", "image/webp", "image/avif", "image/heic", "image/heif"], + error: "Edited photo must be a valid image (jpg, png, webp, avif, heic, heif).", + }), + }), + }) + + // ----- 2. GET /photos ----- + // Returns all metadata entries *without* the 'owner_key'. + .get("/photos", async () => { + const metadata = await getMetadata() + + // Map over the array and filter out the 'owner_key' from each object + const publicMetadata = metadata.map((entry) => { + const { owner_key, ...publicEntry } = entry + return publicEntry + }) + + return publicMetadata + }) + + .listen(3000) + +console.log(`🦊 Elysia server running at http://${app.server?.hostname}:${app.server?.port}`) diff --git a/src/test-client.ts b/src/test-client.ts new file mode 100644 index 0000000..c8c74a3 --- /dev/null +++ b/src/test-client.ts @@ -0,0 +1,108 @@ +// --- Configuration --- +// Make sure this key matches your secrets.json! +const API_KEY = "meowmeow"; +const SERVER_URL = "http://localhost:3000"; + +// A unique ID for this test upload +const TEST_ID = `test-${Math.floor(Math.random() * 10000)}`; +const TEST_OWNER_KEY = "1234"; +const TEST_IMAGE_PATH = "./UWU01721.JPG"; // The dummy image you will create + +/** + * Creates a dummy image file if it doesn't exist. + * This is just for testing; in a real client, you'd use a real file. + */ +async function ensureTestImageExists() { + const file = Bun.file(TEST_IMAGE_PATH); + if (!(await file.exists())) { + console.log(`Creating dummy file at: ${TEST_IMAGE_PATH}`); + // Create a tiny, simple text file as a placeholder. + // The server validation only checks MIME type based on extension, + // so for this test, a fake "jpg" is okay. + // A real image would be better for EXIF parsing. + await Bun.write(TEST_IMAGE_PATH, "This is a test image buffer"); + } +} + +/** + * Test 1: POST /upload + * Attempts to upload the test image with metadata. + */ +async function testUpload() { + console.log(`--- Testing POST /upload with ID: ${TEST_ID} ---`); + await ensureTestImageExists(); + + const testFile = Bun.file(TEST_IMAGE_PATH); + + // 1. Create the multipart/form-data payload + const formData = new FormData(); + formData.append("key", API_KEY); + formData.append("id", TEST_ID); + formData.append("owner_key", TEST_OWNER_KEY); + + // Append the files + // We'll use the same dummy image for both "original" and "edited" + formData.append("photo", testFile); + formData.append("edited_photo", testFile); + + try { + // 2. Send the request + const response = await fetch(`${SERVER_URL}/upload`, { + method: "POST", + body: formData, + }); + + // 3. Log the response + const result = await response.json(); + console.log(`Status: ${response.status}`); + console.log("Response:", result); + + if (!response.ok) { + console.error("Upload test FAILED!"); + } else { + console.log("Upload test PASSED!"); + } + } catch (e) { + console.error("Error during /upload test:", e); + } +} + +/** + * Test 2: GET /photos + * Fetches the public metadata. + */ +async function testGetPhotos() { + console.log("\n--- Testing GET /photos ---"); + try { + const response = await fetch(`${SERVER_URL}/photos`); + const metadata = await response.json(); + + console.log(`Status: ${response.status}`); + console.log(`Found ${metadata.length} entries.`); + + // Find our test upload + const ourEntry = metadata.find((e: any) => e.id === TEST_ID); + if (ourEntry) { + console.log("Found our test entry:", ourEntry); + if (ourEntry.owner_key) { + console.error("GET /photos test FAILED: 'owner_key' was exposed!"); + } else { + console.log( + "GET /photos test PASSED: 'owner_key' was correctly removed.", + ); + } + } else { + console.warn("Could not find our test entry in /photos response."); + } + } catch (e) { + console.error("Error during /photos test:", e); + } +} + +// --- Run Tests --- +async function runTests() { + await testUpload(); + await testGetPhotos(); +} + +runTests();