diff --git a/www/public/js/params.js b/www/public/js/params.js new file mode 100644 index 0000000..12de2e1 --- /dev/null +++ b/www/public/js/params.js @@ -0,0 +1,45 @@ +!function() { + var p = new URLSearchParams(location.search); + + var just = p.get('just'); + if (just && /^[a-z0-9-]+$/.test(just)) { + document.documentElement.dataset.just = just; + var css = 'section[data-section]:not([data-section="' + just + '"]){display:none}' + + ' section[data-section="' + just + '"] .section-label{pointer-events:none;text-decoration:none;color:inherit}'; + document.head.appendChild(Object.assign(document.createElement('style'), { textContent: css })); + } + + var act = p.get('do'); + var urls = window.__urls; + if (urls && urls.length) { + if (act === 'random') { + var url = urls[Math.floor(Math.random() * urls.length)]; + location.replace(url.startsWith('http') ? url : location.origin + url); + } + if (act === 'newest') { + location.replace(urls[0].startsWith('http') ? urls[0] : location.origin + urls[0]); + } + } + if (act === 'admin') { + location.replace('/admin'); + } + + var has = p.get('has'); + if (has) { + document.documentElement.dataset.has = has; + has = has.toLowerCase(); + document.addEventListener('DOMContentLoaded', function() { + document.querySelectorAll('section[data-section] pre').forEach(function(pre) { + var lines = pre.innerHTML.split('\n'); + pre.innerHTML = lines.filter(function(line) { + return !line.trim() || line.toLowerCase().indexOf(has) !== -1; + }).join('\n'); + }); + document.querySelectorAll('.guestbook-entry').forEach(function(entry) { + if (entry.textContent.toLowerCase().indexOf(has) === -1) { + entry.style.display = 'none'; + } + }); + }); + } +}(); diff --git a/www/src/layouts/Layout.astro b/www/src/layouts/Layout.astro index a09d6ef..36a569b 100644 --- a/www/src/layouts/Layout.astro +++ b/www/src/layouts/Layout.astro @@ -5,17 +5,25 @@ interface Props { title: string; showHeader?: boolean; isHome?: boolean; + urls?: string[]; } -const { title, showHeader = true, isHome = false } = Astro.props; +const { title, showHeader = true, isHome = false, urls = [] } = Astro.props; --- -{title} + + + + {title} + + {urls.length > 0 && } + + {showHeader && (
-
{isHome ? 'lewis m.w.' : lewis m.w.}  mail gh rss sitemap random
+
{isHome ? lewis m.w.lewis m.w. : lewis m.w.}  mail gh rss sitemap random
)} diff --git a/www/src/lib/rate-limit.ts b/www/src/lib/rate-limit.ts new file mode 100644 index 0000000..6f51ca5 --- /dev/null +++ b/www/src/lib/rate-limit.ts @@ -0,0 +1,15 @@ +const requests = new Map(); + +export function isRateLimited(key: string, maxRequests: number, windowMs: number): boolean { + const now = Date.now(); + const timestamps = requests.get(key) ?? []; + const recent = timestamps.filter(t => now - t < windowMs); + + if (recent.length >= maxRequests) { + return true; + } + + recent.push(now); + requests.set(key, recent); + return false; +} diff --git a/www/src/pages/[slug].astro b/www/src/pages/[slug].astro index c8c281e..caeea05 100644 --- a/www/src/pages/[slug].astro +++ b/www/src/pages/[slug].astro @@ -25,9 +25,9 @@ const related = post.data.related ? resolveRelatedPosts(post.data.related, allPo {related.length > 0 && ( -
- related +
+
 formatListItem(p.dates.created, `/${getSlug(p.id)}`, p.data.title)).join('\n')} />
-  
+ )} diff --git a/www/src/pages/api/guestbook.ts b/www/src/pages/api/guestbook.ts index 79a2d04..962ebf5 100644 --- a/www/src/pages/api/guestbook.ts +++ b/www/src/pages/api/guestbook.ts @@ -1,11 +1,17 @@ import type { APIRoute } from 'astro'; import { createEntry } from '../../lib/db'; import { jsonResponse, errorResponse } from '../../lib/api'; +import { isRateLimited } from '../../lib/rate-limit'; export const prerender = false; export const POST: APIRoute = async ({ request }) => { try { + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'; + if (isRateLimited(ip, 3, 60_000)) { + return errorResponse('Too many requests, try again later', 429); + } + const data = await request.json(); const { name, message, url } = data; diff --git a/www/src/pages/bookmarks/index.astro b/www/src/pages/bookmarks/index.astro deleted file mode 100644 index 73f8438..0000000 --- a/www/src/pages/bookmarks/index.astro +++ /dev/null @@ -1,16 +0,0 @@ ---- -import { getCollection } from 'astro:content'; -import Layout from '../../layouts/Layout.astro'; -import { formatListItem, extractDomain } from '../../lib/format'; - -const bookmarksCollection = await getCollection('bookmarks'); -const bookmarks = bookmarksCollection - .sort((a, b) => b.data.date.getTime() - a.data.date.getTime()); ---- - - -
- bookmarks -
 formatListItem(b.data.date, b.data.url, b.data.title, { suffix: `(${extractDomain(b.data.url)})` })).join('\n')} />
