diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ee072c2 --- /dev/null +++ b/.env.example @@ -0,0 +1,21 @@ +# Required: random secret used to salt IP hashes and sign sessions +# Generate with: openssl rand -hex 32 +SECRET_KEY=replace_me_with_a_random_secret +# Required: admin login token for /admin/login +ADMIN_SECRET_TOKEN=replace_me_with_a_long_random_token + +# Optional: force cookie secure behavior (`true` or `false`) +# Leave unset for automatic behavior based on NODE_ENV +# COOKIE_SECURE= + +# Database path (Docker mounts /data as a named volume) +DB_PATH=/data/guestbook.db + +# Server binding +HOST=0.0.0.0 +PORT=3000 + +# --- Development overrides --- +# For local dev (npm run dev), override with: +# COOKIE_SECURE=false +# DB_PATH=./data/guestbook.db diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 5fb7088..e839adf 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -5,7 +5,7 @@ # # Detection logic: # 1. Python: if requirements.txt exists → install deps, lint, test. -# 2. Node/JS: if package.json exists → npm ci, lint, test, build. +# 2. Node/JS: if package.json exists → install deps, lint, test, build. # 3. Neither detected → print a message and exit 0 (never fail). # # Controlled by .ci/config.env: @@ -194,11 +194,14 @@ jobs: if: env.HAS_NODE == 'true' uses: actions/setup-node@v4 with: - node-version: "lts/*" + # Keep CI on Node 20 to match runtime/Docker and better-sqlite3 compatibility. + node-version: "20.x" - name: Install Node dependencies if: env.HAS_NODE == 'true' - run: npm ci + run: | + # Lockfile is currently not authoritative; use install to refresh dependency tree. + npm install # ----------------------------------------------------------------------- # Step 9: Node.js — Lint (only if "lint" script exists in package.json) diff --git a/.gitignore b/.gitignore index 28a0719..5a440b4 100644 --- a/.gitignore +++ b/.gitignore @@ -62,3 +62,9 @@ Thumbs.db !.env.example *.pem *.key + +# ---- Guestbook SQLite database (use Docker volume in production) ---- +data/ +*.db +*.db-wal +*.db-shm diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..209e3ef --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +20 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..6a8ec35 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,169 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Project Quick Reference + +**Project:** Cozy Den - Personal landing page for hiddenden.cafe +**Owner:** Latte (gay furry developer, values self-hosting and privacy) +**Tech Stack:** Astro 4.x, TypeScript, Vanilla CSS, Docker + Nginx +**Aesthetic:** Warm coffee/cappuccino theme, cozy hidden den vibes +**Deployment:** Docker containers pushed to Gitea registry at git.hiddenden.cafe + +## Core Design Principles + +1. **Cozy Aesthetic** - Warm colors, coffee/cappuccino theme, hidden den vibes +2. **Self-Hosted** - Everything runs on personal infrastructure (homelab/VPS) +3. **Privacy First** - No tracking, no external dependencies +4. **Lightweight** - Static HTML/CSS, minimal JavaScript +5. **Docker-Ready** - Easy deployment via containers + +## File Structure + +``` +cozy-den/ +├── src/ +│ ├── layouts/ +│ │ └── BaseLayout.astro # Base layout + global styles +│ └── pages/ +│ ├── index.astro # Main landing page +│ └── 404.astro # Custom 404 page +├── public/ +│ ├── favicon.svg # Coffee emoji favicon +│ └── robots.txt # Search engine directives +├── astro.config.mjs # Astro config with sitemap +├── package.json # Dependencies (Astro 4.x, @astrojs/sitemap) +├── Dockerfile # Multi-stage: Node builder + Nginx +├── docker-compose.yml # Local container orchestration +└── nginx.conf # Production web server config +``` + +## Architecture Notes + +This is a simple static site following standard Astro conventions: +- Layouts in `src/layouts/` for reusable page templates +- Pages in `src/pages/` (routes automatically based on filename) +- All content is on a single page (`index.astro`) with multiple sections +- Custom 404 page with cozy theming +- No client-side JavaScript - pure static HTML/CSS output +- CSS custom properties centralized in `BaseLayout.astro` for theming +- Accessibility improvements with ARIA labels and semantic HTML + +## Commands + +```bash +# Development +npm install # Install dependencies +npm run dev # Start dev server at http://localhost:4321 +npm run build # Build for production (runs astro check + astro build) +npm run preview # Preview production build + +# Docker +docker build -t cozy-den . +docker run -d -p 3000:80 --name cozy-den cozy-den +docker-compose up -d + +# Deployment to Gitea registry +docker tag cozy-den git.hiddenden.cafe/mats/cozy-den:latest +docker login git.hiddenden.cafe +docker push git.hiddenden.cafe/mats/cozy-den:latest +``` + +## Color System + +All colors use CSS custom properties in `BaseLayout.astro`: + +```css +--color-bg: #1a1410 /* Dark background (deep coffee) */ +--color-bg-light: #2a1f18 /* Lighter background for cards */ +--color-text: #f4e9d8 /* Cream text */ +--color-text-dim: #c4b5a0 /* Dimmed text */ +--color-accent: #d4a574 /* Warm accent (coffee with cream) */ +--color-accent-bright: #e8bf8e /* Brighter accent for highlights */ +--color-warm: #8b6f47 /* Warm brown for borders/accents */ +``` + +**To change theme:** Edit these variables. All components update automatically. + +## Common Modification Patterns + +### Adding a Section +```astro +
+
+
+

