225 lines
7.2 KiB
TypeScript
225 lines
7.2 KiB
TypeScript
import { Elysia, redirect, t } from "elysia"
|
|
import ExifReader from "exifreader"
|
|
import { exists, mkdir } from "fs/promises"
|
|
|
|
// --- Configuration ---
|
|
const FOLDER_BASE = "./data"
|
|
const FOLDER_PHOTOS = `${FOLDER_BASE}/photos`
|
|
const FILE_METADATA = `${FOLDER_BASE}/metadata.json`
|
|
|
|
// --- 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 }
|
|
[key: string]: any // Custom properties
|
|
}
|
|
|
|
class HttpError extends Error {
|
|
constructor(public status: number, public content: string | object) {
|
|
super()
|
|
}
|
|
}
|
|
function done(status: number, content: string | object): never {
|
|
throw new HttpError(status, content)
|
|
}
|
|
|
|
/**
|
|
* Loads the metadata.json file.
|
|
* Returns an empty array if the file doesn't exist.
|
|
*/
|
|
async function getMetadata(): Promise<PhotoMetadata[]> {
|
|
const file = Bun.file(FILE_METADATA)
|
|
if (!(await file.exists())) return []
|
|
return await file.json()
|
|
}
|
|
|
|
/**
|
|
* Safely saves the metadata array to metadata.json.
|
|
*/
|
|
async function saveMetadata(data: PhotoMetadata[]): Promise<void> {
|
|
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<any> {
|
|
return ExifReader.load(await file.arrayBuffer(), {
|
|
expanded: true,
|
|
includeUnknown: false,
|
|
})
|
|
}
|
|
|
|
// --- Elysia Server ---
|
|
|
|
// Load secrets and ensure directories exist before starting
|
|
const INSTANT_KEY = process.env.INSTANT_KEY!
|
|
if (!await exists(FOLDER_PHOTOS)) await mkdir(FOLDER_PHOTOS, { recursive: true })
|
|
|
|
console.log("Server started.")
|
|
|
|
function checkHeaderKey(headers: Record<string, string | undefined>, expectList: string[] = [INSTANT_KEY]) {
|
|
const key = headers["x-instant-key"]
|
|
if (!expectList.includes(key ?? "")) done(401, "Invalid authentication key")
|
|
}
|
|
|
|
export const app = new Elysia()
|
|
// Error handling: Return status code and message
|
|
.onError(({ error, status }) => {
|
|
if (error instanceof HttpError) {
|
|
if (error.content instanceof String) return status(error.status, { msg: error.message })
|
|
else return status(error.status, error.content)
|
|
}
|
|
// else return status(500, { error: "Internal Server Error" })
|
|
else throw error
|
|
})
|
|
.get("/", ({ redirect }) => redirect("https://aza.moe/photo"))
|
|
|
|
.post("/upload", async ({ body, headers, status }) => {
|
|
const { owner_key, photo, edited_photo } = body
|
|
checkHeaderKey(headers)
|
|
|
|
// Generate an ID of 8 characters
|
|
const id = Math.random().toString(36).substring(2, 10)
|
|
let metadata = await getMetadata()
|
|
|
|
// 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, 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)
|
|
|
|
done(201, { success: true, id: newEntry.id })
|
|
}, {
|
|
// --- Validation Schema ---
|
|
body: t.Object({
|
|
owner_key: t.String(),
|
|
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()).filter((entry) => !entry.hide)
|
|
|
|
// Map over the array and filter out the 'owner_key' from each object
|
|
const pubMeta = metadata.map((entry) => {
|
|
const { owner_key, ...publicEntry } = entry
|
|
return publicEntry
|
|
})
|
|
|
|
return pubMeta
|
|
})
|
|
|
|
// ----- 3. GET /photos/:id (Static File) -----
|
|
// Returns the static file for the *edited* photo.
|
|
// Respects the 'hide' flag.
|
|
.get("/photos/:id", async ({ body }) => {
|
|
const { id } = body
|
|
const metadata = await getMetadata()
|
|
const photo = metadata.find((p) => p.id === id)
|
|
|
|
// Not found or hidden
|
|
if (!photo || photo.hide === true) done(404, "Photo not found")
|
|
|
|
const file = Bun.file(photo.edited_photo.path)
|
|
if (!(await file.exists())) {
|
|
console.error(`Missing file for ID ${id} at ${photo.edited_photo.path}`)
|
|
done(404, "Photo file not found on disk")
|
|
}
|
|
return file
|
|
}, {
|
|
body: t.Object({ id: t.String() })
|
|
})
|
|
|
|
// ----- 4. POST /edit -----
|
|
// Edits a specific field in the metadata.
|
|
.post("/edit", async ({ headers, body, status }) => {
|
|
const { id, key, field, value } = body
|
|
const metadata = await getMetadata()
|
|
|
|
const photoIndex = metadata.findIndex((p) => p.id === id)
|
|
if (photoIndex === -1) done(404, "Photo not found")
|
|
|
|
const photo = metadata[photoIndex]
|
|
checkHeaderKey(headers, [photo.owner_key, INSTANT_KEY])
|
|
|
|
// Prevent editing core, protected fields
|
|
const protectedFields = ["id", "owner_key", "upload_time", "original_photo", "edited_photo"]
|
|
if (protectedFields.includes(field)) done(400, `Cannot edit protected field: ${field}`)
|
|
|
|
// Apply the edit
|
|
console.log(`Editing photo ${id}: Set ${field} = ${value}`)
|
|
photo[field] = value
|
|
metadata[photoIndex] = photo // Update the photo in the main array
|
|
|
|
await saveMetadata(metadata)
|
|
done(200, { success: true, id, updated: { [field]: value } })
|
|
}, {
|
|
body: t.Object({ id: t.String(), key: t.String(), field: t.String(), value: t.Any() })
|
|
})
|
|
|
|
// Redirect for short links. ID can be either ID or owner_key
|
|
.get("/:id", async ({ params, redirect }) => {
|
|
const { id } = params
|
|
const metadata = await getMetadata()
|
|
|
|
const photo = metadata.find((p) => p.id === id) || metadata.find((p) => p.owner_key === id)
|
|
if (!photo || photo.hide === true) done(404, "Photo not found")
|
|
|
|
return redirect(`https://aza.moe/photo/${photo.id}`)
|
|
}, {
|
|
params: t.Object({ id: t.String() })
|
|
})
|
|
|
|
.listen(3000)
|
|
|
|
console.log(`🦊 Elysia server running at http://${app.server?.hostname}:${app.server?.port}`)
|