feat: added auth and photo uploads
eb3406b9
14 file(s) · +703 −1
| 1 | + | # Copy this file to .dev.vars and fill in your values |
|
| 2 | + | # These are used for local development with `wrangler dev` or `bun run dev` |
|
| 3 | + | ||
| 4 | + | # Generate a secure random string for SESSION_SECRET (e.g., `openssl rand -hex 32`) |
|
| 5 | + | SESSION_SECRET=your-session-secret-here |
|
| 6 | + | ||
| 7 | + | # Generate ADMIN_PASSWORD_HASH using the hash-password.ts script: |
|
| 8 | + | # bun run scripts/hash-password.ts <your-password> <session-secret> |
|
| 9 | + | ADMIN_PASSWORD_HASH=your-password-hash-here |
| 21 | 21 | # Vite |
|
| 22 | 22 | vite.config.js.timestamp-* |
|
| 23 | 23 | vite.config.ts.timestamp-* |
|
| 24 | + | .dev.vars |
| 5 | 5 | "": { |
|
| 6 | 6 | "name": "steve-photo-svelte", |
|
| 7 | 7 | "dependencies": { |
|
| 8 | + | "exifr": "^7.1.3", |
|
| 8 | 9 | "from": "^0.1.7", |
|
| 9 | 10 | "import": "^0.0.6", |
|
| 10 | 11 | }, |
|
| 294 | 295 | "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], |
|
| 295 | 296 | ||
| 296 | 297 | "esrap": ["esrap@2.2.2", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" } }, "sha512-zA6497ha+qKvoWIK+WM9NAh5ni17sKZKhbS5B3PoYbBvaYHZWoS33zmFybmyqpn07RLUxSmn+RCls2/XF+d0oQ=="], |
|
| 298 | + | ||
| 299 | + | "exifr": ["exifr@7.1.3", "", {}, "sha512-g/aje2noHivrRSLbAUtBPWFbxKdKhgj/xr1vATDdUXPOFYJlQ62Ft0oy+72V6XLIpDJfHs6gXLbBLAolqOXYRw=="], |
|
| 297 | 300 | ||
| 298 | 301 | "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], |
|
| 299 | 302 | ||
| 25 | 25 | "vite": "^7.2.6" |
|
| 26 | 26 | }, |
|
| 27 | 27 | "dependencies": { |
|
| 28 | + | "exifr": "^7.1.3", |
|
| 28 | 29 | "from": "^0.1.7", |
|
| 29 | 30 | "import": "^0.0.6" |
|
| 30 | 31 | } |
| 1 | + | // Usage: bun run scripts/hash-password.ts <password> <session-secret> |
|
| 2 | + | // Or: npx tsx scripts/hash-password.ts <password> <session-secret> |
|
| 3 | + | ||
| 4 | + | const encoder = new TextEncoder(); |
|
| 5 | + | ||
| 6 | + | async function hashPassword(password: string, secret: string): Promise<string> { |
|
| 7 | + | const key = await crypto.subtle.importKey( |
|
| 8 | + | "raw", |
|
| 9 | + | encoder.encode(secret), |
|
| 10 | + | { name: "HMAC", hash: "SHA-256" }, |
|
| 11 | + | false, |
|
| 12 | + | ["sign"], |
|
| 13 | + | ); |
|
| 14 | + | const signature = await crypto.subtle.sign( |
|
| 15 | + | "HMAC", |
|
| 16 | + | key, |
|
| 17 | + | encoder.encode(password), |
|
| 18 | + | ); |
|
| 19 | + | return Array.from(new Uint8Array(signature)) |
|
| 20 | + | .map((b) => b.toString(16).padStart(2, "0")) |
|
| 21 | + | .join(""); |
|
| 22 | + | } |
|
| 23 | + | ||
| 24 | + | const [password, secret] = process.argv.slice(2); |
|
| 25 | + | ||
| 26 | + | if (!password || !secret) { |
|
| 27 | + | console.error( |
|
| 28 | + | "Usage: bun run scripts/hash-password.ts <password> <session-secret>", |
|
| 29 | + | ); |
|
| 30 | + | console.error("\nExample:"); |
|
| 31 | + | console.error( |
|
| 32 | + | " bun run scripts/hash-password.ts mypassword $(openssl rand -hex 32)", |
|
| 33 | + | ); |
|
| 34 | + | process.exit(1); |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | const hash = await hashPassword(password, secret); |
|
| 38 | + | ||
| 39 | + | console.log( |
|
| 40 | + | "\nAdd these to your .dev.vars file (for local dev) or Cloudflare secrets (for production):\n", |
|
| 41 | + | ); |
|
| 42 | + | console.log(`SESSION_SECRET=${secret}`); |
|
| 43 | + | console.log(`ADMIN_PASSWORD_HASH=${hash}`); |
|
| 44 | + | console.log("\nTo set Cloudflare secrets, run:"); |
|
| 45 | + | console.log(` wrangler secret put SESSION_SECRET`); |
|
| 46 | + | console.log(` wrangler secret put ADMIN_PASSWORD_HASH`); |
| 3 | 3 | declare global { |
|
| 4 | 4 | namespace App { |
|
| 5 | 5 | // interface Error {} |
|
| 6 | - | // interface Locals {} |
|
| 6 | + | interface Locals { |
|
| 7 | + | user: { authenticated: boolean } | null; |
|
| 8 | + | } |
|
| 7 | 9 | // interface PageData {} |
|
| 8 | 10 | // interface PageState {} |
|
| 9 | 11 | interface Platform { |
|
| 10 | 12 | env: { |
|
| 11 | 13 | DB: D1Database; |
|
| 12 | 14 | PHOTOS: R2Bucket; |
|
| 15 | + | ADMIN_PASSWORD_HASH: string; |
|
| 16 | + | SESSION_SECRET: string; |
|
| 13 | 17 | }; |
|
| 14 | 18 | } |
|
| 15 | 19 | } |
| 1 | + | import type { Handle } from "@sveltejs/kit"; |
|
| 2 | + | import { verifySession } from "$lib/auth"; |
|
| 3 | + | ||
| 4 | + | export const handle: Handle = async ({ event, resolve }) => { |
|
| 5 | + | const sessionCookie = event.cookies.get("session"); |
|
| 6 | + | const secret = event.platform?.env?.SESSION_SECRET; |
|
| 7 | + | ||
| 8 | + | if (sessionCookie && secret) { |
|
| 9 | + | const isValid = await verifySession(sessionCookie, secret); |
|
| 10 | + | event.locals.user = isValid ? { authenticated: true } : null; |
|
| 11 | + | } else { |
|
| 12 | + | event.locals.user = null; |
|
| 13 | + | } |
|
| 14 | + | ||
| 15 | + | return resolve(event); |
|
| 16 | + | }; |
| 1 | + | const encoder = new TextEncoder(); |
|
| 2 | + | ||
| 3 | + | export async function hashPassword( |
|
| 4 | + | password: string, |
|
| 5 | + | secret: string, |
|
| 6 | + | ): Promise<string> { |
|
| 7 | + | const key = await crypto.subtle.importKey( |
|
| 8 | + | "raw", |
|
| 9 | + | encoder.encode(secret), |
|
| 10 | + | { name: "HMAC", hash: "SHA-256" }, |
|
| 11 | + | false, |
|
| 12 | + | ["sign"], |
|
| 13 | + | ); |
|
| 14 | + | const signature = await crypto.subtle.sign( |
|
| 15 | + | "HMAC", |
|
| 16 | + | key, |
|
| 17 | + | encoder.encode(password), |
|
| 18 | + | ); |
|
| 19 | + | return arrayBufferToHex(signature); |
|
| 20 | + | } |
|
| 21 | + | ||
| 22 | + | export async function verifyPassword( |
|
| 23 | + | password: string, |
|
| 24 | + | hash: string, |
|
| 25 | + | secret: string, |
|
| 26 | + | ): Promise<boolean> { |
|
| 27 | + | const computed = await hashPassword(password, secret); |
|
| 28 | + | return timingSafeEqual(computed, hash); |
|
| 29 | + | } |
|
| 30 | + | ||
| 31 | + | export async function createSession(secret: string): Promise<string> { |
|
| 32 | + | const sessionId = crypto.randomUUID(); |
|
| 33 | + | const timestamp = Date.now().toString(); |
|
| 34 | + | const data = `${sessionId}.${timestamp}`; |
|
| 35 | + | ||
| 36 | + | const key = await crypto.subtle.importKey( |
|
| 37 | + | "raw", |
|
| 38 | + | encoder.encode(secret), |
|
| 39 | + | { name: "HMAC", hash: "SHA-256" }, |
|
| 40 | + | false, |
|
| 41 | + | ["sign"], |
|
| 42 | + | ); |
|
| 43 | + | const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); |
|
| 44 | + | const sig = arrayBufferToHex(signature); |
|
| 45 | + | ||
| 46 | + | return `${data}.${sig}`; |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | export async function verifySession( |
|
| 50 | + | token: string, |
|
| 51 | + | secret: string, |
|
| 52 | + | ): Promise<boolean> { |
|
| 53 | + | const parts = token.split("."); |
|
| 54 | + | if (parts.length !== 3) return false; |
|
| 55 | + | ||
| 56 | + | const [sessionId, timestamp, providedSig] = parts; |
|
| 57 | + | const data = `${sessionId}.${timestamp}`; |
|
| 58 | + | ||
| 59 | + | // Check if session is expired (24 hours) |
|
| 60 | + | const sessionTime = parseInt(timestamp, 10); |
|
| 61 | + | if (isNaN(sessionTime) || Date.now() - sessionTime > 24 * 60 * 60 * 1000) { |
|
| 62 | + | return false; |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | const key = await crypto.subtle.importKey( |
|
| 66 | + | "raw", |
|
| 67 | + | encoder.encode(secret), |
|
| 68 | + | { name: "HMAC", hash: "SHA-256" }, |
|
| 69 | + | false, |
|
| 70 | + | ["sign"], |
|
| 71 | + | ); |
|
| 72 | + | const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(data)); |
|
| 73 | + | const expectedSig = arrayBufferToHex(signature); |
|
| 74 | + | ||
| 75 | + | return timingSafeEqual(providedSig, expectedSig); |
|
| 76 | + | } |
|
| 77 | + | ||
| 78 | + | function arrayBufferToHex(buffer: ArrayBuffer): string { |
|
| 79 | + | return Array.from(new Uint8Array(buffer)) |
|
| 80 | + | .map((b) => b.toString(16).padStart(2, "0")) |
|
| 81 | + | .join(""); |
|
| 82 | + | } |
|
| 83 | + | ||
| 84 | + | function timingSafeEqual(a: string, b: string): boolean { |
|
| 85 | + | if (a.length !== b.length) return false; |
|
| 86 | + | let result = 0; |
|
| 87 | + | for (let i = 0; i < a.length; i++) { |
|
| 88 | + | result |= a.charCodeAt(i) ^ b.charCodeAt(i); |
|
| 89 | + | } |
|
| 90 | + | return result === 0; |
|
| 91 | + | } |
| 1 | + | import type { LayoutServerLoad } from "./$types"; |
|
| 2 | + | import { redirect } from "@sveltejs/kit"; |
|
| 3 | + | ||
| 4 | + | export const load: LayoutServerLoad = async ({ locals }) => { |
|
| 5 | + | if (!locals.user?.authenticated) { |
|
| 6 | + | throw redirect(302, "/login"); |
|
| 7 | + | } |
|
| 8 | + | return {}; |
|
| 9 | + | }; |
| 1 | + | import type { Actions, PageServerLoad } from "./$types"; |
|
| 2 | + | import { fail, redirect, isRedirect } from "@sveltejs/kit"; |
|
| 3 | + | ||
| 4 | + | const R2_BASE_URL = "https://r2.steve.photo"; |
|
| 5 | + | ||
| 6 | + | export const load: PageServerLoad = async () => { |
|
| 7 | + | return {}; |
|
| 8 | + | }; |
|
| 9 | + | ||
| 10 | + | export const actions: Actions = { |
|
| 11 | + | upload: async ({ request, platform, locals }) => { |
|
| 12 | + | if (!locals.user?.authenticated) { |
|
| 13 | + | return fail(401, { error: "Unauthorized" }); |
|
| 14 | + | } |
|
| 15 | + | ||
| 16 | + | const db = platform?.env?.DB; |
|
| 17 | + | const bucket = platform?.env?.PHOTOS; |
|
| 18 | + | ||
| 19 | + | if (!db || !bucket) { |
|
| 20 | + | return fail(500, { error: "Server configuration error" }); |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | const formData = await request.formData(); |
|
| 24 | + | const file = formData.get("file") as File | null; |
|
| 25 | + | const thumbnail = formData.get("thumbnail") as File | null; |
|
| 26 | + | const title = formData.get("title") as string; |
|
| 27 | + | const date = formData.get("date") as string; |
|
| 28 | + | const camera = formData.get("camera") as string; |
|
| 29 | + | const lens = formData.get("lens") as string; |
|
| 30 | + | const aperture = formData.get("aperture") as string; |
|
| 31 | + | const exposure = formData.get("exposure") as string; |
|
| 32 | + | const focalLength = formData.get("focalLength") as string; |
|
| 33 | + | const iso = formData.get("iso") as string; |
|
| 34 | + | const make = formData.get("make") as string; |
|
| 35 | + | ||
| 36 | + | if (!file || !title || !date) { |
|
| 37 | + | return fail(400, { error: "File, title, and date are required" }); |
|
| 38 | + | } |
|
| 39 | + | ||
| 40 | + | // Generate slug from title |
|
| 41 | + | const slug = title |
|
| 42 | + | .toLowerCase() |
|
| 43 | + | .replace(/[^a-z0-9]+/g, "-") |
|
| 44 | + | .replace(/(^-|-$)/g, ""); |
|
| 45 | + | ||
| 46 | + | // Check if slug already exists |
|
| 47 | + | const existing = await db |
|
| 48 | + | .prepare("SELECT id FROM photos WHERE slug = ?") |
|
| 49 | + | .bind(slug) |
|
| 50 | + | .first(); |
|
| 51 | + | if (existing) { |
|
| 52 | + | return fail(400, { error: "A photo with this title already exists" }); |
|
| 53 | + | } |
|
| 54 | + | ||
| 55 | + | // Get file extension |
|
| 56 | + | const ext = file.name.split(".").pop()?.toLowerCase() || "jpg"; |
|
| 57 | + | const imageKey = `${slug}.${ext}`; |
|
| 58 | + | const thumbKey = `${slug}-thumb.jpg`; |
|
| 59 | + | ||
| 60 | + | try { |
|
| 61 | + | // Upload original image to R2 |
|
| 62 | + | const fileBuffer = await file.arrayBuffer(); |
|
| 63 | + | await bucket.put(imageKey, fileBuffer, { |
|
| 64 | + | httpMetadata: { |
|
| 65 | + | contentType: file.type, |
|
| 66 | + | }, |
|
| 67 | + | }); |
|
| 68 | + | ||
| 69 | + | // Upload thumbnail to R2 |
|
| 70 | + | if (thumbnail) { |
|
| 71 | + | const thumbBuffer = await thumbnail.arrayBuffer(); |
|
| 72 | + | await bucket.put(thumbKey, thumbBuffer, { |
|
| 73 | + | httpMetadata: { |
|
| 74 | + | contentType: "image/jpeg", |
|
| 75 | + | }, |
|
| 76 | + | }); |
|
| 77 | + | } else { |
|
| 78 | + | // If no thumbnail provided, use original as thumbnail |
|
| 79 | + | await bucket.put(thumbKey, fileBuffer, { |
|
| 80 | + | httpMetadata: { |
|
| 81 | + | contentType: file.type, |
|
| 82 | + | }, |
|
| 83 | + | }); |
|
| 84 | + | } |
|
| 85 | + | ||
| 86 | + | // Insert into database |
|
| 87 | + | await db |
|
| 88 | + | .prepare( |
|
| 89 | + | `INSERT INTO photos (slug, title, date, image_key, thumb_key, camera, lens, aperture, exposure, focal_length, iso, make) |
|
| 90 | + | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, |
|
| 91 | + | ) |
|
| 92 | + | .bind( |
|
| 93 | + | slug, |
|
| 94 | + | title, |
|
| 95 | + | date, |
|
| 96 | + | imageKey, |
|
| 97 | + | thumbKey, |
|
| 98 | + | camera || null, |
|
| 99 | + | lens || null, |
|
| 100 | + | aperture || null, |
|
| 101 | + | exposure || null, |
|
| 102 | + | focalLength || null, |
|
| 103 | + | iso || null, |
|
| 104 | + | make || null, |
|
| 105 | + | ) |
|
| 106 | + | .run(); |
|
| 107 | + | ||
| 108 | + | throw redirect(302, `/photo/${slug}`); |
|
| 109 | + | } catch (err) { |
|
| 110 | + | if (isRedirect(err)) { |
|
| 111 | + | throw err; // Re-throw redirects |
|
| 112 | + | } |
|
| 113 | + | const errorMessage = err instanceof Error ? err.message : String(err); |
|
| 114 | + | console.error("Upload error:", errorMessage, err); |
|
| 115 | + | return fail(500, { error: `Failed to upload photo: ${errorMessage}` }); |
|
| 116 | + | } |
|
| 117 | + | }, |
|
| 118 | + | }; |
| 1 | + | <script lang="ts"> |
|
| 2 | + | import { enhance } from "$app/forms"; |
|
| 3 | + | import exifr from "exifr"; |
|
| 4 | + | ||
| 5 | + | let { form } = $props(); |
|
| 6 | + | ||
| 7 | + | let fileInput = $state<HTMLInputElement | null>(null); |
|
| 8 | + | let selectedFile = $state<File | null>(null); |
|
| 9 | + | let previewUrl = $state<string | null>(null); |
|
| 10 | + | let thumbnailBlob = $state<Blob | null>(null); |
|
| 11 | + | ||
| 12 | + | let title = $state(""); |
|
| 13 | + | let date = $state(""); |
|
| 14 | + | let camera = $state(""); |
|
| 15 | + | let lens = $state(""); |
|
| 16 | + | let aperture = $state(""); |
|
| 17 | + | let exposure = $state(""); |
|
| 18 | + | let focalLength = $state(""); |
|
| 19 | + | let iso = $state(""); |
|
| 20 | + | let make = $state(""); |
|
| 21 | + | ||
| 22 | + | let isLoading = $state(false); |
|
| 23 | + | ||
| 24 | + | async function handleFileSelect(event: Event) { |
|
| 25 | + | const input = event.target as HTMLInputElement; |
|
| 26 | + | const file = input.files?.[0]; |
|
| 27 | + | if (!file) return; |
|
| 28 | + | ||
| 29 | + | selectedFile = file; |
|
| 30 | + | previewUrl = URL.createObjectURL(file); |
|
| 31 | + | ||
| 32 | + | // Auto-populate title from filename |
|
| 33 | + | const nameWithoutExt = file.name.replace(/\.[^/.]+$/, ""); |
|
| 34 | + | title = nameWithoutExt.replace(/[-_]/g, " "); |
|
| 35 | + | ||
| 36 | + | // Extract EXIF data |
|
| 37 | + | try { |
|
| 38 | + | const exif = await exifr.parse(file, { |
|
| 39 | + | pick: [ |
|
| 40 | + | "DateTimeOriginal", |
|
| 41 | + | "Model", |
|
| 42 | + | "LensModel", |
|
| 43 | + | "FNumber", |
|
| 44 | + | "ExposureTime", |
|
| 45 | + | "FocalLength", |
|
| 46 | + | "ISO", |
|
| 47 | + | "Make", |
|
| 48 | + | ], |
|
| 49 | + | }); |
|
| 50 | + | ||
| 51 | + | if (exif) { |
|
| 52 | + | if (exif.DateTimeOriginal) { |
|
| 53 | + | const d = new Date(exif.DateTimeOriginal); |
|
| 54 | + | date = d.toISOString().split("T")[0]; |
|
| 55 | + | } |
|
| 56 | + | if (exif.Model) camera = exif.Model; |
|
| 57 | + | if (exif.LensModel) lens = exif.LensModel; |
|
| 58 | + | if (exif.FNumber) aperture = `f/${exif.FNumber}`; |
|
| 59 | + | if (exif.ExposureTime) { |
|
| 60 | + | exposure = |
|
| 61 | + | exif.ExposureTime < 1 ? `1/${Math.round(1 / exif.ExposureTime)}s` : `${exif.ExposureTime}s`; |
|
| 62 | + | } |
|
| 63 | + | if (exif.FocalLength) focalLength = `${exif.FocalLength}mm`; |
|
| 64 | + | if (exif.ISO) iso = String(exif.ISO); |
|
| 65 | + | if (exif.Make) make = exif.Make; |
|
| 66 | + | } |
|
| 67 | + | } catch (err) { |
|
| 68 | + | console.error("Failed to read EXIF data:", err); |
|
| 69 | + | } |
|
| 70 | + | ||
| 71 | + | // Generate thumbnail |
|
| 72 | + | await generateThumbnail(file); |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | async function generateThumbnail(file: File): Promise<void> { |
|
| 76 | + | return new Promise((resolve) => { |
|
| 77 | + | const img = new Image(); |
|
| 78 | + | img.onload = () => { |
|
| 79 | + | const maxSize = 400; |
|
| 80 | + | let width = img.width; |
|
| 81 | + | let height = img.height; |
|
| 82 | + | ||
| 83 | + | if (width > height) { |
|
| 84 | + | if (width > maxSize) { |
|
| 85 | + | height = (height * maxSize) / width; |
|
| 86 | + | width = maxSize; |
|
| 87 | + | } |
|
| 88 | + | } else { |
|
| 89 | + | if (height > maxSize) { |
|
| 90 | + | width = (width * maxSize) / height; |
|
| 91 | + | height = maxSize; |
|
| 92 | + | } |
|
| 93 | + | } |
|
| 94 | + | ||
| 95 | + | const canvas = document.createElement("canvas"); |
|
| 96 | + | canvas.width = width; |
|
| 97 | + | canvas.height = height; |
|
| 98 | + | ||
| 99 | + | const ctx = canvas.getContext("2d"); |
|
| 100 | + | if (ctx) { |
|
| 101 | + | ctx.drawImage(img, 0, 0, width, height); |
|
| 102 | + | canvas.toBlob( |
|
| 103 | + | (blob) => { |
|
| 104 | + | thumbnailBlob = blob; |
|
| 105 | + | resolve(); |
|
| 106 | + | }, |
|
| 107 | + | "image/jpeg", |
|
| 108 | + | 0.8 |
|
| 109 | + | ); |
|
| 110 | + | } else { |
|
| 111 | + | resolve(); |
|
| 112 | + | } |
|
| 113 | + | }; |
|
| 114 | + | img.src = URL.createObjectURL(file); |
|
| 115 | + | }); |
|
| 116 | + | } |
|
| 117 | + | ||
| 118 | + | function handleSubmit() { |
|
| 119 | + | isLoading = true; |
|
| 120 | + | } |
|
| 121 | + | </script> |
|
| 122 | + | ||
| 123 | + | <svelte:head> |
|
| 124 | + | <title>Admin - Upload Photo</title> |
|
| 125 | + | </svelte:head> |
|
| 126 | + | ||
| 127 | + | <div class="min-h-screen p-4 md:p-8"> |
|
| 128 | + | <div class="max-w-2xl mx-auto"> |
|
| 129 | + | <div class="flex items-center justify-between mb-8"> |
|
| 130 | + | <h1 class="text-2xl font-bold">Upload Photo</h1> |
|
| 131 | + | <a |
|
| 132 | + | href="/api/logout" |
|
| 133 | + | class="text-sm text-zinc-400 hover:text-white transition-colors" |
|
| 134 | + | > |
|
| 135 | + | Logout |
|
| 136 | + | </a> |
|
| 137 | + | </div> |
|
| 138 | + | ||
| 139 | + | <form |
|
| 140 | + | method="POST" |
|
| 141 | + | action="?/upload" |
|
| 142 | + | enctype="multipart/form-data" |
|
| 143 | + | use:enhance={({ formData }) => { |
|
| 144 | + | handleSubmit(); |
|
| 145 | + | // Append thumbnail blob as file |
|
| 146 | + | if (thumbnailBlob) { |
|
| 147 | + | formData.append("thumbnail", thumbnailBlob, "thumbnail.jpg"); |
|
| 148 | + | } |
|
| 149 | + | return async ({ update }) => { |
|
| 150 | + | isLoading = false; |
|
| 151 | + | await update(); |
|
| 152 | + | }; |
|
| 153 | + | }} |
|
| 154 | + | class="space-y-6" |
|
| 155 | + | > |
|
| 156 | + | <div> |
|
| 157 | + | <label for="file" class="block text-sm mb-2">Image</label> |
|
| 158 | + | <input |
|
| 159 | + | type="file" |
|
| 160 | + | id="file" |
|
| 161 | + | name="file" |
|
| 162 | + | accept="image/jpeg,image/png,image/webp" |
|
| 163 | + | required |
|
| 164 | + | bind:this={fileInput} |
|
| 165 | + | onchange={handleFileSelect} |
|
| 166 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500 file:mr-4 file:py-1 file:px-4 file:rounded file:border-0 file:bg-zinc-700 file:text-white file:cursor-pointer" |
|
| 167 | + | /> |
|
| 168 | + | </div> |
|
| 169 | + | ||
| 170 | + | {#if previewUrl} |
|
| 171 | + | <div class="relative"> |
|
| 172 | + | <img |
|
| 173 | + | src={previewUrl} |
|
| 174 | + | alt="Preview" |
|
| 175 | + | class="w-full max-h-64 object-contain rounded bg-zinc-900" |
|
| 176 | + | /> |
|
| 177 | + | </div> |
|
| 178 | + | {/if} |
|
| 179 | + | ||
| 180 | + | ||
| 181 | + | ||
| 182 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|
| 183 | + | <div> |
|
| 184 | + | <label for="title" class="block text-sm mb-2">Title *</label> |
|
| 185 | + | <input |
|
| 186 | + | type="text" |
|
| 187 | + | id="title" |
|
| 188 | + | name="title" |
|
| 189 | + | required |
|
| 190 | + | bind:value={title} |
|
| 191 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 192 | + | /> |
|
| 193 | + | </div> |
|
| 194 | + | ||
| 195 | + | <div> |
|
| 196 | + | <label for="date" class="block text-sm mb-2">Date *</label> |
|
| 197 | + | <input |
|
| 198 | + | type="date" |
|
| 199 | + | id="date" |
|
| 200 | + | name="date" |
|
| 201 | + | required |
|
| 202 | + | bind:value={date} |
|
| 203 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 204 | + | /> |
|
| 205 | + | </div> |
|
| 206 | + | </div> |
|
| 207 | + | ||
| 208 | + | <div class="border-t border-zinc-800 pt-6"> |
|
| 209 | + | <h2 class="text-lg font-medium mb-4">EXIF Data</h2> |
|
| 210 | + | ||
| 211 | + | <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> |
|
| 212 | + | <div> |
|
| 213 | + | <label for="make" class="block text-sm mb-2">Make</label> |
|
| 214 | + | <input |
|
| 215 | + | type="text" |
|
| 216 | + | id="make" |
|
| 217 | + | name="make" |
|
| 218 | + | bind:value={make} |
|
| 219 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 220 | + | /> |
|
| 221 | + | </div> |
|
| 222 | + | ||
| 223 | + | <div> |
|
| 224 | + | <label for="camera" class="block text-sm mb-2">Camera</label> |
|
| 225 | + | <input |
|
| 226 | + | type="text" |
|
| 227 | + | id="camera" |
|
| 228 | + | name="camera" |
|
| 229 | + | bind:value={camera} |
|
| 230 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 231 | + | /> |
|
| 232 | + | </div> |
|
| 233 | + | ||
| 234 | + | <div> |
|
| 235 | + | <label for="lens" class="block text-sm mb-2">Lens</label> |
|
| 236 | + | <input |
|
| 237 | + | type="text" |
|
| 238 | + | id="lens" |
|
| 239 | + | name="lens" |
|
| 240 | + | bind:value={lens} |
|
| 241 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 242 | + | /> |
|
| 243 | + | </div> |
|
| 244 | + | ||
| 245 | + | <div> |
|
| 246 | + | <label for="aperture" class="block text-sm mb-2">Aperture</label> |
|
| 247 | + | <input |
|
| 248 | + | type="text" |
|
| 249 | + | id="aperture" |
|
| 250 | + | name="aperture" |
|
| 251 | + | bind:value={aperture} |
|
| 252 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 253 | + | /> |
|
| 254 | + | </div> |
|
| 255 | + | ||
| 256 | + | <div> |
|
| 257 | + | <label for="exposure" class="block text-sm mb-2">Exposure</label> |
|
| 258 | + | <input |
|
| 259 | + | type="text" |
|
| 260 | + | id="exposure" |
|
| 261 | + | name="exposure" |
|
| 262 | + | bind:value={exposure} |
|
| 263 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 264 | + | /> |
|
| 265 | + | </div> |
|
| 266 | + | ||
| 267 | + | <div> |
|
| 268 | + | <label for="focalLength" class="block text-sm mb-2">Focal Length</label> |
|
| 269 | + | <input |
|
| 270 | + | type="text" |
|
| 271 | + | id="focalLength" |
|
| 272 | + | name="focalLength" |
|
| 273 | + | bind:value={focalLength} |
|
| 274 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 275 | + | /> |
|
| 276 | + | </div> |
|
| 277 | + | ||
| 278 | + | <div> |
|
| 279 | + | <label for="iso" class="block text-sm mb-2">ISO</label> |
|
| 280 | + | <input |
|
| 281 | + | type="text" |
|
| 282 | + | id="iso" |
|
| 283 | + | name="iso" |
|
| 284 | + | bind:value={iso} |
|
| 285 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 286 | + | /> |
|
| 287 | + | </div> |
|
| 288 | + | </div> |
|
| 289 | + | </div> |
|
| 290 | + | ||
| 291 | + | {#if form?.error} |
|
| 292 | + | <p class="text-red-500 text-sm">{form.error}</p> |
|
| 293 | + | {/if} |
|
| 294 | + | ||
| 295 | + | <button |
|
| 296 | + | type="submit" |
|
| 297 | + | disabled={isLoading || !selectedFile} |
|
| 298 | + | class="w-full py-3 bg-white text-black font-medium rounded hover:bg-zinc-200 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" |
|
| 299 | + | > |
|
| 300 | + | {isLoading ? "Uploading..." : "Upload Photo"} |
|
| 301 | + | </button> |
|
| 302 | + | </form> |
|
| 303 | + | ||
| 304 | + | <p class="mt-6 text-center text-sm text-zinc-500"> |
|
| 305 | + | <a href="/" class="hover:text-white transition-colors">← Back to gallery</a> |
|
| 306 | + | </p> |
|
| 307 | + | </div> |
|
| 308 | + | </div> |
| 1 | + | import { redirect } from "@sveltejs/kit"; |
|
| 2 | + | import type { RequestHandler } from "./$types"; |
|
| 3 | + | ||
| 4 | + | export const GET: RequestHandler = async ({ cookies }) => { |
|
| 5 | + | cookies.delete("session", { path: "/" }); |
|
| 6 | + | throw redirect(302, "/"); |
|
| 7 | + | }; |
| 1 | + | import type { Actions, PageServerLoad } from "./$types"; |
|
| 2 | + | import { fail, redirect } from "@sveltejs/kit"; |
|
| 3 | + | import { verifyPassword, createSession } from "$lib/auth"; |
|
| 4 | + | ||
| 5 | + | export const load: PageServerLoad = async ({ locals }) => { |
|
| 6 | + | if (locals.user?.authenticated) { |
|
| 7 | + | throw redirect(302, "/admin"); |
|
| 8 | + | } |
|
| 9 | + | return {}; |
|
| 10 | + | }; |
|
| 11 | + | ||
| 12 | + | export const actions: Actions = { |
|
| 13 | + | default: async ({ request, platform, cookies }) => { |
|
| 14 | + | const data = await request.formData(); |
|
| 15 | + | const password = data.get("password"); |
|
| 16 | + | ||
| 17 | + | if (!password || typeof password !== "string") { |
|
| 18 | + | return fail(400, { error: "Password is required" }); |
|
| 19 | + | } |
|
| 20 | + | ||
| 21 | + | const secret = platform?.env?.SESSION_SECRET; |
|
| 22 | + | const passwordHash = platform?.env?.ADMIN_PASSWORD_HASH; |
|
| 23 | + | ||
| 24 | + | if (!secret || !passwordHash) { |
|
| 25 | + | return fail(500, { error: "Server configuration error" }); |
|
| 26 | + | } |
|
| 27 | + | ||
| 28 | + | const isValid = await verifyPassword(password, passwordHash, secret); |
|
| 29 | + | ||
| 30 | + | if (!isValid) { |
|
| 31 | + | return fail(401, { error: "Invalid password" }); |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | const session = await createSession(secret); |
|
| 35 | + | ||
| 36 | + | cookies.set("session", session, { |
|
| 37 | + | path: "/", |
|
| 38 | + | httpOnly: true, |
|
| 39 | + | secure: true, |
|
| 40 | + | sameSite: "strict", |
|
| 41 | + | maxAge: 60 * 60 * 24, // 24 hours |
|
| 42 | + | }); |
|
| 43 | + | ||
| 44 | + | throw redirect(302, "/admin"); |
|
| 45 | + | }, |
|
| 46 | + | }; |
| 1 | + | <script lang="ts"> |
|
| 2 | + | import { enhance } from "$app/forms"; |
|
| 3 | + | ||
| 4 | + | let { form } = $props(); |
|
| 5 | + | </script> |
|
| 6 | + | ||
| 7 | + | <svelte:head> |
|
| 8 | + | <title>Login</title> |
|
| 9 | + | </svelte:head> |
|
| 10 | + | ||
| 11 | + | <div class="min-h-screen flex items-center justify-center p-4"> |
|
| 12 | + | <div class="w-full max-w-sm"> |
|
| 13 | + | <h1 class="text-2xl font-bold mb-8 text-center">Admin Login</h1> |
|
| 14 | + | ||
| 15 | + | <form method="POST" use:enhance class="space-y-4"> |
|
| 16 | + | <div> |
|
| 17 | + | <label for="password" class="block text-sm mb-2">Password</label> |
|
| 18 | + | <input |
|
| 19 | + | type="password" |
|
| 20 | + | id="password" |
|
| 21 | + | name="password" |
|
| 22 | + | required |
|
| 23 | + | class="w-full px-4 py-2 bg-zinc-900 border border-zinc-700 rounded focus:outline-none focus:border-zinc-500" |
|
| 24 | + | /> |
|
| 25 | + | </div> |
|
| 26 | + | ||
| 27 | + | {#if form?.error} |
|
| 28 | + | <p class="text-red-500 text-sm">{form.error}</p> |
|
| 29 | + | {/if} |
|
| 30 | + | ||
| 31 | + | <button |
|
| 32 | + | type="submit" |
|
| 33 | + | class="w-full py-2 bg-white text-black font-medium rounded hover:bg-zinc-200 transition-colors" |
|
| 34 | + | > |
|
| 35 | + | Sign In |
|
| 36 | + | </button> |
|
| 37 | + | </form> |
|
| 38 | + | ||
| 39 | + | <p class="mt-6 text-center text-sm text-zinc-500"> |
|
| 40 | + | <a href="/" class="hover:text-white transition-colors">← Back to gallery</a> |
|
| 41 | + | </p> |
|
| 42 | + | </div> |
|
| 43 | + | </div> |