Section Title

+

Content

+
+
+
+``` + +### Adding a Service +```astro +
+

🔧 Service Name

+

Description of the service

+
+``` + +### Adding a New Page +Create new `.astro` file in `src/pages/`: +```astro +--- +import BaseLayout from '../layouts/BaseLayout.astro'; +--- + + +
+

New Page

+
+
+``` +Note: Pages route based on filename (e.g., `about.astro` → `/about`) + +## Implementation Guidelines + +**DO:** +- Maintain cozy, warm aesthetic (coffee/cappuccino theme) +- Keep site lightweight - static HTML/CSS only, no JavaScript runtime +- Use CSS custom properties for all colors (defined in `src/layouts/BaseLayout.astro`) +- Use `.fade-in` class for animations, `.card` class for consistent card styling +- Test production builds and Docker builds after changes +- Ensure responsive design works on mobile +- Follow standard Astro structure (layouts in `src/layouts/`, pages in `src/pages/`) + +**DON'T:** +- Add tracking or external dependencies (privacy-first approach) +- Add client-side JavaScript unless absolutely necessary +- Break the coffee/warm color theme +- Create sterile or corporate design elements + +## Astro-Specific Notes + +- Frontmatter (code between `---`) runs at build time only +- ` diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 98e57ac..5b00fc9 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -54,8 +54,18 @@ const fullOgImage = new URL(ogImage, Astro.site).href; name="keywords" content="self-hosted, privacy, open-source, furry, developer, cozy, hidden den" /> - - + + + + @@ -101,6 +111,9 @@ const fullOgImage = new URL(ogImage, Astro.site).href; --color-blue: #89b4fa; --color-green: #a6e3a1; --color-peach: #fab387; + --color-glass: rgba(30, 30, 46, 0.8); + --color-glass-nav: rgba(30, 30, 46, 0.85); + color-scheme: dark; /* Spacing */ --space-xs: 0.5rem; @@ -113,6 +126,25 @@ const fullOgImage = new URL(ogImage, Astro.site).href; --font-body: "JetBrains Mono", "Fira Code", "SF Mono", Consolas, monospace; } + @media (prefers-color-scheme: light) { + :root { + --color-bg: #f6efe6; + --color-bg-light: #efe1cf; + --color-surface: #d8c2a8; + --color-text: #35251a; + --color-text-dim: #6b5442; + --color-accent: #8b5e3c; + --color-accent-bright: #a16c45; + --color-warm: #b6794f; + --color-blue: #326b8f; + --color-green: #3f7c47; + --color-peach: #b5693e; + --color-glass: rgba(246, 239, 230, 0.82); + --color-glass-nav: rgba(246, 239, 230, 0.88); + color-scheme: light; + } + } + * { margin: 0; padding: 0; diff --git a/src/lib/auth.ts b/src/lib/auth.ts new file mode 100644 index 0000000..0cafdc1 --- /dev/null +++ b/src/lib/auth.ts @@ -0,0 +1,73 @@ +import { createHash, randomBytes } from 'node:crypto'; +import db from './db'; + +function getSecretKey(): string { + const key = process.env.SECRET_KEY; + if (!key) throw new Error('SECRET_KEY environment variable is required'); + return key; +} + +export function hashIP(ip: string): string { + return createHash('sha256').update(getSecretKey() + ':' + ip).digest('hex').slice(0, 16); +} + +export function generateId(): string { + return randomBytes(32).toString('hex'); +} + +export interface AdminSession { + id: string; + user_id: string; + created_at: string; + expires_at: string; +} + +export function createSession(userId: string): string { + const sessionId = generateId(); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + db.prepare( + `INSERT INTO admin_sessions (id, user_id, expires_at) VALUES (?, ?, ?)` + ).run(sessionId, userId, expiresAt); + + return sessionId; +} + +export function getSession(sessionId: string): AdminSession | undefined { + return db + .prepare( + `SELECT * FROM admin_sessions + WHERE id = ? AND expires_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + ) + .get(sessionId) as AdminSession | undefined; +} + +export function deleteSession(sessionId: string): void { + db.prepare(`DELETE FROM admin_sessions WHERE id = ?`).run(sessionId); +} + +export function cleanExpiredSessions(): void { + db.prepare( + `DELETE FROM admin_sessions WHERE expires_at <= strftime('%Y-%m-%dT%H:%M:%SZ', 'now')` + ).run(); +} + +export const SESSION_COOKIE = 'admin_session'; + +function shouldUseSecureCookies(): boolean { + const secureOverride = process.env.COOKIE_SECURE?.trim().toLowerCase(); + if (secureOverride === 'true') return true; + if (secureOverride === 'false') return false; + + return process.env.NODE_ENV === 'production'; +} + +export function sessionCookieOptions(maxAge: number) { + return { + httpOnly: true, + secure: shouldUseSecureCookies(), + sameSite: 'strict' as const, + path: '/', + maxAge, + }; +} diff --git a/src/lib/blog.ts b/src/lib/blog.ts new file mode 100644 index 0000000..169f8df --- /dev/null +++ b/src/lib/blog.ts @@ -0,0 +1,17 @@ +export function formatBlogDate(date: Date) { + return date.toISOString().split("T")[0]; +} + +export function slugifyTag(tag: string) { + return tag + .toLowerCase() + .trim() + .replace(/&/g, " and ") + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-"); +} + +export function getTagHref(tag: string) { + return `/blog/tag/${slugifyTag(tag)}`; +} diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..e3ae8bf --- /dev/null +++ b/src/lib/db.ts @@ -0,0 +1,57 @@ +import Database from 'better-sqlite3'; +import { mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +const dbPath = resolve(process.env.DB_PATH ?? './data/guestbook.db'); + +// Ensure directory exists +mkdirSync(dirname(dbPath), { recursive: true }); + +const db = new Database(dbPath); + +// WAL mode improves concurrent read performance +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS guestbook_entries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_name TEXT NOT NULL, + message TEXT NOT NULL, + website TEXT, + ip_hash TEXT, + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + moderated_at TEXT, + moderation_note TEXT + ); + + CREATE TABLE IF NOT EXISTS admin_sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), + expires_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS rate_limit ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ip_hash TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE TABLE IF NOT EXISTS audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + action TEXT NOT NULL, + entry_id INTEGER, + admin_session TEXT, + note TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) + ); + + CREATE INDEX IF NOT EXISTS idx_entries_status ON guestbook_entries(status); + CREATE INDEX IF NOT EXISTS idx_entries_created ON guestbook_entries(created_at); + CREATE INDEX IF NOT EXISTS idx_rate_limit_ip ON rate_limit(ip_hash, created_at); + CREATE INDEX IF NOT EXISTS idx_sessions_expires ON admin_sessions(expires_at); +`); + +export default db; diff --git a/src/lib/guestbook.ts b/src/lib/guestbook.ts new file mode 100644 index 0000000..a7c0612 --- /dev/null +++ b/src/lib/guestbook.ts @@ -0,0 +1,125 @@ +import db from './db'; + +export interface GuestbookEntry { + id: number; + display_name: string; + message: string; + website: string | null; + ip_hash: string | null; + status: 'pending' | 'approved' | 'rejected' | 'spam'; + created_at: string; + moderated_at: string | null; + moderation_note: string | null; +} + +export interface SubmitData { + display_name: string; + message: string; + website: string | null; + ip_hash: string | null; +} + +const PAGE_SIZE = 20; + +export function getApprovedEntries(page = 1): { entries: GuestbookEntry[]; total: number; hasMore: boolean } { + const offset = (page - 1) * PAGE_SIZE; + const entries = db + .prepare( + `SELECT id, display_name, message, website, created_at + FROM guestbook_entries + WHERE status = 'approved' + ORDER BY created_at DESC + LIMIT ? OFFSET ?` + ) + .all(PAGE_SIZE, offset) as GuestbookEntry[]; + + const { total } = db + .prepare(`SELECT COUNT(*) as total FROM guestbook_entries WHERE status = 'approved'`) + .get() as { total: number }; + + return { entries, total, hasMore: offset + PAGE_SIZE < total }; +} + +export function getPendingEntries(): GuestbookEntry[] { + return db + .prepare( + `SELECT * FROM guestbook_entries WHERE status = 'pending' ORDER BY created_at ASC` + ) + .all() as GuestbookEntry[]; +} + +export function getRecentModerated(limit = 30): GuestbookEntry[] { + return db + .prepare( + `SELECT * FROM guestbook_entries + WHERE status IN ('approved', 'rejected', 'spam') + ORDER BY moderated_at DESC + LIMIT ?` + ) + .all(limit) as GuestbookEntry[]; +} + +export function submitEntry(data: SubmitData): number { + const result = db + .prepare( + `INSERT INTO guestbook_entries (display_name, message, website, ip_hash) + VALUES (?, ?, ?, ?)` + ) + .run(data.display_name, data.message, data.website, data.ip_hash); + + return result.lastInsertRowid as number; +} + +export function moderateEntry( + id: number, + status: 'approved' | 'rejected' | 'spam', + note: string | null, + sessionId: string +): boolean { + const result = db + .prepare( + `UPDATE guestbook_entries + SET status = ?, moderated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now'), moderation_note = ? + WHERE id = ? AND status = 'pending'` + ) + .run(status, note, id); + + if (result.changes > 0) { + db.prepare( + `INSERT INTO audit_log (action, entry_id, admin_session, note) VALUES (?, ?, ?, ?)` + ).run(`moderate:${status}`, id, sessionId, note); + } + + return result.changes > 0; +} + +export function checkRateLimit(ipHash: string): boolean { + // Clean up old entries (>1 hour) + db.prepare( + `DELETE FROM rate_limit WHERE created_at < strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 hour')` + ).run(); + + const { count } = db + .prepare(`SELECT COUNT(*) as count FROM rate_limit WHERE ip_hash = ?`) + .get(ipHash) as { count: number }; + + // Allow max 3 submissions per hour per IP hash + return count < 3; +} + +export function recordSubmission(ipHash: string): void { + db.prepare(`INSERT INTO rate_limit (ip_hash) VALUES (?)`).run(ipHash); +} + +export function isDuplicateSubmission(displayName: string, message: string): boolean { + // Check for exact duplicate in last 24 hours (regardless of status) + const row = db + .prepare( + `SELECT id FROM guestbook_entries + WHERE display_name = ? AND message = ? + AND created_at > strftime('%Y-%m-%dT%H:%M:%SZ', 'now', '-1 day')` + ) + .get(displayName, message); + + return row !== undefined; +} diff --git a/src/lib/readingTime.ts b/src/lib/readingTime.ts new file mode 100644 index 0000000..0ae82af --- /dev/null +++ b/src/lib/readingTime.ts @@ -0,0 +1,19 @@ +export function getReadingTime(content: string) { + const normalized = content + .replace(/```[\s\S]*?```/g, " ") + .replace(/`[^`]*`/g, " ") + .replace(/!\[[^\]]*\]\([^)]+\)/g, " ") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/<[^>]+>/g, " ") + .replace(/[#>*_~-]/g, " ") + .replace(/\s+/g, " ") + .trim(); + + const wordCount = normalized ? normalized.split(" ").length : 0; + const minutes = Math.max(1, Math.ceil(wordCount / 200)); + + return { + minutes, + text: `${minutes} min read`, + }; +} diff --git a/src/lib/spam.ts b/src/lib/spam.ts new file mode 100644 index 0000000..4a547d9 --- /dev/null +++ b/src/lib/spam.ts @@ -0,0 +1,120 @@ +// Lightweight spam detection and input sanitization + +const MAX_NAME_LENGTH = 60; +const MAX_MESSAGE_LENGTH = 1000; +const MAX_WEBSITE_LENGTH = 200; + +// Patterns that strongly suggest spam +const SPAM_PATTERNS = [ + /\b(viagra|cialis|casino|poker|lottery|bitcoin|crypto|investment|forex)\b/i, + /\b(click here|buy now|free money|earn \$|make money)\b/i, + /(https?:\/\/[^\s]{0,10}){3,}/i, // 3+ URLs in message +]; + +// Basic URL validation for the optional website field +const SAFE_URL_PATTERN = /^https?:\/\/[a-z0-9-]+(\.[a-z0-9-]+)+(\/[^\s]*)?$/i; + +export interface ValidationError { + field: string; + message: string; +} + +export function sanitizeText(input: string): string { + // Normalize whitespace: collapse runs of spaces/tabs, normalize line endings + return input + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .replace(/[ \t]+/g, ' ') + .replace(/\n{3,}/g, '\n\n') + .trim(); +} + +export function stripHtml(input: string): string { + // Remove anything that looks like an HTML tag + return input.replace(/<[^>]*>/g, '').replace(/&[a-z#0-9]+;/gi, (match) => { + const entities: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", + }; + return entities[match] ?? match; + }); +} + +export function validateWebsite(url: string): string | null { + if (!url || url.trim() === '') return null; + + const cleaned = url.trim(); + + if (cleaned.length > MAX_WEBSITE_LENGTH) return null; + if (!SAFE_URL_PATTERN.test(cleaned)) return null; + + // Block localhost and private ranges in URLs + if (/localhost|127\.|192\.168\.|10\.|172\.(1[6-9]|2\d|3[01])\./i.test(cleaned)) return null; + + return cleaned; +} + +export function validateEntry(data: { + display_name: string; + message: string; + website: string; + consent: string; + honeypot: string; +}): ValidationError[] { + const errors: ValidationError[] = []; + + // Honeypot check: must be empty (bots fill it in) + if (data.honeypot && data.honeypot.trim() !== '') { + errors.push({ field: 'honeypot', message: 'Spam detected' }); + return errors; + } + + // Consent required + if (!data.consent || data.consent !== 'yes') { + errors.push({ field: 'consent', message: 'You must consent to public posting' }); + } + + // Display name validation + const name = stripHtml(sanitizeText(data.display_name)); + if (!name || name.length < 1) { + errors.push({ field: 'display_name', message: 'Please enter a display name' }); + } else if (name.length > MAX_NAME_LENGTH) { + errors.push({ field: 'display_name', message: `Name must be ${MAX_NAME_LENGTH} characters or less` }); + } + + // Message validation + const message = stripHtml(sanitizeText(data.message)); + if (!message || message.length < 1) { + errors.push({ field: 'message', message: 'Please enter a message' }); + } else if (message.length > MAX_MESSAGE_LENGTH) { + errors.push({ field: 'message', message: `Message must be ${MAX_MESSAGE_LENGTH} characters or less` }); + } + + return errors; +} + +export function scoreSpam(data: { display_name: string; message: string; website: string | null }): number { + let score = 0; + const combined = `${data.display_name} ${data.message} ${data.website ?? ''}`; + + for (const pattern of SPAM_PATTERNS) { + if (pattern.test(combined)) score += 30; + } + + // Lots of URLs in message is suspicious + const urlCount = (data.message.match(/https?:\/\//gi) ?? []).length; + if (urlCount >= 2) score += 20 * urlCount; + + // All-caps message is a mild spam signal + const upperRatio = (data.message.match(/[A-Z]/g) ?? []).length / Math.max(data.message.length, 1); + if (upperRatio > 0.6 && data.message.length > 10) score += 15; + + return score; +} + +export function isLikelySpam(data: { display_name: string; message: string; website: string | null }): boolean { + return scoreSpam(data) >= 50; +} diff --git a/src/pages/404.astro b/src/pages/404.astro index a6c5ac0..d25f62e 100644 --- a/src/pages/404.astro +++ b/src/pages/404.astro @@ -66,7 +66,7 @@ import BaseLayout from "../layouts/BaseLayout.astro"; .container { max-width: 500px; width: 100%; - background: rgba(30, 30, 46, 0.8); + background: var(--color-glass); backdrop-filter: blur(10px); border: 1px solid var(--color-surface); border-radius: 8px; diff --git a/src/pages/about.astro b/src/pages/about.astro index 1a4906a..c15bdc9 100644 --- a/src/pages/about.astro +++ b/src/pages/about.astro @@ -4,67 +4,192 @@ import BaseLayout from "../layouts/BaseLayout.astro";
+