-
-
diff --git a/www/src/pages/guestbook/index.astro b/www/src/pages/guestbook/index.astro deleted file mode 100644 index 92ecdbf..0000000 --- a/www/src/pages/guestbook/index.astro +++ /dev/null @@ -1,35 +0,0 @@ ---- -import Layout from '../../layouts/Layout.astro'; -import { getApprovedEntries, type GuestbookEntry } from '../../lib/db'; -import { formatDate } from '../../lib/format'; - -let guestbookEntries: GuestbookEntry[] = []; -try { - guestbookEntries = await getApprovedEntries(); -} catch { - // DB not available during dev without env vars -} ---- - - -
- guestbook -
- {guestbookEntries.map(e => ( -
- {formatDate(e.createdAt)} - ${e.name}` : e.name} /> {e.message} -
- ))} -
- - sign -
-
-
- - -
diff --git a/www/src/pages/index.astro b/www/src/pages/index.astro index 00eeea0..19c56fb 100644 --- a/www/src/pages/index.astro +++ b/www/src/pages/index.astro @@ -22,56 +22,57 @@ try { } catch { // DB not available during dev without env vars } + +const urls = [ + ...posts.map(post => ({ url: `/${getSlug(post.id)}`, date: post.dates.created.getTime() })), + ...txtFiles.map(f => ({ url: `/${f.name}`, date: f.date.getTime() })), + ...bookmarksCollection.map(b => ({ url: b.data.url, date: b.data.date.getTime() })), +].sort((a, b) => b.date - a.date).map(e => e.url); --- - + {sortedCategories.map(category => { const categoryPosts = grouped[category]; return ( -
- {category} -
 formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })),
-  ...(categoryPosts.length > 10 ? [`+${categoryPosts.length - 10} more`] : [])
-].join('\n')} />
-
+
+ +
 formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })).join('\n')} />
+
); })} -
- txt -
 formatListItem(f.date, `/${f.name}`, f.name, { pinned: f.pinned })),
-  ...(txtFiles.length > 10 ? [`+${txtFiles.length - 10} more`] : [])
-].join('\n')} />
-
+
+ +
 formatListItem(f.date, `/${f.name}`, f.name, { pinned: f.pinned })).join('\n')} />
+
-
- bookmarks -
 formatListItem(b.data.date, b.data.url, b.data.title, { suffix: `(${extractDomain(b.data.url)})` })),
-  ...(bookmarks.length > 10 ? [`+${bookmarks.length - 10} more`] : [])
-].join('\n')} />
-
+
+ +
 formatListItem(b.data.date, b.data.url, b.data.title, { suffix: `(${extractDomain(b.data.url)})` })).join('\n')} />
+
-
- guestbook +
+
- {guestbookEntries.slice(0, 10).map(e => ( + {guestbookEntries.map(e => (
{formatDate(e.createdAt)} ${e.name}` : e.name} /> {e.message}
))} -
- {guestbookEntries.length > 10 && +{guestbookEntries.length - 10} more} - sign -
-
+
+
+
+
+ + +
+
diff --git a/www/src/pages/md/index.astro b/www/src/pages/md/index.astro deleted file mode 100644 index fc3b0e3..0000000 --- a/www/src/pages/md/index.astro +++ /dev/null @@ -1,19 +0,0 @@ ---- -import { getCollection } from 'astro:content'; -import Layout from '../../layouts/Layout.astro'; -import { formatListItem } from '../../lib/format'; -import { organizePostsByCategory, getSlug, enrichPostsWithDates } from '../../lib/md'; - -const rawPosts = await getCollection('md'); -const posts = enrichPostsWithDates(rawPosts); -const { grouped, categories: sortedCategories } = organizePostsByCategory(posts); ---- - - -{sortedCategories.map(category => ( -
- {category} -
 formatListItem(post.dates.created, `/${getSlug(post.id)}`, post.data.title, { pinned: post.data.pinned })).join('\n')} />
-
-))} -
diff --git a/www/src/pages/random.ts b/www/src/pages/random.ts deleted file mode 100644 index ab17428..0000000 --- a/www/src/pages/random.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { getCollection } from 'astro:content'; -import type { APIContext } from 'astro'; -import { getSlug } from '../lib/md'; -import { getTxtFileNames } from '../lib/txt'; - -export const prerender = false; - -export async function GET(context: APIContext) { - const site = context.site?.origin ?? 'https://wynne.rs'; - const posts = await getCollection('md'); - const bookmarks = await getCollection('bookmarks'); - const txtFiles = getTxtFileNames(); - - const urls = [ - ...posts.map(post => `/${getSlug(post.id)}`), - ...txtFiles.map(txt => `/${txt}`), - ...bookmarks.map(b => b.data.url), - ]; - - const random = urls[Math.floor(Math.random() * urls.length)]; - const redirectUrl = random.startsWith('http') ? random : `${site}${random}`; - - return Response.redirect(redirectUrl, 302); -} diff --git a/www/src/pages/sitemap.txt.ts b/www/src/pages/sitemap.txt.ts index af030c5..464b8ad 100644 --- a/www/src/pages/sitemap.txt.ts +++ b/www/src/pages/sitemap.txt.ts @@ -15,10 +15,7 @@ export async function GET(context: APIContext) { const urls = [ '/', ...posts.map(post => `/${getSlug(post.id)}`), - '/txt', ...txtFiles.map(txt => `/${txt}`), - '/bookmarks', - '/guestbook', ].map(p => `${site}${p}`); return new Response([...urls, ...SUBDOMAINS].join('\n'), { diff --git a/www/src/pages/txt/index.astro b/www/src/pages/txt/index.astro deleted file mode 100644 index 4b55aa7..0000000 --- a/www/src/pages/txt/index.astro +++ /dev/null @@ -1,14 +0,0 @@ ---- -import Layout from '../../layouts/Layout.astro'; -import { formatListItem } from '../../lib/format'; -import { getTxtFiles } from '../../lib/txt'; - -const txtFiles = getTxtFiles(); ---- - - -
- txt -
 formatListItem(f.date, `/${f.name}`, f.name, { pinned: f.pinned })).join('\n')} />
-
-
diff --git a/www/src/scripts/guestbook-sign.ts b/www/src/scripts/guestbook-sign.ts index b9cdf47..b04befb 100644 --- a/www/src/scripts/guestbook-sign.ts +++ b/www/src/scripts/guestbook-sign.ts @@ -1,30 +1,42 @@ -export function initGuestbookSign() { - document.getElementById('sign-guestbook')?.addEventListener('click', async (e) => { +export function initGuestbookForm() { + const form = document.getElementById('guestbook-form') as HTMLFormElement | null; + if (!form) return; + + const status = document.getElementById('guestbook-status')!; + + form.addEventListener('submit', async (e) => { e.preventDefault(); - const status = document.getElementById('guestbook-status')!; + status.textContent = ''; - const name = prompt('name:'); - if (!name) return; + const data = new FormData(form); + const name = (data.get('name') as string).trim(); + const message = (data.get('message') as string).trim(); + const url = (data.get('url') as string).trim() || null; - const message = prompt('message:'); - if (!message) return; + if (!name || !message) return; - const url = prompt('url (optional):'); + const button = form.querySelector('button')!; + button.disabled = true; try { const res = await fetch('/api/guestbook', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, message, url: url || null }), + body: JSON.stringify({ name, message, url }), }); if (res.ok) { status.textContent = ' thanks! pending approval.'; + form.reset(); + } else if (res.status === 429) { + status.textContent = ' too many requests, try later.'; } else { status.textContent = ' error'; } } catch { status.textContent = ' failed'; + } finally { + button.disabled = false; } }); } diff --git a/www/src/styles/global.css b/www/src/styles/global.css index 8dfeead..3191ef4 100644 --- a/www/src/styles/global.css +++ b/www/src/styles/global.css @@ -72,21 +72,31 @@ div.grid .content { display: block; } -details { +section { margin: 1rem 0; } -summary { - cursor: pointer; +section .section-label { font-family: monospace; - list-style: none; } -summary::-webkit-details-marker { +.home-name-link { display: none; } -details pre { +html[data-just] .home-name { + display: none; +} + +html[data-just] .home-name-link { + display: inline; +} + +html[data-has] .guestbook-form { + display: none; +} + +section pre { margin: 0; } @@ -99,3 +109,9 @@ details pre { grid-template-columns: 8ch 1fr; gap: 0 4ch; } + +.guestbook-form { + margin-top: 0.5rem; + margin-left: 12ch; + font-family: monospace; +}