Compare commits
7
Commits
server
...
cf16e1f60d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf16e1f60d | ||
|
|
3a21998d60 | ||
|
|
9aa0ada81d | ||
|
|
7bf4d98fc0 | ||
|
|
73603bc61b | ||
|
|
bd6358cf1c | ||
|
|
42e7e0f1ff |
+8
-3
@@ -17,12 +17,17 @@ RUN npm ci
|
||||
|
||||
# Copy application source files
|
||||
COPY . .
|
||||
RUN chmod +x /app/entrypoint.sh
|
||||
|
||||
# Build static production assets
|
||||
# Pre-compile baseline static assets
|
||||
RUN npm run build
|
||||
|
||||
# Expose web server port dynamically
|
||||
EXPOSE ${PORT}
|
||||
|
||||
# Serve site with npm
|
||||
CMD ["npm", "start"]
|
||||
# Native container healthcheck probing IPv4 loopback
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=3s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || 8080) + '/healthz').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||
|
||||
# Serve site using dynamic entrypoint
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
@@ -1,116 +1,223 @@
|
||||
# River Rooks | Technical Publication & Systems Architecture Blog
|
||||
|
||||
> Developer-centric, high-signal, low-noise personal technical blog and homelab documentation site.
|
||||
> Developer-centric, high-signal, low-noise personal technical publication and systems architecture blog.
|
||||
|
||||
Inspired by native terminal environments, modal text editors (Zed, Ghostty), and tiling window managers, this site prioritizes raw performance, keyboard navigability, information density, and structured craftsmanship over generic marketing aesthetics.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architectural Vision & Pillars
|
||||
## 1. Quickstart: Production Server Deployment
|
||||
|
||||
### 1.1 Static Generation via Eleventy (11ty)
|
||||
- **Engine:** Fast, pre-rendered static HTML and minimal client-side runtime using **Eleventy** and **Nunjucks (`.njk`)** templates.
|
||||
- **Performance:** Instant time-to-first-byte (TTFB), zero client-side framework bloat, and pure static asset delivery via high-performance native Node runtime.
|
||||
- **Syntax Highlighting & Code Blocks:** Terminal-styled code blocks with title bars displaying filenames, language tags, and high-contrast monospace code.
|
||||
|
||||
### 1.2 Dynamic Obsidian Publishing Pipeline
|
||||
- **Seamless Authoring:** Author and edit technical dispatches, CTF write-ups, and homelab notes directly inside an **Obsidian** vault.
|
||||
- **Custom Ingestion API:** A dedicated server-side endpoint (`POST /api/posts`) accepting markdown payloads and assets, paired with a custom Obsidian plugin for one-click publishing.
|
||||
- **LiveSync Integration:** Architectural support for syncing notes from a self-hosted Obsidian LiveSync (CouchDB) instance.
|
||||
- **Content Normalization:** Translates Obsidian wikilinks (`[[slug|Title]]`), callouts (`> [!NOTE]`), and asset references into standard web formats during ingestion and build time.
|
||||
|
||||
### 1.3 Strict CSS Isolation & Reusable Templating
|
||||
- **No Monolithic Inlines:** Replaces repetitive inline `<style>` tags with a modular CSS token and component architecture.
|
||||
- **Design Tokens:** Centralized CSS custom properties (`--bg-canvas`, `--bg-surface`, `--border-color`, `--accent-cyan`, etc.) for consistent dark-mode native styling.
|
||||
- **DRY Layouts & Partials:** Reusable Nunjucks components for global navigation (`global-bar`), hero tiles (`hero-tile`), split cards, and article workspace layouts (`article-workspace-grid`).
|
||||
|
||||
### 1.4 AI Pair Architect & Pedagogical Explainer
|
||||
- **Interactive Learning:** The workspace includes customized Antigravity agent configurations (`.agents/` and `AGENTS.md`) that pair-program with the author, architecting solutions while clearly explaining *how* and *why* code is built.
|
||||
|
||||
---
|
||||
|
||||
## 2. Strict Design Specification
|
||||
|
||||
Adhering to the principles in [`sample-designs/design-specification.md`](./sample-designs/design-specification.md):
|
||||
|
||||
| Rule | Constraint |
|
||||
| :--- | :--- |
|
||||
| **Color Scheme** | Dark-mode native only. Canvas: `#0d0f12`, Surface: `#121417`, Elevated: `#1c1f26`, Borders: `#2b303b`. |
|
||||
| **Accents** | High-signal terminal accents used sparingly: Cyan (`#00f0ff`), Green (`#00e676`), Amber (`#ffb700`). |
|
||||
| **Borders & Radius** | Crisp 1px solid borders. Zero to 4px maximum border-radius (sharp corners preferred). |
|
||||
| **Strict Anti-Patterns** | **NO** glassmorphism (`backdrop-filter`), **NO** drop shadows or glows, **NO** gradients, **NO** spring animations. |
|
||||
| **Typography** | Monospace for UI & code (`IBM Plex Mono`, `JetBrains Mono`); clean sans-serif for body prose (`Inter`). |
|
||||
| **Interactivity** | Instant binary hover snaps (no slow fade transitions); high-contrast focus outlines for keyboard navigation. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Project Directory Map
|
||||
The production deployment uses a **decoupled container architecture**. The server does not need Node.js, git, or build dependencies—it only pulls the prebuilt engine from the registry and mounts a local folder containing your Markdown posts.
|
||||
|
||||
### 1.1 Server Directory Structure (`~/docker/blog`)
|
||||
```text
|
||||
.
|
||||
├── .agents/ # Antigravity agent configurations & customizations
|
||||
│ ├── rules/
|
||||
│ │ └── blog-system.md # Architectural constraints, CSS/template isolation & explainer mode
|
||||
│ └── skills/
|
||||
│ ├── eleventy-tiling-blog/ # Runbook & recipes for Eleventy builds and component extraction
|
||||
│ └── obsidian-sync/ # Runbook & API contracts for Obsidian publishing & plugin sync
|
||||
├── sample-designs/ # Canonical HTML/CSS prototypes & design specification
|
||||
│ ├── article-tiling-firmware.html
|
||||
│ ├── blog-tiling-lead.html
|
||||
│ ├── design-specification.md
|
||||
│ └── service-gateway-tiling.html
|
||||
├── src/ # Eleventy source directory (scaffolded in Phase 2)
|
||||
│ ├── _data/ # Global site metadata, navigation, telemetry data
|
||||
│ ├── _includes/
|
||||
│ │ ├── layouts/ # Base, post, and page templates
|
||||
│ │ └── partials/ # Global bar, hero tile, card, code box, sidebar
|
||||
│ ├── assets/
|
||||
│ │ ├── css/ # Modular CSS (tokens, reset, layout, components)
|
||||
│ │ └── fonts/ # Self-hosted monospace & prose webfonts
|
||||
│ └── posts/ # Markdown content ingested from Obsidian
|
||||
├── .eleventy.js # Eleventy configuration, markdown-it plugins, and shortcodes
|
||||
├── package.json # Project dependencies & build scripts
|
||||
├── server.js # Native Node static web server & clean-URL router
|
||||
├── Dockerfile # Node container definition (built & served via npm)
|
||||
├── docker-compose.yml # Container orchestration (web gateway)
|
||||
└── AGENTS.md # Project-level agent instructions & pair programming guide
|
||||
~/docker/blog/
|
||||
├── .env # Environment variables (SITE_DOMAIN, etc.)
|
||||
├── docker-compose.yml # Production container definition
|
||||
└── posts/ # Your live Markdown articles (.md)
|
||||
```
|
||||
|
||||
### 1.2 Production `docker-compose.yml`
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||
container_name: river_rooks_web
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- "./posts:/app/src/posts:ro"
|
||||
environment:
|
||||
- "SITE_DOMAIN=${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||
- "SITE_URL=${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||
- "PORT=${PORT:-8080}"
|
||||
- "WATCH=${WATCH:-false}"
|
||||
ports:
|
||||
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
|
||||
networks:
|
||||
- backend
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.blog.rule=Host(`riverrooks.dev`) || Host(`www.riverrooks.dev`)"
|
||||
- "traefik.http.routers.blog.entrypoints=websecure"
|
||||
- "traefik.http.routers.blog.tls.certresolver=basic"
|
||||
- "traefik.http.services.blog.loadbalancer.server.port=8080"
|
||||
- "traefik.docker.network=backend"
|
||||
|
||||
networks:
|
||||
backend:
|
||||
external: true
|
||||
```
|
||||
|
||||
### 1.3 Deployment Commands
|
||||
```bash
|
||||
# 1. Pull the prebuilt engine from Gitea registry
|
||||
docker compose pull
|
||||
|
||||
# 2. Launch the container
|
||||
docker compose up -d
|
||||
|
||||
# 3. Verify healthcheck status (transitions to healthy in 3-5 seconds)
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Development & Build Roadmap
|
||||
## 2. Local Development & Verification
|
||||
|
||||
- [x] **Phase 1: Foundation & Agent Customizations**
|
||||
- Establish `README.md` architectural blueprint.
|
||||
- Setup `.agents/` workspace rules, skills, and root `AGENTS.md`.
|
||||
- [ ] **Phase 2: Eleventy Scaffolding & Modularization**
|
||||
- Initialize Node/npm environment and configure `.eleventy.js`.
|
||||
- Extract design tokens, layout styles, and components into `src/assets/css/`.
|
||||
- Build reusable Nunjucks layouts and partials modeled on `sample-designs/`.
|
||||
- [ ] **Phase 3: Dynamic Obsidian Ingestion & Plugin**
|
||||
- Implement the server-side note ingestion endpoint (`POST /api/posts`).
|
||||
- Develop the custom Obsidian plugin for publishing notes and frontmatter.
|
||||
- Integrate wikilink and callout transformation filters.
|
||||
- [ ] **Phase 4: Containerization & Deployment**
|
||||
- Verify static build outputs in `_site/`.
|
||||
- Update Dockerfile and `docker-compose.yml` for unified production serving.
|
||||
|
||||
---
|
||||
|
||||
## 5. Development Setup
|
||||
|
||||
*(To be initialized during Phase 2)*
|
||||
All templates, styles, and engine code are developed in this repository (`~/code/blog`).
|
||||
|
||||
### 2.1 Native Node.js Workflow
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start Eleventy local development server with hot-reload
|
||||
# Run automated test suite (unit tests, filters, server, and docker assertions)
|
||||
npm test
|
||||
|
||||
# Launch Eleventy dev server with hot-reload (binds to http://localhost:8080)
|
||||
npm run dev
|
||||
|
||||
# Compile static assets for production
|
||||
# Compile static assets to _site/
|
||||
npm run build
|
||||
|
||||
# Run via Docker Compose
|
||||
docker compose up --build
|
||||
# Start the native static server locally
|
||||
npm start
|
||||
```
|
||||
|
||||
### 2.2 Local Docker Compose Workflow
|
||||
The local `docker-compose.yml` provides sensible defaults (`localhost`, port 8080, and mounts `./src/posts`):
|
||||
```bash
|
||||
# Build and run the local container stack
|
||||
docker compose up -d --build
|
||||
|
||||
# View container logs (Eleventy compilation + server output)
|
||||
docker compose logs -f
|
||||
|
||||
# Stop and clean up
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Push & Update Cycles
|
||||
|
||||
Because the system separates the **Engine** (templates/code) from the **Content** (markdown notes), updates follow two distinct paths:
|
||||
|
||||
### 3.1 Publishing or Editing Articles (Content Workflow)
|
||||
When you write a new note or edit an existing article:
|
||||
|
||||
1. **Add or update the Markdown file** in `posts/` (e.g. `posts/my-new-post.md`):
|
||||
```yaml
|
||||
---
|
||||
title: "My Article Title"
|
||||
date: 2026-09-14
|
||||
tags: ["#systems", "#architecture"]
|
||||
excerpt: "A short synopsis of the article."
|
||||
---
|
||||
|
||||
# Article Content...
|
||||
```
|
||||
2. **Recompile the site:**
|
||||
```bash
|
||||
docker compose restart web
|
||||
```
|
||||
*Eleventy compiles the mounted markdown in ~150ms and starts serving immediately.*
|
||||
|
||||
> [!TIP]
|
||||
> **Zero-Restart Live Reload:** Set `WATCH=true` in your server's `docker-compose.yml` environment. Eleventy will watch the `posts/` directory in the background. Whenever a note is saved or synced (e.g., from Obsidian), the site automatically updates in memory without running any commands.
|
||||
|
||||
### 3.2 Updating Blog Templates, CSS, or Features (Engine Workflow)
|
||||
When you modify design tokens, Nunjucks templates, server routes, or dependencies:
|
||||
|
||||
1. **Develop and verify locally:**
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
2. **Build and push the new engine image to the Gitea container registry:**
|
||||
```bash
|
||||
# Build the engine image
|
||||
docker build -t git.riverrooks.dev/venus/river-rooks-blog:latest .
|
||||
|
||||
# Push to Gitea
|
||||
docker push git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||
```
|
||||
3. **Update the production server:**
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. System Architecture & Structure
|
||||
|
||||
### 4.1 High-Level Architecture
|
||||
```text
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Dev Machine / Workstation │
|
||||
│ src/ (templates, CSS, config) + Dockerfile + entrypoint.sh │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ docker push
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Gitea Container Registry (git.riverrooks.dev) │
|
||||
│ image: venus/river-rooks-blog:latest │
|
||||
└──────────────────────────────┬──────────────────────────────┘
|
||||
│ docker pull
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Production Server │
|
||||
│ │
|
||||
│ ~/docker/blog/posts/ ──(volume)──> [ river_rooks_web ] │
|
||||
│ (Live Markdown Notes) ├── entrypoint.sh │
|
||||
│ ├── 11ty compiles │
|
||||
│ Traefik (SSL / Proxy) <──────────── └── server.js (:8080) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Key Engine Components
|
||||
- **`entrypoint.sh`**: Dynamic container entrypoint. Compiles mounted markdown notes with Eleventy on boot, starts optional background file watcher, and spawns `server.js` as PID 1 for clean signal handling.
|
||||
- **`Dockerfile`**: Minimal `node:22-alpine` container with pre-installed dependencies, baseline static pre-compilation, and baked-in `HEALTHCHECK` querying `http://127.0.0.1:8080/healthz`.
|
||||
- **`server.js`**: Zero-dependency native Node static web server supporting clean directory URLs, MIME caching, security headers, and the `/healthz` telemetry endpoint.
|
||||
- **`eleventy.config.js`**: Eleventy 3.x configuration, custom collections (`posts`, `tagList`), markdown-it anchor plugins, and custom date/reading-time filters.
|
||||
|
||||
### 4.3 Directory Map
|
||||
```text
|
||||
.
|
||||
├── .agents/ # Antigravity agent configurations & rules
|
||||
├── sample-designs/ # Design specifications & HTML/CSS prototypes
|
||||
├── src/ # Blog engine source
|
||||
│ ├── _data/ # Site metadata & navigation
|
||||
│ ├── _filters/ # Modular Eleventy filters (date, readingTime, headings)
|
||||
│ ├── _includes/
|
||||
│ │ ├── layouts/ # Base & post Nunjucks templates
|
||||
│ │ └── partials/ # Global bar, hero tile, post cards, code box
|
||||
│ ├── assets/
|
||||
│ │ └── css/ # Modular CSS (tokens, reset, layout, components)
|
||||
│ └── posts/ # Default/fallback markdown posts
|
||||
├── test/ # Automated test suite (node:test)
|
||||
├── docker-compose.yml # Local development compose file
|
||||
├── sample-docker-compose.yml # Production-ready compose template
|
||||
├── Dockerfile # Production container image definition
|
||||
├── entrypoint.sh # Container startup & compilation entrypoint
|
||||
├── server.js # Native Node static HTTP server
|
||||
├── eleventy.config.js # Eleventy configuration
|
||||
├── package.json # Project scripts & dependencies
|
||||
└── README.md # Documentation & runbook
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Strict Design Specification
|
||||
|
||||
All UI components and stylesheets strictly follow [`sample-designs/design-specification.md`](./sample-designs/design-specification.md):
|
||||
|
||||
| Rule | Constraint |
|
||||
| :--- | :--- |
|
||||
| **Color Scheme** | Dark-mode native only. Canvas: `#0d0f12`, Surface: `#121417`, Elevated: `#1c1f26`, Borders: `#2b303b`. |
|
||||
| **Accents** | Terminal accents: Cyan (`#00f0ff`), Green (`#00e676`), Amber (`#ffb700`), Red (`#ff5252`). |
|
||||
| **Borders & Radius** | Crisp 1px solid borders. Zero to 4px maximum border-radius (sharp corners). |
|
||||
| **Strict Anti-Patterns** | **NO** glassmorphism (`backdrop-filter`), **NO** drop shadows or glows, **NO** gradients, **NO** spring animations. |
|
||||
| **Typography** | Monospace for UI & code (`IBM Plex Mono`, `JetBrains Mono`); clean sans-serif for body prose (`Inter`). |
|
||||
| **Interactivity** | Instant binary hover snaps (no slow fade transitions); high-contrast focus outlines for keyboard navigation. |
|
||||
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Pass-through mode: if arguments were provided, execute them directly
|
||||
if [ "$#" -gt 0 ]; then
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Build static site from mounted or packaged markdown posts
|
||||
echo "[blog-engine] Compiling static site with Eleventy..."
|
||||
npm run build || exit 1
|
||||
|
||||
# Optional watch mode for zero-restart live note updates
|
||||
if [ "${WATCH:-false}" = "true" ]; then
|
||||
echo "[blog-engine] Starting live watch daemon for markdown changes..."
|
||||
npx @11ty/eleventy --watch &
|
||||
fi
|
||||
|
||||
# Start production static web server
|
||||
echo "[blog-engine] Starting static server on port ${PORT:-8080}..."
|
||||
exec node server.js
|
||||
@@ -106,6 +106,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-pretitle {
|
||||
|
||||
@@ -407,11 +407,11 @@
|
||||
<!-- Main Hero Landing Tile (Central Focus of Homepage) -->
|
||||
<section class="hero-tile" aria-label="Homepage Mission & Landing Declaration">
|
||||
<div class="hero-pretitle">SYSTEM DECLARATION • RACK-01 NODE GATEWAY</div>
|
||||
<h1 class="hero-main-title">Engineering Sovereign Infrastructure & Technical Notes</h1>
|
||||
<h1 class="hero-main-title">Cybersecurity & Dev Ops notes</h1>
|
||||
|
||||
<div class="hero-landing-text">
|
||||
<p>
|
||||
I am River Rooks, a cybersecurity engineering student at the University of Alabama. My work centers on network forensics, self-hosted system architecture, low-level firmware exploitation, and zero-allocation Unix utilities.
|
||||
I am River Rooks, a cybersecurity student at the University of Alabama. My work centers on self-hosted system architecture, network security, and capture the flag style skills. I maintain a personal homelab cluster for research, experimentation, and development of open-source tools.
|
||||
</p>
|
||||
<p>
|
||||
This website functions as the unified frontend for my physical homelab cluster. Rather than hosting projects on opaque third-party clouds, everything here runs on bare metal in my personal server rack—orchestrated through Proxmox VE, partitioned across isolated VLANs via OPNsense, and protected by point-to-point WireGuard cryptographic meshes.
|
||||
|
||||
+27
-14
@@ -1,25 +1,38 @@
|
||||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
SITE_DOMAIN: "${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||
SITE_URL: "${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||
PORT: "${PORT:-8080}"
|
||||
image: river-rooks-blog:latest
|
||||
# Pull prebuilt engine image from your Gitea registry
|
||||
image: git.riverrooks.dev/venus/river-rooks-blog:latest
|
||||
# Local build fallback (optional when building image locally)
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile
|
||||
# args:
|
||||
# SITE_DOMAIN: "${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||
# SITE_URL: "${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||
# PORT: "${PORT:-8080}"
|
||||
container_name: river_rooks_web
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "test/healthcheck.js"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
start_period: 3s
|
||||
retries: 3
|
||||
# Healthcheck is natively baked into the Dockerfile image metadata
|
||||
environment:
|
||||
- "SITE_DOMAIN=${SITE_DOMAIN:?Error: SITE_DOMAIN is not set. Please define SITE_DOMAIN in .env}"
|
||||
- "SITE_URL=${SITE_URL:-https://${SITE_DOMAIN}}"
|
||||
- "PORT=${PORT:-8080}"
|
||||
- "WATCH=${WATCH:-false}"
|
||||
volumes:
|
||||
# Mount external markdown posts directly into the container
|
||||
- "./posts:/app/src/posts:ro"
|
||||
ports:
|
||||
- "${HOST_PORT:-${PORT:-8080}}:${PORT:-8080}"
|
||||
networks:
|
||||
- backend
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.blog.rule=Host(`riverrooks.dev`) || Host(`www.riverrooks.dev`)"
|
||||
- "traefik.http.routers.blog.entrypoints=websecure"
|
||||
- "traefik.http.routers.blog.tls.certresolver=basic"
|
||||
- "traefik.http.services.blog.loadbalancer.server.port=8080"
|
||||
- "traefik.docker.network=backend"
|
||||
|
||||
networks:
|
||||
backend:
|
||||
external: true
|
||||
|
||||
@@ -24,12 +24,66 @@ const MIME_TYPES = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates the HTTP server instance for serving static files from _site
|
||||
* @param {string} [customSiteDir] Optional custom directory to serve from
|
||||
* Normalizes Obsidian-specific syntax (wikilinks, internal attachments)
|
||||
* into standard web URLs and image references.
|
||||
* @param {string} content Raw Markdown content from Obsidian
|
||||
* @returns {string} Normalized Markdown content
|
||||
*/
|
||||
export function normalizeObsidianMarkdown(content) {
|
||||
if (typeof content !== "string") return "";
|
||||
|
||||
let normalized = content;
|
||||
|
||||
// Bridge created -> date so Eleventy collections sorting and filters work seamlessly
|
||||
const createdMatch = normalized.match(/^created:\s*([^\r\n]+)$/m);
|
||||
const hasDate = /^date:\s*.+$/m.test(normalized);
|
||||
if (createdMatch && !hasDate) {
|
||||
normalized = normalized.replace(/^(---\r?\n)/, `$1date: ${createdMatch[1].trim()}\n`);
|
||||
}
|
||||
|
||||
// 1. Convert internal attachments: ![[image.png|alt]] or ![[image.png]]
|
||||
normalized = normalized.replace(/!\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/g, (match, filename, alt) => {
|
||||
const cleanFilename = path.basename(filename.trim());
|
||||
const altText = alt ? alt.trim() : cleanFilename;
|
||||
return ``;
|
||||
});
|
||||
|
||||
// 2. Convert wikilinks with label: [[target-slug|Custom Label]] -> [Custom Label](/posts/target-slug/)
|
||||
normalized = normalized.replace(/\[\[([^|\]]+)\|([^\]]+)\]\]/g, (match, target, label) => {
|
||||
const slug = slugify(target);
|
||||
return `[${label.trim()}](/posts/${slug}/)`;
|
||||
});
|
||||
|
||||
// 3. Convert simple wikilinks: [[target-slug]] -> [target-slug](/posts/target-slug/)
|
||||
normalized = normalized.replace(/\[\[([^\]]+)\]\]/g, (match, target) => {
|
||||
const trimmed = target.trim();
|
||||
const slug = slugify(trimmed);
|
||||
return `[${trimmed}](/posts/${slug}/)`;
|
||||
});
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function slugify(text) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, "")
|
||||
.replace(/[\s_-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the HTTP server instance for serving static files and handling ingestion API
|
||||
* @param {string} [customSiteDir] Optional custom directory for _site
|
||||
* @param {string} [customPostsDir] Optional custom directory for posts
|
||||
* @param {string} [customAssetsDir] Optional custom directory for uploaded post assets
|
||||
* @returns {http.Server}
|
||||
*/
|
||||
export function createServer(customSiteDir) {
|
||||
export function createServer(customSiteDir, customPostsDir, customAssetsDir) {
|
||||
const siteDir = path.resolve(customSiteDir || path.join(process.cwd(), "_site"));
|
||||
const postsDir = path.resolve(customPostsDir || path.join(process.cwd(), "src/posts"));
|
||||
const assetsDir = path.resolve(customAssetsDir || path.join(process.cwd(), "src/assets/images/posts"));
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
// Standard security headers
|
||||
@@ -46,6 +100,127 @@ export function createServer(customSiteDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Obsidian Ingestion API: /api/posts
|
||||
if (parsedUrl.pathname === "/api/posts") {
|
||||
const authHeader = req.headers["authorization"] || "";
|
||||
const expectedSecret = process.env.API_SECRET;
|
||||
|
||||
if (!expectedSecret) {
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Server API_SECRET is not configured" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const match = authHeader.match(/^Bearer\s+(.+)$/i);
|
||||
if (!match || match[1] !== expectedSecret) {
|
||||
res.writeHead(401, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Unauthorized: Invalid or missing Bearer token" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
if (body.length > 50 * 1024 * 1024) {
|
||||
res.writeHead(413, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Payload too large (50MB limit)" }));
|
||||
req.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(body);
|
||||
} catch {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid JSON payload" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const { slug, content, assets } = payload;
|
||||
if (!slug || typeof slug !== "string" || !content || typeof content !== "string") {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Missing required fields: 'slug' and 'content'" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanSlug = slug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
||||
if (!cleanSlug) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Invalid slug format" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasTitle = /^title:\s*.+$/m.test(content);
|
||||
const hasDate = /^date:\s*.+$/m.test(content) || /^created:\s*.+$/m.test(content);
|
||||
if (!hasTitle || !hasDate) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Missing required frontmatter: 'title' and 'created' (or 'date') are required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(postsDir, { recursive: true });
|
||||
fs.mkdirSync(assetsDir, { recursive: true });
|
||||
|
||||
const normalizedContent = normalizeObsidianMarkdown(content);
|
||||
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
|
||||
fs.writeFileSync(targetFilePath, normalizedContent, "utf-8");
|
||||
|
||||
if (Array.isArray(assets)) {
|
||||
for (const asset of assets) {
|
||||
if (asset && asset.filename && asset.data) {
|
||||
const cleanName = path.basename(asset.filename).replace(/[^a-zA-Z0-9._-]/g, "");
|
||||
if (cleanName) {
|
||||
const assetPath = path.join(assetsDir, cleanName);
|
||||
fs.writeFileSync(assetPath, Buffer.from(asset.data, "base64"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({
|
||||
status: "published",
|
||||
slug: cleanSlug,
|
||||
path: `/posts/${cleanSlug}/`
|
||||
}));
|
||||
} catch (writeErr) {
|
||||
console.error("[Ingestion API] Error saving post/assets:", writeErr);
|
||||
res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: `Failed to write file to disk: ${writeErr.message}` }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "DELETE") {
|
||||
const querySlug = parsedUrl.searchParams.get("slug");
|
||||
if (!querySlug) {
|
||||
res.writeHead(400, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Missing 'slug' parameter" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanSlug = querySlug.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "");
|
||||
const targetFilePath = path.join(postsDir, `${cleanSlug}.md`);
|
||||
|
||||
if (fs.existsSync(targetFilePath)) {
|
||||
fs.unlinkSync(targetFilePath);
|
||||
}
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "deleted", slug: cleanSlug }));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(405, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Path normalization & security validation against directory traversal
|
||||
let decodedPath = "";
|
||||
try {
|
||||
|
||||
+9
-3
@@ -54,8 +54,8 @@ export default {
|
||||
url: "/dispatches/"
|
||||
},
|
||||
{
|
||||
label: "Gitea",
|
||||
url: `https://git.${siteDomain}`,
|
||||
label: "Code",
|
||||
url: `https://git.riverrooks.dev/venus`,
|
||||
external: true
|
||||
},
|
||||
{
|
||||
@@ -66,7 +66,7 @@ export default {
|
||||
services: [
|
||||
{
|
||||
name: "Gitea",
|
||||
url: `https://git.${siteDomain}`,
|
||||
url: `https://git.riverrooks.dev/venus`,
|
||||
tag: `git.${siteDomain}:2222`,
|
||||
desc: "Self-hosted Git code repositories."
|
||||
},
|
||||
@@ -93,6 +93,12 @@ export default {
|
||||
url: `https://library.${siteDomain}`,
|
||||
tag: `books.${siteDomain}:443`,
|
||||
desc: `Calibre web frontend for managing and reading eBooks.`
|
||||
},
|
||||
{
|
||||
name: "Whoami",
|
||||
url: `https://whoami.${siteDomain}`,
|
||||
tag: `whoami.${siteDomain}:443`,
|
||||
desc: `Simple service to display request headers and connection information.`
|
||||
}
|
||||
],
|
||||
topics: [
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.hero-pretitle {
|
||||
|
||||
+5
-3
@@ -6,15 +6,17 @@ title: "River Rooks | Homelab Gateway"
|
||||
<!-- Main Hero Landing Tile -->
|
||||
<section class="hero-tile" aria-label="Gateway Declaration">
|
||||
<div class="hero-pretitle">SYSTEM DECLARATION • NODE GATEWAY</div>
|
||||
<h1 class="hero-main-title">Engineering Sovereign Infrastructure & Technical Notes</h1>
|
||||
<h1 class="hero-main-title">Cybersecurity & Dev Ops notes</h1>
|
||||
|
||||
<div class="hero-landing-text">
|
||||
<p>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum.
|
||||
I am River Rooks, a cybersecurity student and the University of Alabama. This website is the public-facing gateway to my homelab, which hosts a variety of services and tools for personal and professional use. Here, you can find my technical blog and a collection of public tools that host.
|
||||
</p>
|
||||
<p>
|
||||
Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla. Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos.
|
||||
I deploy and maintain a variety of services in my homelab for research, development, and learning purposes. This includes web applications, databases, and other tools that I use to enhance my skills in cybersecurity and DevOps.
|
||||
</p>
|
||||
<p>
|
||||
My blog serves as a way to share some of my extensive notes with the public. I primarily focus on Capture The Flag (CTF) writeups, technical guides, and other cybersecurity-related content. I also use this platform to document my learning journey and share insights into the world of cybersecurity and DevOps.
|
||||
</div>
|
||||
|
||||
<div class="hero-directive-box">
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
---
|
||||
layout: layouts/post.njk
|
||||
title: "Infrastructure Notes & Systems Deployment Guide"
|
||||
date: 2026-07-15
|
||||
tags:
|
||||
- homelab
|
||||
- networking
|
||||
- templates
|
||||
excerpt: "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident."
|
||||
readingTime: "4 MIN READ"
|
||||
featured: false
|
||||
difficulty: "BEGINNER"
|
||||
---
|
||||
|
||||
## 1.0 Topology Overview
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua:
|
||||
|
||||
```text
|
||||
[Gateway Ingress]
|
||||
|
|
||||
[Edge Router]
|
||||
|
|
||||
+-----+-----+
|
||||
| |
|
||||
[Node 01] [Node 02]
|
||||
```
|
||||
|
||||
## 2.0 Deployment Configuration
|
||||
|
||||
Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
app:
|
||||
image: service:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:80"
|
||||
```
|
||||
|
||||
## 3.0 Verification Testing
|
||||
|
||||
Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur:
|
||||
|
||||
```sh
|
||||
curl -s http://localhost:8080/health
|
||||
```
|
||||
|
||||
Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
|
||||
@@ -1,78 +0,0 @@
|
||||
---
|
||||
layout: layouts/post.njk
|
||||
title: "Sample Technical Dispatch: Systems Architecture & Implementation"
|
||||
date: 2026-07-20
|
||||
tags:
|
||||
- systems
|
||||
- architecture
|
||||
- templates
|
||||
excerpt: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam."
|
||||
readingTime: "6 MIN READ"
|
||||
featured: true
|
||||
difficulty: "INTERMEDIATE"
|
||||
---
|
||||
|
||||
## 1.0 Executive Summary
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam. Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla.
|
||||
|
||||
Curabitur sodales ligula in libero. Sed dignissim lacinia nunc. Curabitur tortor. Pellentesque nibh. Aenean quam. In scelerisque sem at dolor. Maecenas mattis. Sed convallis tristique sem. Proin ut ligula vel nunc egestas porttitor. Morbi lectus risus, iaculis vel, suscipit quis, luctus non, massa.
|
||||
|
||||
<div class="markdown-callout callout-note">
|
||||
<div class="callout-title">[!NOTE] Operational Directives</div>
|
||||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Integer nec odio. Praesent libero. Sed cursus ante dapibus diam.</p>
|
||||
</div>
|
||||
|
||||
## 2.0 System Parameters & Specifications
|
||||
|
||||
Class aptent taciti sociosqu ad litora torquent per conubia nostra, per inceptos himenaeos. Nam nec ante. Sed lacinia, urna non tincidunt mattis, tortor neque adipiscing diam, a cursus ipsum ante quis turpis:
|
||||
|
||||
### 2.1 Interface Configuration Matrix
|
||||
|
||||
| Parameter ID | Signal Name | Protocol Bus | Expected Value |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **0x01** | `SYS_CLOCK` | SPI Primary | 100 MHz |
|
||||
| **0x02** | `DATA_IN` | Serial Bus | Bidirectional |
|
||||
| **0x03** | `CONTROL_REG` | I2C Header | 0x3F Active |
|
||||
| **0x04** | `GROUND_REF` | Chassis GND | 0.0V Nominal |
|
||||
|
||||
<div class="markdown-callout callout-warning">
|
||||
<div class="callout-title">[!WARNING] Configuration Voltage Boundary</div>
|
||||
<p>Sed nisi. Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta.</p>
|
||||
</div>
|
||||
|
||||
## 3.0 Execution Workflow
|
||||
|
||||
Nulla quis sem at nibh elementum imperdiet. Duis sagittis ipsum. Praesent mauris. Fusce nec tellus sed augue semper porta:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
# Sample deployment and verification routine
|
||||
set -eu
|
||||
|
||||
echo "[+] Initializing subsystem..."
|
||||
check_environment() {
|
||||
test -f /etc/config.json && echo "Config verified."
|
||||
}
|
||||
|
||||
check_environment
|
||||
```
|
||||
|
||||
Press <kbd>Ctrl</kbd> + <kbd>C</kbd> to interrupt operations safely at any point.
|
||||
|
||||
## 4.0 Telemetry & Verification
|
||||
|
||||
Fusce nec tellus sed augue semper porta. Mauris massa. Vestibulum lacinia arcu eget nulla:
|
||||
|
||||
- **Metric A:** `1,048,576 ops/sec`
|
||||
- **Metric B:** `SHA256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
|
||||
- **Latency:** `< 2.5ms` nominal
|
||||
|
||||
## 5.0 Audit Checklist
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit:
|
||||
|
||||
- [x] Baseline architectural parameters verified
|
||||
- [x] Initial configuration and unit tests passed
|
||||
- [x] System telemetry within expected boundaries
|
||||
- [ ] Production deployment and final review
|
||||
@@ -3,7 +3,7 @@ import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
test("Docker Configuration: Dockerfile enforces Node runtime and excludes Nginx", () => {
|
||||
test("Docker Configuration: Dockerfile enforces Node runtime, healthcheck, and entrypoint", () => {
|
||||
const dockerfilePath = path.resolve(process.cwd(), "Dockerfile");
|
||||
assert.ok(fs.existsSync(dockerfilePath), "Dockerfile must exist");
|
||||
|
||||
@@ -16,12 +16,32 @@ test("Docker Configuration: Dockerfile enforces Node runtime and excludes Nginx"
|
||||
assert.doesNotMatch(content, /FROM\s+nginx/i, "Dockerfile must not use nginx base image");
|
||||
assert.doesNotMatch(content, /nginx/i, "Dockerfile must not contain nginx directives");
|
||||
|
||||
// Must build static site and serve with npm
|
||||
assert.match(content, /RUN\s+npm\s+run\s+build/, "Dockerfile must run npm build");
|
||||
// Must build static site and configure dynamic port
|
||||
assert.match(content, /RUN\s+npm\s+run\s+build/, "Dockerfile must compile baseline static assets");
|
||||
assert.match(content, /ARG\s+PORT=8080/, "Dockerfile must define ARG PORT with default fallback");
|
||||
assert.match(content, /ENV\s+PORT=\$\{?PORT\}?/, "Dockerfile must define ENV PORT using variable");
|
||||
assert.match(content, /EXPOSE\s+\$\{?PORT\}?/, "Dockerfile must expose dynamic PORT variable");
|
||||
assert.match(content, /CMD\s*\[\s*["']npm["']\s*,\s*["']start["']\s*\]/, "Dockerfile must start via npm start");
|
||||
|
||||
// Must define built-in HEALTHCHECK probing 127.0.0.1 loopback
|
||||
assert.match(content, /HEALTHCHECK[\s\S]*?127\.0\.0\.1/, "Dockerfile must define baked-in HEALTHCHECK on 127.0.0.1");
|
||||
|
||||
// Must configure dynamic entrypoint script
|
||||
assert.match(content, /ENTRYPOINT\s*\[\s*["']\/app\/entrypoint\.sh["']\s*\]/, "Dockerfile must use /app/entrypoint.sh entrypoint");
|
||||
});
|
||||
|
||||
test("Docker Configuration: entrypoint.sh script exists and has valid structure", () => {
|
||||
const entrypointPath = path.resolve(process.cwd(), "entrypoint.sh");
|
||||
assert.ok(fs.existsSync(entrypointPath), "entrypoint.sh must exist");
|
||||
|
||||
const stats = fs.statSync(entrypointPath);
|
||||
// Check executable permissions (at least owner executable: 0o100)
|
||||
assert.ok(Boolean(stats.mode & 0o111), "entrypoint.sh must have executable permission bits");
|
||||
|
||||
const content = fs.readFileSync(entrypointPath, "utf-8");
|
||||
assert.match(content, /^#!\/bin\/sh/, "entrypoint.sh must use POSIX /bin/sh shebang");
|
||||
assert.match(content, /exec\s+"\$@"/, "entrypoint.sh must pass through custom container arguments");
|
||||
assert.match(content, /npm\s+run\s+build/, "entrypoint.sh must build the static site");
|
||||
assert.match(content, /exec\s+node\s+server\.js/, "entrypoint.sh must start static server with exec");
|
||||
});
|
||||
|
||||
test("Docker Configuration: docker-compose configures Node service correctly", () => {
|
||||
@@ -33,9 +53,12 @@ test("Docker Configuration: docker-compose configures Node service correctly", (
|
||||
const content = fs.readFileSync(composePath, "utf-8");
|
||||
|
||||
// Verifies dynamic port mapping and environment variables
|
||||
assert.match(content, /PORT:\s*"?\$\{PORT:-8080\}"?/, "docker-compose.yml must pass dynamic PORT build arg and env");
|
||||
assert.match(content, /PORT.*8080/, "docker-compose.yml must configure PORT fallback");
|
||||
assert.match(content, /:\$\{PORT:-8080\}/, "docker-compose.yml must map dynamic container PORT");
|
||||
|
||||
// Verifies domain argument enforcement
|
||||
assert.match(content, /SITE_DOMAIN/, "docker-compose.yml must require SITE_DOMAIN");
|
||||
|
||||
// Verifies markdown volume mount
|
||||
assert.match(content, /\/app\/src\/posts/, "docker-compose.yml must mount external markdown posts");
|
||||
});
|
||||
|
||||
@@ -6,10 +6,15 @@ import path from "node:path";
|
||||
const postsDir = path.resolve("src/posts");
|
||||
|
||||
test("Content Contract: All posts in src/posts/ satisfy frontmatter schema", () => {
|
||||
assert.ok(fs.existsSync(postsDir), "src/posts directory must exist");
|
||||
if (!fs.existsSync(postsDir)) {
|
||||
fs.mkdirSync(postsDir, { recursive: true });
|
||||
}
|
||||
const postFiles = fs.readdirSync(postsDir).filter((f) => f.endsWith(".md"));
|
||||
|
||||
assert.ok(postFiles.length > 0, "There must be at least one published post");
|
||||
if (postFiles.length === 0) {
|
||||
// When posts are decoupled and mounted externally or ingested via API
|
||||
return;
|
||||
}
|
||||
|
||||
for (const file of postFiles) {
|
||||
const filePath = path.join(postsDir, file);
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import http from "node:http";
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const TEST_SECRET = "super-secret-test-token";
|
||||
|
||||
test("Ingestion API: rejects requests without proper Bearer token", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||
process.env.API_SECRET = TEST_SECRET;
|
||||
|
||||
const server = createServer(tmpDir, tmpPosts);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
try {
|
||||
// 1. Missing Authorization header
|
||||
const resNoAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ slug: "test", content: "test" })
|
||||
});
|
||||
assert.strictEqual(resNoAuth.status, 401, "Missing auth header must return 401");
|
||||
|
||||
// 2. Invalid Bearer token
|
||||
const resBadAuth = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer wrong-token"
|
||||
},
|
||||
body: JSON.stringify({ slug: "test", content: "test" })
|
||||
});
|
||||
assert.strictEqual(resBadAuth.status, 401, "Invalid token must return 401");
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Ingestion API: validates required fields and frontmatter schema", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||
process.env.API_SECRET = TEST_SECRET;
|
||||
|
||||
const server = createServer(tmpDir, tmpPosts);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
try {
|
||||
// Missing slug
|
||||
const resNoSlug = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
},
|
||||
body: JSON.stringify({ content: "---\ntitle: 'Test'\ndate: 2026-09-14\n---\nHello" })
|
||||
});
|
||||
assert.strictEqual(resNoSlug.status, 400, "Missing slug must return 400");
|
||||
|
||||
// Missing required frontmatter title
|
||||
const resNoTitle = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: "no-title-post",
|
||||
content: "---\ndate: 2026-09-14\n---\nHello"
|
||||
})
|
||||
});
|
||||
assert.strictEqual(resNoTitle.status, 400, "Missing frontmatter title must return 400");
|
||||
|
||||
// Missing required frontmatter date or created
|
||||
const resNoDate = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: "no-date-post",
|
||||
content: "---\ntitle: 'Valid Title'\n---\nHello"
|
||||
})
|
||||
});
|
||||
assert.strictEqual(resNoDate.status, 400, "Missing frontmatter created/date must return 400");
|
||||
|
||||
// Valid with created instead of date
|
||||
const resWithCreated = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: "created-tag-post",
|
||||
content: "---\ntitle: 'Valid Title'\ncreated: 2026-09-14\n---\nHello"
|
||||
})
|
||||
});
|
||||
assert.strictEqual(resWithCreated.status, 200, "Valid post with created tag must return 200");
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Ingestion API: publishes markdown, normalizes wikilinks, saves assets, and handles delete", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||
const tmpAssets = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-assets-"));
|
||||
process.env.API_SECRET = TEST_SECRET;
|
||||
|
||||
const server = createServer(tmpDir, tmpPosts, tmpAssets);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
const rawMarkdown = `---
|
||||
title: "Reverse Engineering Flock Safety"
|
||||
created: 2026-09-14
|
||||
tags: ["#security", "#firmware"]
|
||||
excerpt: "A deep dive into firmware."
|
||||
---
|
||||
|
||||
Check out [[other-post|Other Post]] and also [[simple-link]].
|
||||
Here is an image: ![[camera-board.png]]
|
||||
`;
|
||||
|
||||
// 1x1 transparent PNG base64
|
||||
const sampleBase64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
|
||||
|
||||
try {
|
||||
// Publish post
|
||||
const resPublish = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: "flock-firmware",
|
||||
content: rawMarkdown,
|
||||
assets: [
|
||||
{
|
||||
filename: "camera-board.png",
|
||||
data: sampleBase64
|
||||
}
|
||||
]
|
||||
})
|
||||
});
|
||||
|
||||
assert.strictEqual(resPublish.status, 200, "Valid publish must return 200");
|
||||
const json = await resPublish.json();
|
||||
assert.strictEqual(json.status, "published");
|
||||
assert.strictEqual(json.path, "/posts/flock-firmware/");
|
||||
|
||||
// Verify markdown file written and wikilinks normalized
|
||||
const writtenPath = path.join(tmpPosts, "flock-firmware.md");
|
||||
assert.ok(fs.existsSync(writtenPath), "Post markdown file must be written to disk");
|
||||
|
||||
const writtenContent = fs.readFileSync(writtenPath, "utf-8");
|
||||
assert.match(writtenContent, /\[Other Post\]\(\/posts\/other-post\/\)/, "Wikilink with alias must be translated");
|
||||
assert.match(writtenContent, /\[simple-link\]\(\/posts\/simple-link\/\)/, "Simple wikilink must be translated");
|
||||
assert.match(writtenContent, /!\[camera-board\.png\]\(\/assets\/images\/posts\/camera-board\.png\)/, "Attachment wikilink must be translated");
|
||||
|
||||
// Verify asset image was written
|
||||
const assetPath = path.join(tmpAssets, "camera-board.png");
|
||||
assert.ok(fs.existsSync(assetPath), "Asset file must be saved");
|
||||
|
||||
// Delete post
|
||||
const resDelete = await fetch(`http://127.0.0.1:${port}/api/posts?slug=flock-firmware`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
}
|
||||
});
|
||||
assert.strictEqual(resDelete.status, 200, "Delete must return 200");
|
||||
assert.ok(!fs.existsSync(writtenPath), "Post markdown file must be deleted");
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||
fs.rmSync(tmpAssets, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Ingestion API: adversarial protection against path traversal in slug", async () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-site-"));
|
||||
const tmpPosts = fs.mkdtempSync(path.join(process.cwd(), "test-tmp-posts-"));
|
||||
process.env.API_SECRET = TEST_SECRET;
|
||||
|
||||
const server = createServer(tmpDir, tmpPosts);
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const port = server.address().port;
|
||||
|
||||
try {
|
||||
const resTraversal = await fetch(`http://127.0.0.1:${port}/api/posts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${TEST_SECRET}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: "../../etc/evil",
|
||||
content: "---\ntitle: 'Evil'\ndate: 2026-09-14\n---\nEvil content"
|
||||
})
|
||||
});
|
||||
// Should sanitize or reject slug containing path traversal
|
||||
if (resTraversal.status === 200) {
|
||||
const evilPath = path.resolve(tmpPosts, "../../etc/evil.md");
|
||||
assert.ok(!fs.existsSync(evilPath), "Must never write outside posts directory");
|
||||
} else {
|
||||
assert.strictEqual(resTraversal.status, 400, "Invalid slug must return 400");
|
||||
}
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.rmSync(tmpPosts, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -40,7 +40,7 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
||||
// Verify dynamic subdomains
|
||||
const gitService = site.services.find((s) => s.name === "Gitea");
|
||||
assert.ok(gitService, "Gitea service exists");
|
||||
assert.equal(gitService.url, "https://git.testcluster.internal");
|
||||
assert.equal(gitService.url, "https://git.riverrooks.dev/venus");
|
||||
assert.equal(gitService.tag, "git.testcluster.internal:2222");
|
||||
|
||||
const chefService = site.services.find((s) => s.name === "CyberChef");
|
||||
@@ -52,9 +52,9 @@ test("Site Config: correctly parses SITE_DOMAIN and derives service subdomains",
|
||||
assert.ok(navHome, "Nav Home link exists");
|
||||
assert.equal(navHome.url, "/");
|
||||
|
||||
const navGit = site.nav.find((n) => n.label === "Gitea");
|
||||
assert.ok(navGit, "Nav Gitea link exists");
|
||||
assert.equal(navGit.url, "https://git.testcluster.internal");
|
||||
const navCode = site.nav.find((n) => n.label === "Code");
|
||||
assert.ok(navCode, "Nav Code link exists");
|
||||
assert.equal(navCode.url, "https://git.riverrooks.dev/venus");
|
||||
});
|
||||
|
||||
test("Site Config: respects explicit SITE_URL override", async () => {
|
||||
|
||||
Reference in New Issue
Block a user