Latte / Hidden Den

About

══════════════════════════════
-
- -
-

The Den

-

- Hidden Den Cafe is my little corner of the internet — self-hosted, - self-maintained, and free from corporate nonsense. No trackers, no ads, - no data harvesting. Just a cozy space that I built and control. +

+ Hi, I'm Latte. Hidden Den exists because I wanted a personal place on + the internet that feels the way I want technology to feel: calm, + understandable, warm, and fully mine.

-
+

Who I Am

- I'm Latte — an IT wizard with a homelab, - a love for privacy, and a deep distrust of companies that treat your data - like their product. I believe in owning your infrastructure, running your - own services, and keeping things under your own roof. + I'm Latte - an IT professional, a + developer, and someone who spends a lot of time close to systems. A + lot of my day-to-day thinking is shaped by infrastructure, maintenance, + deployment, networking, and the quiet work required to keep things + reliable. I like understanding how things fit together, not just using + them from a distance.

- I'm a gay furry developer who builds things because I want them to exist — - not because some product manager told me to. My stack leans toward Python - and self-hosted tooling, but I'm always exploring new things. + I run a homelab because I enjoy learning by building, breaking, + fixing, and gradually improving the systems I rely on. I tend to + prefer tools I can audit, services I can migrate, setups I can back + up, and infrastructure I can replace without begging a platform to keep + my life intact.

