feat: added auth and photo uploads eb3406b9
Steve · 2026-01-25 16:38 14 file(s) · +703 −1
.dev.vars.example (added) +9 −0
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
.gitignore +1 −0
21 21
# Vite
22 22
vite.config.js.timestamp-*
23 23
vite.config.ts.timestamp-*
24 +
.dev.vars
bun.lock +3 −0
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
package.json +1 −0
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
	}
scripts/hash-password.ts (added) +46 −0
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`);
src/app.d.ts +5 −1
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
	}
src/hooks.server.ts (added) +16 −0
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 +
};
src/lib/auth.ts (added) +91 −0
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 +
}
src/routes/admin/+layout.server.ts (added) +9 −0
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 +
};
src/routes/admin/+page.server.ts (added) +118 −0
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 +
};
src/routes/admin/+page.svelte (added) +308 −0
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>
src/routes/api/logout/+server.ts (added) +7 −0
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 +
};
src/routes/login/+page.server.ts (added) +46 −0
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 +
};
src/routes/login/+page.svelte (added) +43 −0
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>