My personal photography website steve.phot
portfolio photography svelte sveltekit

feat: added auth and photo uploads

+703 -1
+9
.dev.vars.example
··· 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
+1
.gitignore
··· 21 21 # Vite 22 22 vite.config.js.timestamp-* 23 23 vite.config.ts.timestamp-* 24 + .dev.vars
+3
bun.lock
··· 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
+1
package.json
··· 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 }
+46
scripts/hash-password.ts
··· 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`);
+5 -1
src/app.d.ts
··· 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 }
+16
src/hooks.server.ts
··· 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 + };
+91
src/lib/auth.ts
··· 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 + }
+9
src/routes/admin/+layout.server.ts
··· 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 + };
+118
src/routes/admin/+page.server.ts
··· 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 + };
+308
src/routes/admin/+page.svelte
··· 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">&larr; Back to gallery</a> 306 + </p> 307 + </div> 308 + </div>
+7
src/routes/api/logout/+server.ts
··· 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 + };
+46
src/routes/login/+page.server.ts
··· 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 + };
+43
src/routes/login/+page.svelte
··· 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">&larr; Back to gallery</a> 41 + </p> 42 + </div> 43 + </div>