-

The Homelab

+

The Person Behind The Stack

- Most of what runs here lives on my own hardware — Gitea for code, Docker - for deployment, nginx for serving. Where physical infra doesn't make sense, - I rent VPS capacity from OVH and Play.hosting. For work, the Microsoft 365 - ecosystem does what it needs to do. + The technical side is real, but it is not the whole story. I'm also a + gay furry developer with a soft spot for cozy cafe aesthetics, warm + tones, coffee culture, quiet spaces, and slow building. I am much less + interested in performing some polished hacker persona than I am in + making a space that feels thoughtful, lived in, and unmistakably human.

- The goal isn't purity — it's control. Keep data minimal, choose providers - you understand, avoid surveillance-adjacent platforms. Self-host what you - can; rent infra where it's practical; use what you need without pretending - you don't. + Hidden Den reflects that mix. It is technical, but not sterile. It is + personal, but not performative. It is a place where infrastructure, + writing, projects, experiments, and internet philosophy can sit next to + warmth, identity, and the kinds of details that make a site feel like + someone actually lives there. +

+
+

+ More cozy tech wizard than cyberpunk hacker. +

+
+
+ +
+

How I Tend To Build

+
+
+

Understand It

+

+ I am most comfortable with systems I can inspect and reason + about. If I do not understand the tradeoffs, the failure modes, + or the path out, I do not feel like I really own the tool. +

