first commit
This commit is contained in:
45
Dockerfile
Normal file
45
Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
# --- STAGE 1: Build the application ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency configurations
|
||||
COPY package*.json ./
|
||||
|
||||
# Install all dependencies (including devDependencies needed for building)
|
||||
RUN npm ci
|
||||
|
||||
# Copy the rest of the application source code
|
||||
COPY . .
|
||||
|
||||
# Build the production application
|
||||
RUN npm run build
|
||||
|
||||
# Prune node_modules down to only production requirements
|
||||
RUN npm prune --production
|
||||
|
||||
|
||||
# --- STAGE 2: Run the application ---
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
# Set production configurations
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Create a secure non-root system user for security hardening
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -u 1001 -S svelteuser -G nodejs
|
||||
|
||||
# Copy essential production files from the builder stage
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/build ./build
|
||||
|
||||
# Switch execution contexts to the non-root user
|
||||
USER svelteuser
|
||||
|
||||
# Expose target internal application port
|
||||
EXPOSE 3000
|
||||
|
||||
# Fire up the SvelteKit Node cluster server
|
||||
CMD ["node", "build/index.js"]
|
||||
42
README.md
Normal file
42
README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```sh
|
||||
# create a new project
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
To recreate this project with the same configuration:
|
||||
|
||||
```sh
|
||||
# recreate this project
|
||||
npx sv@0.16.1 create --template minimal --no-types --add tailwindcss="plugins:typography" prettier --install yarn photogallery
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
13
jsconfig.json
Normal file
13
jsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": false,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
33
package.json
Normal file
33
package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "photogallery",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"lint": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^7.0.1",
|
||||
"@sveltejs/kit": "^2.63.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"prettier": "^3.8.3",
|
||||
"prettier-plugin-svelte": "^4.1.0",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"svelte": "^5.56.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.16"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1075.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1075.0",
|
||||
"@sveltejs/adapter-node": "^5.5.7"
|
||||
}
|
||||
}
|
||||
12
src/app.html
Normal file
12
src/app.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="text-scale" content="scale" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
16
src/hooks.server.js
Normal file
16
src/hooks.server.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export async function handle({ event, resolve }) {
|
||||
const session = event.cookies.get('gallery_session');
|
||||
const isLoginPage = event.url.pathname === '/login';
|
||||
|
||||
if (!session && !isLoginPage) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
if (session && isLoginPage) {
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
return await resolve(event);
|
||||
}
|
||||
1
src/lib/assets/favicon.svg
Normal file
1
src/lib/assets/favicon.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
1
src/lib/index.js
Normal file
1
src/lib/index.js
Normal file
@@ -0,0 +1 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
9
src/routes/+layout.svelte
Normal file
9
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
225
src/routes/+page.svelte
Normal file
225
src/routes/+page.svelte
Normal file
@@ -0,0 +1,225 @@
|
||||
<script>
|
||||
import { PUBLIC_GALLERY_NAME } from '$env/static/public';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
// Svelte 5 Runes for Reactivity
|
||||
let photos = $state([]);
|
||||
let nextToken = $state(null);
|
||||
let loading = $state(false);
|
||||
let hasMore = $state(true);
|
||||
let activePhoto = $state(null);
|
||||
let observerTarget;
|
||||
|
||||
// Touch tracking coordinates for swipes
|
||||
let touchStartX = 0;
|
||||
let touchEndX = 0;
|
||||
|
||||
// Find the current active index
|
||||
let activeIndex = $derived(
|
||||
activePhoto ? photos.findIndex(p => p.id === activePhoto.id) : -1
|
||||
);
|
||||
|
||||
async function loadPhotos() {
|
||||
if (loading || !hasMore) return;
|
||||
loading = true;
|
||||
|
||||
const url = new URL('/api/photos', window.location.origin);
|
||||
if (nextToken) url.searchParams.set('next', nextToken);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
if (data && Array.isArray(data.photos)) {
|
||||
photos = [...photos, ...data.photos];
|
||||
nextToken = data.nextContinuationToken;
|
||||
hasMore = !!nextToken;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load photos:', err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPhotos();
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && hasMore) {
|
||||
loadPhotos();
|
||||
}
|
||||
}, { rootMargin: '300px' });
|
||||
|
||||
if (observerTarget) observer.observe(observerTarget);
|
||||
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
// Navigation triggers
|
||||
function nextPhoto() {
|
||||
if (activeIndex !== -1 && activeIndex < photos.length - 1) {
|
||||
activePhoto = photos[activeIndex + 1];
|
||||
// Automatically pre-fetch next batch if user reaches near the end of loaded list
|
||||
if (activeIndex >= photos.length - 4) loadPhotos();
|
||||
}
|
||||
}
|
||||
|
||||
function prevPhoto() {
|
||||
if (activeIndex > 0) {
|
||||
activePhoto = photos[activeIndex - 1];
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard Handler
|
||||
function handleKeyDown(e) {
|
||||
if (!activePhoto) return;
|
||||
if (e.key === 'Escape') activePhoto = null;
|
||||
if (e.key === 'ArrowRight') nextPhoto();
|
||||
if (e.key === 'ArrowLeft') prevPhoto();
|
||||
}
|
||||
|
||||
// Swipe Handlers for Mobile Devices
|
||||
function handleTouchStart(e) {
|
||||
touchStartX = e.changedTouches[0].screenX;
|
||||
}
|
||||
|
||||
function handleTouchEnd(e) {
|
||||
touchEndX = e.changedTouches[0].screenX;
|
||||
handleSwipeGesture();
|
||||
}
|
||||
|
||||
function handleSwipeGesture() {
|
||||
const threshold = 50; // Minimum distance in pixels to count as a swipe
|
||||
if (touchStartX - touchEndX > threshold) {
|
||||
nextPhoto(); // Swiped left, view next
|
||||
} else if (touchEndX - touchStartX > threshold) {
|
||||
prevPhoto(); // Swiped right, view previous
|
||||
}
|
||||
}
|
||||
|
||||
// File Downloader
|
||||
async function downloadImage(url, filename) {
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const blob = await res.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
} catch (err) {
|
||||
console.error('Download failed', err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
<div class="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header class="sticky top-0 z-10 border-b border-zinc-800 bg-zinc-950/80 backdrop-blur-md px-6 py-4 flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold tracking-wide">{PUBLIC_GALLERY_NAME}</h1>
|
||||
<span class="text-xs text-zinc-500 uppercase tracking-widest bg-zinc-900 border border-zinc-800 px-3 py-1 rounded-full">Secure Session</span>
|
||||
</header>
|
||||
|
||||
<main class="p-4 sm:p-6 lg:p-8 max-w-[1600px] mx-auto">
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
|
||||
{#each photos as photo (photo.id)}
|
||||
<button
|
||||
class="group relative aspect-square overflow-hidden rounded-xl bg-zinc-900 border border-zinc-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onclick={() => activePhoto = photo}
|
||||
>
|
||||
<img
|
||||
src={photo.thumbUrl}
|
||||
alt="Gallery item"
|
||||
loading="lazy"
|
||||
class="h-full w-full object-cover object-center transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div class="absolute inset-0 bg-black/20 opacity-0 transition-opacity group-hover:opacity-100"></div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div bind:this={observerTarget} class="w-full flex justify-center py-12">
|
||||
{#if loading}
|
||||
<div class="h-6 w-6 animate-spin rounded-full border-2 border-zinc-500 border-t-transparent"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{#if activePhoto}
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black/95 p-4 transition-opacity select-none touch-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) activePhoto = null; }}
|
||||
onkeydown={handleKeyDown}
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchend={handleTouchEnd}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute left-0 top-0 bottom-0 w-[10%] z-30 cursor-w-resize focus:outline-none flex items-center justify-start pl-4 group"
|
||||
onclick={prevPhoto}
|
||||
aria-label="Previous photo"
|
||||
disabled={activeIndex === 0}
|
||||
>
|
||||
{#if activeIndex > 0}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-8 h-8 text-zinc-600 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5L8.25 12l7.5-7.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-0 top-0 bottom-0 w-[10%] z-30 cursor-e-resize focus:outline-none flex items-center justify-end pr-4 group"
|
||||
onclick={nextPhoto}
|
||||
aria-label="Next photo"
|
||||
disabled={activeIndex === photos.length - 1}
|
||||
>
|
||||
{#if activeIndex < photos.length - 1}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" class="w-8 h-8 text-zinc-600 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<div class="absolute top-4 right-4 flex items-center gap-3 z-50">
|
||||
<a
|
||||
href="/api/download?id={activePhoto.id}"
|
||||
class="rounded-full bg-zinc-900/80 p-3 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
||||
title="Download Original"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<button
|
||||
class="rounded-full bg-zinc-900/80 p-3 text-zinc-300 hover:text-white transition hover:bg-zinc-800"
|
||||
title="Close"
|
||||
onclick={() => activePhoto = null}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="relative max-h-[85vh] max-w-full flex items-center justify-center pointer-events-none">
|
||||
<img
|
||||
src={activePhoto.fullUrl}
|
||||
alt="High-resolution visual"
|
||||
class="max-h-[85vh] max-w-full object-contain rounded shadow-2xl transition-all duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-4 text-xs text-zinc-500 font-mono tracking-wider">{activePhoto.id}</p>
|
||||
</div>
|
||||
{/if}
|
||||
44
src/routes/api/download/+server.js
Normal file
44
src/routes/api/download/+server.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// src/routes/api/download/+server.js
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import {
|
||||
B2_ENDPOINT, B2_REGION, B2_ACCESS_KEY_ID, B2_SECRET_ACCESS_KEY, B2_BUCKET_NAME
|
||||
} from '$env/static/private';
|
||||
|
||||
const s3 = new S3Client({
|
||||
endpoint: B2_ENDPOINT,
|
||||
region: B2_REGION,
|
||||
credentials: {
|
||||
accessKeyId: B2_ACCESS_KEY_ID,
|
||||
secretAccessKey: B2_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
export async function GET({ url }) {
|
||||
const id = url.searchParams.get('id');
|
||||
if (!id) throw error(400, 'Missing photo identity ID');
|
||||
|
||||
try {
|
||||
const fullSizeKey = `images/${id}`;
|
||||
|
||||
// Create a command that forces the browser to treat this as an attachment download
|
||||
const command = new GetObjectCommand({
|
||||
Bucket: B2_BUCKET_NAME,
|
||||
Key: fullSizeKey,
|
||||
ResponseContentDisposition: `attachment; filename="${id}"`
|
||||
});
|
||||
|
||||
// Generate a quick 60-second valid link
|
||||
const downloadUrl = await getSignedUrl(s3, command, { expiresIn: 60 });
|
||||
|
||||
// Redirect the user directly to the freshly generated download link
|
||||
throw redirect(307, downloadUrl);
|
||||
} catch (err) {
|
||||
// SvelteKit uses redirect exceptions, so let them pass through cleanly
|
||||
if (err.status === 307) throw err;
|
||||
|
||||
console.error('Server download redirection failed:', err);
|
||||
throw error(500, 'Could not generate download routing asset.');
|
||||
}
|
||||
}
|
||||
66
src/routes/api/photos/+server.js
Normal file
66
src/routes/api/photos/+server.js
Normal file
@@ -0,0 +1,66 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { S3Client, ListObjectsV2Command, GetObjectCommand } from '@aws-sdk/client-s3';
|
||||
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
|
||||
import {
|
||||
B2_ENDPOINT, B2_REGION, B2_ACCESS_KEY_ID, B2_SECRET_ACCESS_KEY, B2_BUCKET_NAME
|
||||
} from '$env/static/private';
|
||||
|
||||
const s3 = new S3Client({
|
||||
endpoint: B2_ENDPOINT,
|
||||
region: B2_REGION,
|
||||
credentials: {
|
||||
accessKeyId: B2_ACCESS_KEY_ID,
|
||||
secretAccessKey: B2_SECRET_ACCESS_KEY
|
||||
}
|
||||
});
|
||||
|
||||
export async function GET({ url }) {
|
||||
const nextToken = url.searchParams.get('next') || undefined;
|
||||
|
||||
try {
|
||||
// 1. Fetch a batch of files from the thumbs/ folder
|
||||
const listCommand = new ListObjectsV2Command({
|
||||
Bucket: B2_BUCKET_NAME,
|
||||
Prefix: 'thumbs/',
|
||||
MaxKeys: 40, // Increased slight headroom since we filter on the server side now
|
||||
ContinuationToken: nextToken
|
||||
});
|
||||
|
||||
const listResponse = await s3.send(listCommand);
|
||||
const contents = listResponse.Contents || [];
|
||||
|
||||
// 2. Strict Filter: Only allow actual .jpg or .jpeg extensions
|
||||
const imageFiles = contents.filter(file => {
|
||||
const lowerKey = file.Key.toLowerCase();
|
||||
return lowerKey.endsWith('.jpg') || lowerKey.endsWith('.jpeg');
|
||||
});
|
||||
|
||||
// 3. Generate presigned URLs for validated items
|
||||
const photos = await Promise.all(
|
||||
imageFiles.map(async (file) => {
|
||||
const id = file.Key.replace('thumbs/', '');
|
||||
const fullSizeKey = `images/${id}`;
|
||||
|
||||
const thumbCommand = new GetObjectCommand({ Bucket: B2_BUCKET_NAME, Key: file.Key });
|
||||
const fullCommand = new GetObjectCommand({ Bucket: B2_BUCKET_NAME, Key: fullSizeKey });
|
||||
|
||||
// 10 minutes = 600 seconds
|
||||
const [thumbUrl, fullUrl] = await Promise.all([
|
||||
getSignedUrl(s3, thumbCommand, { expiresIn: 600 }),
|
||||
getSignedUrl(s3, fullCommand, { expiresIn: 600 })
|
||||
]);
|
||||
|
||||
return { id, thumbUrl, fullUrl };
|
||||
})
|
||||
);
|
||||
|
||||
return json({
|
||||
photos,
|
||||
// Pass the original continuation token back so pagination structural flow remains intact
|
||||
nextContinuationToken: listResponse.NextContinuationToken || null
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw error(500, 'Failed to process images from storage.');
|
||||
}
|
||||
}
|
||||
2
src/routes/layout.css
Normal file
2
src/routes/layout.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/typography';
|
||||
22
src/routes/login/+page.server.js
Normal file
22
src/routes/login/+page.server.js
Normal file
@@ -0,0 +1,22 @@
|
||||
// src/routes/login/+page.server.js
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { GALLERY_PASSWORD } from '$env/static/private';
|
||||
|
||||
export const actions = {
|
||||
default: async ({ request, cookies }) => {
|
||||
const data = await request.formData();
|
||||
const password = data.get('password');
|
||||
|
||||
if (password === GALLERY_PASSWORD) {
|
||||
cookies.set('gallery_session', 'authenticated', {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'strict',
|
||||
maxAge: 60 * 60 * 24 * 7 // 1 week
|
||||
});
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
return fail(401, { incorrect: true });
|
||||
}
|
||||
};
|
||||
33
src/routes/login/+page.svelte
Normal file
33
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script>
|
||||
import { enhance } from '$app/forms';
|
||||
import { PUBLIC_GALLERY_NAME } from '$env/static/public';
|
||||
let form = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex min-h-screen items-center justify-center bg-zinc-950 px-4 text-zinc-100">
|
||||
<div class="w-full max-w-md rounded-2xl border border-zinc-800 bg-zinc-900 p-8 shadow-xl">
|
||||
<h1 class="mb-2 text-2xl font-bold tracking-tight">{PUBLIC_GALLERY_NAME}</h1>
|
||||
<p class="mb-6 text-sm text-zinc-400">Please enter the password to access the photos.</p>
|
||||
|
||||
<form method="POST" use:enhance class="space-y-4">
|
||||
<div>
|
||||
<input
|
||||
type="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
required
|
||||
class="w-full rounded-lg border border-zinc-700 bg-zinc-800 px-4 py-3 text-white placeholder-zinc-500 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 transition"
|
||||
/>
|
||||
</div>
|
||||
{#if form?.incorrect}
|
||||
<p class="text-sm text-red-400">Incorrect password. Please try again.</p>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full rounded-lg bg-blue-600 py-3 font-semibold text-white transition hover:bg-blue-500 active:bg-blue-700"
|
||||
>
|
||||
Unlock Gallery
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
3
static/robots.txt
Normal file
3
static/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node'; // Change from '@sveltejs/adapter-auto'
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-vite';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
},
|
||||
preprocess: vitePreprocess()
|
||||
};
|
||||
|
||||
export default config;
|
||||
21
vite.config.js
Normal file
21
vite.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit({
|
||||
compilerOptions: {
|
||||
// Force runes mode for the project, except for libraries. Can be removed in svelte 6.
|
||||
runes: ({ filename }) => filename.split(/[/\\]/).includes('node_modules') ? undefined : true
|
||||
},
|
||||
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
})
|
||||
]
|
||||
});
|
||||
Reference in New Issue
Block a user