···11+# Your ATProto DID (Decentralized Identifier)
22+# You can find this in your Bluesky profile settings or at https://bsky.app
33+PUBLIC_ATPROTO_DID=did:plc:your-did-here
44+55+# Enable WhiteWind support (optional)
66+# Set to "true" to check WhiteWind for blog posts, "false" to disable
77+# If disabled, only Leaflet posts will be fetched and redirected
88+# Default: false
99+PUBLIC_ENABLE_WHITEWIND=false
1010+1111+# Fallback URL (optional)
1212+# If a document cannot be found on WhiteWind or Leaflet, redirect here
1313+# Example: https://archive.example.com
1414+# Leave empty to return a 404 error instead
1515+PUBLIC_BLOG_FALLBACK_URL=""
1616+1717+# Publication to Slug Mapping
1818+# Configure your publication slugs in src/lib/config/slugs.ts
1919+# This allows you to access publications via friendly URLs like /blog, /notes, etc.
2020+# Example: { slug: 'blog', publicationRkey: '3m3x4bgbsh22k' }
2121+#
2222+# Each publication in Leaflet can have its own base_path configured, which will be
2323+# automatically used when redirecting. If no base_path is set, the system falls back
2424+# to the standard Leaflet URL format (https://leaflet.pub/lish/{did}/{rkey}).
2525+2626+# If you have `com.whtwnd.blog.entry` records in your AT Protocol
2727+# repository, they will also be fetched and displayed on your website
2828+# alongside your Leaflet posts.
2929+# The WhiteWind posts are always linked to using the following format:
3030+# https://whtwnd.com/[did]/[rkey].
3131+3232+# Slingshot Configuration (optional)
3333+# Local Slingshot instance for development - primary source for AT Protocol data
3434+# Set to your local Slingshot instance URL (default: http://localhost:3000)
3535+# Leave empty to skip local Slingshot and use public Slingshot directly
3636+PUBLIC_LOCAL_SLINGSHOT_URL="http://localhost:3000"
3737+3838+# Public Slingshot instance - fallback if local is unavailable
3939+# Default: https://slingshot.microcosm.blue
4040+PUBLIC_SLINGSHOT_URL="https://slingshot.microcosm.blue"
4141+4242+# Site Metadata (for SEO and social sharing)
4343+PUBLIC_SITE_TITLE="Your Site Title"
4444+PUBLIC_SITE_DESCRIPTION="Your site description"
4545+PUBLIC_SITE_KEYWORDS="your, keywords, here"
4646+PUBLIC_SITE_URL="https://your-site-url.com"
4747+4848+# CORS Configuration (for API endpoints)
4949+# Comma-separated list of allowed origins for CORS
5050+# Use "*" to allow all origins (not recommended for production)
5151+# Example: https://example.com,https://app.example.com
5252+PUBLIC_CORS_ALLOWED_ORIGINS="https://your-site-url.com"
+61
README.md
···111111PUBLIC_SITE_DESCRIPTION="Your site description"
112112PUBLIC_SITE_KEYWORDS="keywords, separated, by, commas"
113113PUBLIC_SITE_URL="https://example.com"
114114+115115+# CORS Configuration (for API endpoints)
116116+# Comma-separated list of allowed origins for CORS
117117+# Use "*" to allow all origins (not recommended for production)
118118+# Example: https://example.com,https://app.example.com
119119+PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com"
114120```
115121116122### Publication Slug Mappings (`src/lib/config/slugs.ts`)
···371377```
372378373379The card will automatically display your current or last played track.
380380+381381+## 🔐 CORS Configuration
382382+383383+The API endpoints support Cross-Origin Resource Sharing (CORS) via dynamic configuration:
384384+385385+### Environment Variable
386386+387387+```ini
388388+# Single origin
389389+PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com"
390390+391391+# Multiple origins (comma-separated)
392392+PUBLIC_CORS_ALLOWED_ORIGINS="https://example.com,https://app.example.com,https://www.example.com"
393393+394394+# Allow all origins (not recommended for production)
395395+PUBLIC_CORS_ALLOWED_ORIGINS="*"
396396+```
397397+398398+### How It Works
399399+400400+1. **Dynamic Origin Matching**: The server checks the `Origin` header against the allowed list
401401+2. **Preflight Requests**: OPTIONS requests are handled automatically with proper CORS headers
402402+3. **Security**: Only specified origins receive CORS headers (unless using `*`)
403403+4. **Headers Set**:
404404+ - `Access-Control-Allow-Origin`: The requesting origin (if allowed)
405405+ - `Access-Control-Allow-Methods`: GET, POST, PUT, DELETE, OPTIONS
406406+ - `Access-Control-Allow-Headers`: Content-Type, Authorization
407407+ - `Access-Control-Max-Age`: 86400 (24 hours)
408408+409409+### API Endpoints
410410+411411+CORS is automatically applied to all routes under `/api/`:
412412+413413+- `/api/artwork` - Album artwork fetching service
414414+415415+### Testing CORS
416416+417417+```bash
418418+# Test from command line
419419+curl -H "Origin: https://example.com" \
420420+ -H "Access-Control-Request-Method: GET" \
421421+ -H "Access-Control-Request-Headers: Content-Type" \
422422+ -X OPTIONS \
423423+ http://localhost:5173/api/artwork
424424+425425+# Check response headers for:
426426+# Access-Control-Allow-Origin: https://example.com
427427+```
428428+429429+### Security Recommendations
430430+431431+1. **Production**: Specify exact allowed origins instead of using `*`
432432+2. **Development**: Use `*` or localhost origins for testing
433433+3. **Multiple Domains**: List all your domains that need API access
434434+4. **HTTPS Only**: Always use HTTPS origins in production
374435375436## 🎨 Styling
376437
+48
src/hooks.server.ts
···11import type { Handle } from '@sveltejs/kit';
22+import { PUBLIC_CORS_ALLOWED_ORIGINS } from '$env/static/public';
2344+/**
55+ * Global request handler with CORS support
66+ *
77+ * CORS headers are dynamically configured via the PUBLIC_CORS_ALLOWED_ORIGINS environment variable.
88+ * Set it to a comma-separated list of allowed origins, or "*" to allow all origins.
99+ */
310export const handle: Handle = async ({ event, resolve }) => {
1111+ // Handle OPTIONS preflight requests for CORS
1212+ if (event.request.method === 'OPTIONS' && event.url.pathname.startsWith('/api/')) {
1313+ const origin = event.request.headers.get('origin');
1414+ const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [];
1515+1616+ const headers: Record<string, string> = {
1717+ 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
1818+ 'Access-Control-Allow-Headers': 'Content-Type, Authorization',
1919+ 'Access-Control-Max-Age': '86400'
2020+ };
2121+2222+ if (allowedOrigins.includes('*')) {
2323+ headers['Access-Control-Allow-Origin'] = '*';
2424+ } else if (origin && allowedOrigins.includes(origin)) {
2525+ headers['Access-Control-Allow-Origin'] = origin;
2626+ headers['Vary'] = 'Origin';
2727+ }
2828+2929+ return new Response(null, { status: 204, headers });
3030+ }
3131+432 const response = await resolve(event, {
533 filterSerializedResponseHeaders: (name) => {
634 return name === 'content-type' || name.startsWith('x-');
735 }
836 });
3737+3838+ // Add CORS headers for API routes
3939+ if (event.url.pathname.startsWith('/api/')) {
4040+ const origin = event.request.headers.get('origin');
4141+ const allowedOrigins = PUBLIC_CORS_ALLOWED_ORIGINS?.split(',').map(o => o.trim()) || [];
4242+4343+ // If * is specified, allow any origin
4444+ if (allowedOrigins.includes('*')) {
4545+ response.headers.set('Access-Control-Allow-Origin', '*');
4646+ } else if (origin && allowedOrigins.includes(origin)) {
4747+ // Only set the specific origin if it's in the allowed list
4848+ response.headers.set('Access-Control-Allow-Origin', origin);
4949+ response.headers.set('Vary', 'Origin');
5050+ }
5151+5252+ response.headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
5353+ response.headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
5454+ response.headers.set('Access-Control-Max-Age', '86400'); // 24 hours
5555+ }
5656+957 return response;
1058};