+
+
+

Keep It Durable

+

+ I prefer setups that can be backed up, migrated, repaired, and + replaced. Durable systems are not always flashy, but they age + better and make better foundations for real life. +

+
+
+

Leave Room For Care

+

+ I care about interfaces and environments that feel intentional. + Warmth matters to me. I do not think technical spaces need to be + cold to be serious. +

+
+
+

Stay Practical

+

+ I do not treat purity as a goal. I self-host a lot because it + teaches me things and gives me control, but I still care about + workable systems more than ideological posturing. +

+
+
+
-

Ethos

-
    -
  • privacy: Your data is yours. Period.
  • -
  • self-hosting: If you can run it yourself, you should.
  • -
  • open source: Knowledge should be shared.
  • -
  • small web: The internet is better when it's personal.
  • +

    Why Hidden Den Exists

    +

    + This site is not a portfolio, a startup brand, or a personal marketing + project. It exists because I wanted a real personal website again: a + place for writing, projects, experiments, infrastructure notes, and the + kinds of ideas that do not fit neatly into social platforms. +

    +

    + I wanted something quieter than a feed and more honest than a polished + personal brand. Hidden Den gives me room to publish on my own terms and + let the site grow slowly, in the shape that actually suits me. +

    +
+ +
+

Why This Matters To Me

+

+ Working with infrastructure changes how you see the internet. It reveals + the parts most people never notice: who owns the platform, where the data + goes, what happens when the service changes, and how little control people + often have over the spaces they depend on. That matters to me both + technically and personally. +

+

+ I care about technology that feels intentional instead of engineered for + surveillance, lock-in, or endless engagement. I want the tools around me + to be legible. I want the places I spend time in to respect the people + using them. Hidden Den is one small attempt to build that kind of space. +

+
+ +
+

On The Internet I Want

+

+ Too much of the modern web is optimized for extraction: attention, + behavior, identity, and dependence. I prefer a smaller internet made of + personal sites, weird projects, community infrastructure, and spaces + that are allowed to be specific. Not everything needs to become a + platform, and not every page needs to be a funnel. +

+

+ I still believe the web is better when more people make places that feel + like their own. Places with taste. Places with personality. Places that + are maintained because someone cares about them, not because they have + been optimized against a dashboard. +

+
+ +
+

Privacy-First, In Practice

+

+ Privacy is part of the philosophy here, but it is also part of the + implementation. Hidden Den avoids trackers, ads, invasive analytics, and + unnecessary third-party dependencies. Static pages are not a compromise + for me. They are often the cleaner solution. +

+

+ The same goes for the infrastructure behind the site. I prefer systems I + can audit, migrate, back up, and replace. People should be able to visit + a personal website without quietly being turned into a behavioral profile. +

+
    +
  • no trackers: Visitors are guests, not telemetry.
  • +
  • minimal dependencies: Fewer external systems means fewer leaks.
  • +
  • self-hosting bias: Control matters when the tradeoff is reasonable.
  • +
  • human scale: The site is built to feel inhabited, not optimized.
+
+

What I Want This Place To Be

+

+ Hidden Den is meant to feel like a quiet corner of the internet: warm, + thoughtful, technical, and personal. A place where I can share what I am + building and thinking about without flattening myself into a bio, a + brand, or a feed. +

+

+ If this page does its job, it should feel clear that there is a real + person behind the site. Someone who likes systems and infrastructure, + cares about privacy, prefers warm light over neon, and still thinks the + internet is worth building on carefully. +

+
+

Made with love by Latte

@@ -105,7 +230,7 @@ import BaseLayout from "../layouts/BaseLayout.astro"; .container { max-width: 700px; width: 100%; - background: rgba(30, 30, 46, 0.8); + background: var(--color-glass); backdrop-filter: blur(10px); border: 1px solid var(--color-surface); border-radius: 8px; @@ -117,6 +242,14 @@ import BaseLayout from "../layouts/BaseLayout.astro"; margin-bottom: var(--space-lg); } + .eyebrow { + color: var(--color-text-dim); + text-transform: uppercase; + letter-spacing: 0.24em; + font-size: 0.75rem; + margin-bottom: var(--space-sm); + } + .title { font-size: 2rem; font-weight: 700; @@ -131,6 +264,13 @@ import BaseLayout from "../layouts/BaseLayout.astro"; user-select: none; } + .lead { + color: var(--color-text); + line-height: 1.8; + max-width: 36rem; + margin: 0 auto; + } + .section { margin: var(--space-lg) 0; } @@ -153,11 +293,56 @@ import BaseLayout from "../layouts/BaseLayout.astro"; margin-bottom: 0; } + .callout { + margin-top: var(--space-md); + padding: var(--space-md); + border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); + background: + linear-gradient( + 135deg, + color-mix(in srgb, var(--color-accent) 10%, transparent), + transparent 70% + ), + color-mix(in srgb, var(--color-bg-light) 84%, transparent); + border-radius: 8px; + } + + .callout p { + color: var(--color-text); + line-height: 1.7; + } + .highlight { color: var(--color-accent-bright); font-weight: 700; } + .value-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--space-md); + } + + .value-card { + padding: var(--space-md); + border-radius: 8px; + background: color-mix(in srgb, var(--color-bg-light) 88%, transparent); + border: 1px solid color-mix(in srgb, var(--color-surface) 70%, transparent); + } + + .value-card h3 { + color: var(--color-accent-bright); + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.12em; + margin-bottom: var(--space-xs); + } + + .value-card p { + color: var(--color-text-dim); + line-height: 1.7; + } + .values { list-style: none; display: flex; @@ -175,6 +360,24 @@ import BaseLayout from "../layouts/BaseLayout.astro"; font-weight: 600; } + .uses-link { + margin-top: var(--space-md); + font-size: 0.875rem; + color: var(--color-text-dim); + } + + .uses-link a { + color: var(--color-accent); + } + + .uses-link a:hover { + color: var(--color-accent-bright); + } + + .compact { + margin-top: var(--space-md); + } + .footer { margin-top: var(--space-xl); text-align: center; @@ -221,6 +424,14 @@ import BaseLayout from "../layouts/BaseLayout.astro"; .divider { font-size: 0.6rem; } + + .lead { + font-size: 0.95rem; + } + + .value-grid { + grid-template-columns: 1fr; + } } @media (prefers-reduced-motion: reduce) { diff --git a/src/pages/admin/index.astro b/src/pages/admin/index.astro new file mode 100644 index 0000000..dc2391f --- /dev/null +++ b/src/pages/admin/index.astro @@ -0,0 +1,282 @@ +--- +export const prerender = false; + +import AdminLayout from '../../layouts/AdminLayout.astro'; +import { getSession, SESSION_COOKIE, cleanExpiredSessions } from '../../lib/auth'; +import { getPendingEntries, getRecentModerated } from '../../lib/guestbook'; + +const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value; +const session = sessionId ? getSession(sessionId) : undefined; + +if (!session) { + return Astro.redirect('/admin/login'); +} + +// Periodic cleanup +cleanExpiredSessions(); + +const pending = getPendingEntries(); +const recent = getRecentModerated(20); + +const errorParam = Astro.url.searchParams.get('error'); +--- + + +

guestbook moderation

+ + {errorParam && ( + + )} + + +
+

+ Pending + {pending.length > 0 && {pending.length}} +

+ + {pending.length === 0 ? ( +
+

No pending messages. All clear.

+
+ ) : ( +
+ {pending.map((entry) => ( +
+ + +
+
+ name: + {entry.display_name} +
+ {entry.website && ( +
+ website: + + {entry.website} + +
+ )} +
+ message: +
+
{entry.message}
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+
+ ))} +
+ )} +
+ + + {recent.length > 0 && ( +
+

Recently moderated

+
+ {recent.map((entry) => ( +
+ +

{entry.message}

+ {entry.moderation_note && ( +

note: {entry.moderation_note}

+ )} +
+ ))} +
+
+ )} +
+ + diff --git a/src/pages/admin/login.astro b/src/pages/admin/login.astro new file mode 100644 index 0000000..db12b49 --- /dev/null +++ b/src/pages/admin/login.astro @@ -0,0 +1,108 @@ +--- +export const prerender = false; + +import AdminLayout from '../../layouts/AdminLayout.astro'; +import { getSession, SESSION_COOKIE } from '../../lib/auth'; + +// Redirect if already logged in +const sessionId = Astro.cookies.get(SESSION_COOKIE)?.value; +const session = sessionId ? getSession(sessionId) : undefined; +if (session) { + return Astro.redirect('/admin'); +} + +const tokenAuthEnabled = Boolean(process.env.ADMIN_SECRET_TOKEN?.trim()); +const tokenError = Astro.url.searchParams.get('tokenError') === '1'; +--- + + + + + + diff --git a/src/pages/api/admin/logout.ts b/src/pages/api/admin/logout.ts new file mode 100644 index 0000000..2eecc5e --- /dev/null +++ b/src/pages/api/admin/logout.ts @@ -0,0 +1,14 @@ +import type { APIRoute } from 'astro'; +import { deleteSession, SESSION_COOKIE } from '../../../lib/auth'; + +export const prerender = false; + +export const POST: APIRoute = async ({ cookies }) => { + const sessionId = cookies.get(SESSION_COOKIE)?.value; + if (sessionId) { + deleteSession(sessionId); + cookies.delete(SESSION_COOKIE, { path: '/' }); + } + + return new Response(null, { status: 303, headers: { Location: '/admin/login' } }); +}; diff --git a/src/pages/api/admin/moderate.ts b/src/pages/api/admin/moderate.ts new file mode 100644 index 0000000..ab88b9a --- /dev/null +++ b/src/pages/api/admin/moderate.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from 'astro'; +import { moderateEntry } from '../../../lib/guestbook'; +import { getSession, SESSION_COOKIE } from '../../../lib/auth'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, cookies }) => { + const sessionId = cookies.get(SESSION_COOKIE)?.value; + const session = sessionId ? getSession(sessionId) : undefined; + + if (!session) { + return new Response(null, { status: 303, headers: { Location: '/admin/login' } }); + } + + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return new Response(null, { status: 303, headers: { Location: '/admin' } }); + } + + const id = parseInt(String(formData.get('id') ?? ''), 10); + const action = String(formData.get('action') ?? ''); + const note = String(formData.get('note') ?? '').trim() || null; + + if (isNaN(id) || !['approve', 'reject', 'spam'].includes(action)) { + return new Response(null, { status: 303, headers: { Location: '/admin?error=invalid' } }); + } + + const statusMap: Record = { + approve: 'approved', + reject: 'rejected', + spam: 'spam', + }; + + moderateEntry(id, statusMap[action], note, session.id); + + return new Response(null, { status: 303, headers: { Location: '/admin' } }); +}; diff --git a/src/pages/api/admin/token-login.ts b/src/pages/api/admin/token-login.ts new file mode 100644 index 0000000..5eb71e6 --- /dev/null +++ b/src/pages/api/admin/token-login.ts @@ -0,0 +1,37 @@ +import type { APIRoute } from 'astro'; +import { timingSafeEqual } from 'node:crypto'; +import { createSession, SESSION_COOKIE, sessionCookieOptions } from '../../../lib/auth'; + +export const prerender = false; + +function tokenMatches(input: string, expected: string): boolean { + const a = Buffer.from(input); + const b = Buffer.from(expected); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +export const POST: APIRoute = async ({ request, cookies }) => { + const expectedToken = process.env.ADMIN_SECRET_TOKEN?.trim(); + if (!expectedToken) { + return new Response(null, { status: 404 }); + } + + const formData = await request.formData(); + const token = String(formData.get('token') ?? '').trim(); + + if (!tokenMatches(token, expectedToken)) { + return new Response(null, { + status: 303, + headers: { Location: '/admin/login?tokenError=1' }, + }); + } + + const sessionId = createSession('admin'); + cookies.set(SESSION_COOKIE, sessionId, sessionCookieOptions(24 * 60 * 60)); + + return new Response(null, { + status: 303, + headers: { Location: '/admin' }, + }); +}; diff --git a/src/pages/api/guestbook/submit.ts b/src/pages/api/guestbook/submit.ts new file mode 100644 index 0000000..24246d5 --- /dev/null +++ b/src/pages/api/guestbook/submit.ts @@ -0,0 +1,83 @@ +import type { APIRoute } from 'astro'; +import { + submitEntry, + checkRateLimit, + recordSubmission, + isDuplicateSubmission, + moderateEntry, +} from '../../../lib/guestbook'; +import { hashIP } from '../../../lib/auth'; +import { validateEntry, sanitizeText, stripHtml, validateWebsite, isLikelySpam } from '../../../lib/spam'; + +export const prerender = false; + +export const POST: APIRoute = async ({ request }) => { + // Parse form data + let formData: FormData; + try { + formData = await request.formData(); + } catch { + return redirect('/guestbook?error=invalid'); + } + + const raw = { + display_name: String(formData.get('display_name') ?? ''), + message: String(formData.get('message') ?? ''), + website: String(formData.get('website') ?? ''), + consent: String(formData.get('consent') ?? ''), + honeypot: String(formData.get('address') ?? ''), // hidden honeypot field named "address" + }; + + // Validate + const errors = validateEntry(raw); + if (errors.some((e) => e.field === 'honeypot')) { + // Silent reject for bots: appear successful + return redirect('/guestbook?submitted=true'); + } + if (errors.length > 0) { + const msg = encodeURIComponent(errors[0].message); + return redirect(`/guestbook?error=${msg}`); + } + + // Sanitize + const display_name = stripHtml(sanitizeText(raw.display_name)); + const message = stripHtml(sanitizeText(raw.message)); + const website = validateWebsite(raw.website); + + // Rate limit by salted IP hash + const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() + ?? request.headers.get('x-real-ip') + ?? 'unknown'; + const ipHash = hashIP(ip); + + if (!checkRateLimit(ipHash)) { + return redirect('/guestbook?error=rate_limit'); + } + + // Duplicate check + if (isDuplicateSubmission(display_name, message)) { + return redirect('/guestbook?submitted=true'); // silent dedupe + } + + // Heuristic spam scoring: auto-mark as spam if score is high + const spamEntry = isLikelySpam({ display_name, message, website }); + + recordSubmission(ipHash); + + const id = submitEntry({ display_name, message, website, ip_hash: ipHash }); + + // If auto-detected as spam, immediately mark it + if (spamEntry) { + moderateEntry(id, 'spam', 'auto-detected', 'system'); + return redirect('/guestbook?submitted=true'); // still appear successful + } + + return redirect('/guestbook?submitted=true'); +}; + +function redirect(location: string): Response { + return new Response(null, { + status: 303, + headers: { Location: location }, + }); +} diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index 26cdcc5..0ee1501 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -1,25 +1,81 @@ --- import BaseLayout from "../../layouts/BaseLayout.astro"; -import { getCollection } from "astro:content"; +import { getCollection, type CollectionEntry } from "astro:content"; +import { formatBlogDate, getTagHref } from "../../lib/blog"; +import { getReadingTime } from "../../lib/readingTime"; + +type BlogPost = CollectionEntry<"blog">; export async function getStaticPaths() { const posts = await getCollection("blog", ({ data }) => !data.draft); + return posts.map((post) => ({ params: { slug: post.slug }, - props: { post }, + props: { + post, + seriesPosts: post.data.series + ? posts + .filter( + (candidate) => + candidate.data.series?.name === + post.data.series?.name, + ) + .sort((a, b) => { + const partDifference = + (a.data.series?.part ?? Number.MAX_SAFE_INTEGER) - + (b.data.series?.part ?? Number.MAX_SAFE_INTEGER); + + if (partDifference !== 0) return partDifference; + + return ( + a.data.pubDate.valueOf() - + b.data.pubDate.valueOf() + ); + }) + : [], + relatedPosts: posts + .filter((candidate) => candidate.slug !== post.slug) + .map((candidate) => ({ + post: candidate, + score: + (candidate.data.series?.name && + candidate.data.series.name === post.data.series?.name + ? 10 + : 0) + + candidate.data.tags.filter((tag) => + post.data.tags.includes(tag), + ).length, + })) + .sort((a, b) => { + if (b.score !== a.score) return b.score - a.score; + return ( + b.post.data.pubDate.valueOf() - + a.post.data.pubDate.valueOf() + ); + }) + .filter((candidate) => candidate.score > 0) + .slice(0, 2) + .map((candidate) => candidate.post), + }, })); } -const { post } = Astro.props; -const { Content } = await post.render(); +const { + post, + seriesPosts = [], + relatedPosts = [], +} = Astro.props as { + post: BlogPost; + seriesPosts: BlogPost[]; + relatedPosts: BlogPost[]; +}; -function formatDate(date: Date) { - return date.toISOString().split("T")[0]; -} +const { Content } = await post.render(); +const readingTime = getReadingTime(post.body); ---