Skip to content

Commit 48d54ae

Browse files
committed
feat: encrypted sql dumps
1 parent f5aa821 commit 48d54ae

File tree

17 files changed

+1686
-238
lines changed

17 files changed

+1686
-238
lines changed

docs/content/docs/1.getting-started/3.configuration.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,26 @@ preview: {
492492
}
493493
```
494494

495+
496+
## `encryption`
497+
498+
Nuxt Content v3 can optionally **encrypt** the prerendered content dumps so they can be hosted as public static assets (CDN, Cloudflare Pages) while remaining unreadable without a key. The browser fetches the encrypted dump, requests a short-lived key from your app (after authentication), decrypts locally, then hydrates the WASM SQLite database.
499+
500+
### `content.encryption`
501+
502+
```ts [nuxt.config.ts]
503+
export default defineNuxtConfig({
504+
content: {
505+
encryption: {
506+
enabled: true, // turn on encrypted dumps + key endpoint
507+
masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes)
508+
}
509+
}
510+
})
511+
```
512+
513+
If `masterKey` is omitted, a random 32-byte key is generated at build time and kept on the server.
514+
495515
## `experimental`
496516

497517
Experimental features that are not yet stable.
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
---
2+
title: Encrypted Dumps
3+
description: Encrypted Dumps in Nuxt Content allow you to serve content safely on a public CDN while requiring authentication to access it.
4+
---
5+
6+
Encrypted Dumps in Nuxt Content allow you to serve your content safely on a public CDN (e.g. Cloudflare Pages) without exposing the raw `.sql` database files.
7+
Instead, dumps are encrypted at build time and decrypted in the browser only after the user has authenticated and received a short-lived key.
8+
9+
They are especially useful for:
10+
11+
- Hosting private content on static/CDN deployments
12+
- Keeping v3’s fast client-side SQLite queries without leaking raw dumps
13+
- Adding fine-grained access control using your own authentication
14+
15+
## How it works
16+
17+
1. **Build** – Each collection is compressed and encrypted with AES-256-GCM.
18+
2. **Static hosting** – Only encrypted `.enc` files are published (`dump.<collection>.sql.enc`).
19+
3. **Key request** – The client requests a short-lived key from `/__nuxt_content/:collection/key`, passing the `kid` extracted from the encrypted dump envelope.
20+
4. **Decrypt & hydrate** – The browser decrypts the dump in memory and hydrates its WASM SQLite database.
21+
22+
Without the key, the dumps are useless.
23+
24+
## Static files produced
25+
26+
When `encryption.enabled = true`:
27+
28+
-`dump.<collection>.sql.enc` → Encrypted database dump, safe to host on CDN.
29+
-`database/queries/*.sql` → Still generated internally, but not exposed publicly.
30+
- ❌ No `.sql` or `.txt` raw dumps are emitted to `public/` or `_nuxt/`.
31+
32+
When `encryption.enabled = false` (default):
33+
34+
- Raw `.sql` or `.txt` dumps are emitted and directly fetched by the client (plain-text behavior).
35+
36+
37+
## API endpoints
38+
39+
Nuxt Content automatically provides endpoints for both **encrypted** and **unencrypted** modes.
40+
41+
### 1. Encrypted mode
42+
43+
- `GET /__nuxt_content/:collection/sql_dump.enc`
44+
Returns the encrypted dump envelope (stringified JSON, base64).
45+
Safe to cache on a CDN.
46+
47+
- `GET /__nuxt_content/:collection/key?kid=<kid>`
48+
Returns `{ kid, k }` where `k` is the short-lived base64-encoded AES key. The `kid` comes from the dump’s envelope and ensures the key matches the actual dump version, even if the SPA is stale.
49+
Must be protected with **your authentication middleware**.
50+
This endpoint is the only place the actual key is exposed.
51+
52+
### 2. Plaintext (no encryption)
53+
54+
- `GET /__nuxt_content/:collection/sql_dump.txt`
55+
Returns the raw compressed SQL array (unsafe for private data).
56+
Still available when `encryption.enabled = false`.
57+
58+
- `POST /__nuxt_content/:collection/query`
59+
Runs an SQL query against the collection database.
60+
Used internally by the client after the dump is hydrated.
61+
62+
## Offline access
63+
64+
When a dump has been decrypted once, the client can cache the derived key locally (keyed by `kid`). On subsequent loads, the cached key is tried first to allow reading content while offline. If it fails (e.g. after a redeploy with a new checksum), the client discards it and requests a fresh key.
65+
66+
## Enable encryption
67+
68+
```ts [nuxt.config.ts]
69+
export default defineNuxtConfig({
70+
content: {
71+
encryption: {
72+
enabled: true,
73+
masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes)
74+
}
75+
}
76+
})
77+
```
78+
79+
If `masterKey` isn't provided, Nuxt Content generates a random 32-byte key at build time and keeps it on the server.
80+
81+
Generate a master key:
82+
83+
```bash
84+
openssl rand -base64 32
85+
# or
86+
node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
87+
```
88+
89+
## Authentication middleware
90+
91+
You must protect the **key endpoint** and the `__nuxt_content` so only authenticated users receive decryption keys and content:
92+
93+
```
94+
// server/middleware/content-auth.ts
95+
import { defineEventHandler, createError, getRequestURL } from 'h3'
96+
97+
// --- Provided elsewhere. Do NOT implement here. ---
98+
declare function getUser(event: any): any | null
99+
declare function hasAccess(user: any, collection: string): boolean
100+
declare function isAdmin(user: any): boolean
101+
// --------------------------------------------------
102+
103+
// all collections prefixed course_ will be private each collection has a different key
104+
const PRIVATE_COLLECTION_PREFIXES: string[] = ['course_']
105+
106+
function getCollectionFromPath(pathname: string): string | null {
107+
const m = pathname.match(/\/__nuxt_content\/([^/]+)/)
108+
return m ? m[1] : null
109+
}
110+
111+
function isKeyEndpoint(pathname: string): boolean {
112+
return /\/__nuxt_content\/[^/]+\/key\/?$/.test(pathname)
113+
}
114+
115+
function isPrivateCollection(collection: string): boolean {
116+
return PRIVATE_COLLECTION_PREFIXES.some(prefix =>
117+
collection.startsWith(prefix)
118+
)
119+
}
120+
121+
export default defineEventHandler(async (event) => {
122+
const url = getRequestURL(event).pathname
123+
124+
// Skip auth for prerender or build phases
125+
if (
126+
process.env.NODE_ENV === 'prerender' ||
127+
process.env.npm_lifecycle_event === 'build'
128+
) {
129+
return
130+
}
131+
132+
// Handle admin endpoints
133+
if (url.includes('/api/admin')) {
134+
const user = getUser(event)
135+
if (!user || !isAdmin(user)) {
136+
throw createError({
137+
statusCode: 403,
138+
statusMessage: 'Unauthorized',
139+
})
140+
}
141+
return
142+
}
143+
144+
// Only protect __nuxt_content routes
145+
if (!url.includes('/__nuxt_content/')) return
146+
147+
const collection = getCollectionFromPath(url)
148+
const forKey = isKeyEndpoint(url)
149+
150+
if (!collection) {
151+
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
152+
}
153+
154+
// Public collections (e.g. "blog") are always allowed
155+
if (!isPrivateCollection(collection)) return
156+
157+
// Private collections: require user
158+
const user = getUser(event)
159+
if (!user) {
160+
throw createError({
161+
statusCode: 401,
162+
statusMessage: 'Unauthorized',
163+
message: forKey
164+
? 'Sign in to request a decryption key for this collection.'
165+
: 'Sign in to access this collection.',
166+
})
167+
}
168+
169+
// Authorization check via provided helper
170+
if (!hasAccess(user, collection)) {
171+
throw createError({
172+
statusCode: 403,
173+
statusMessage: 'Forbidden',
174+
message: forKey
175+
? 'You do not have permission to obtain a key for this collection.'
176+
: 'You do not have permission to access this collection.',
177+
})
178+
}
179+
180+
// If reached here: allowed
181+
})
182+
```
183+
184+
- The /__nuxt_content/:collection/key endpoint is invoked after this middleware.
185+
- Because each collection (`course_*`) runs its own HKDF derivation, the API will hand out different decryption keys for different collections.
186+
- A client with the course key cannot decrypt the premium dump, and vice versa — the separation is enforced cryptographically.
187+
188+
## Why encrypted dumps are secure
189+
190+
This design uses HKDF (HMAC-based Key Derivation Function) to ensure strong separation between collections:
191+
192+
- If you **don’t hand out a key** from `/__nuxt_content/:collection/key`, the client cannot decrypt that collection’s dump.
193+
The encrypted file on the CDN is useless without the key.
194+
- If you hand out a key for one collection (e.g. `posts`), the client can only decrypt that dump.
195+
They cannot derive or guess the key for another collection (e.g. `docs`) because:
196+
- The HKDF `info` parameter is different (`content:posts` vs `content:docs`).
197+
- The server never shares the **master key**.
198+
- Since the **kid** (which encodes the dump’s checksum) is included in the derivation, a new build with updated content produces a new key. Old keys won’t work with updated dumps.
199+
200+
## Summary
201+
202+
* Encrypted dumps are **safe static artifacts**.
203+
* API endpoints provide either the encrypted blob or a short-lived key.
204+
* Middleware is required to control who can fetch keys.
205+
* Clients transparently decrypt and hydrate, preserving v3’s offline & fast querying benefits.

src/module.ts

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { stat } from 'node:fs/promises'
2+
import { randomBytes } from 'node:crypto'
23
import {
34
defineNuxtModule,
45
createResolver,
@@ -149,12 +150,22 @@ export default defineNuxtModule<ModuleOptions>({
149150
// Prerender database.sql routes for each collection to fetch dump
150151
nuxt.options.routeRules ||= {}
151152

152-
// @ts-expect-error - Prevent nuxtseo from indexing nuxt-content routes
153-
// @see https://github.com/nuxt/content/pull/3299
154-
nuxt.options.routeRules![`/__nuxt_content/**`] = { robots: false }
153+
// Prevent nuxtseo from indexing nuxt-content routes
154+
// @ts-expect-error - routeRules uses string index globs which Nuxt supports at runtime but TypeScript cannot type
155+
nuxt.options.routeRules!['/__nuxt_content/**'] = { robots: false }
156+
157+
if (options.encryption?.enabled && !options.encryption.masterKey) {
158+
options.encryption.masterKey = randomBytes(32).toString('base64')
159+
}
160+
const encryptionEnabled = !!(options.encryption?.enabled && options.encryption.masterKey)
155161

156162
manifest.collections.forEach((collection) => {
157-
if (!collection.private) {
163+
if (collection.private) return
164+
165+
if (encryptionEnabled) {
166+
nuxt.options.routeRules![`/__nuxt_content/${collection.name}/sql_dump.enc`] = { prerender: true }
167+
}
168+
else {
158169
nuxt.options.routeRules![`/__nuxt_content/${collection.name}/sql_dump.txt`] = { prerender: true }
159170
}
160171
})
@@ -170,13 +181,19 @@ export default defineNuxtModule<ModuleOptions>({
170181
// Module Options
171182
nuxt.options.runtimeConfig.public.content = {
172183
wsUrl: '',
173-
}
184+
// Expose encryption status to client/runtime so fetchers know which endpoint to use.
185+
encryptionEnabled,
186+
} as never
174187
nuxt.options.runtimeConfig.content = {
175188
databaseVersion,
176189
version,
177190
database: options.database,
178191
localDatabase: options._localDatabase!,
179192
integrityCheck: true,
193+
encryption: {
194+
enabled: encryptionEnabled,
195+
masterKey: encryptionEnabled ? options.encryption!.masterKey : undefined,
196+
},
180197
} as never
181198

182199
nuxt.hook('nitro:config', async (config) => {
@@ -241,6 +258,10 @@ export default defineNuxtModule<ModuleOptions>({
241258
})
242259

243260
async function processCollectionItems(nuxt: Nuxt, collections: ResolvedCollection[], options: ModuleOptions) {
261+
// unchanged … (keep your original implementation)
262+
// NOTE: no changes needed below for the encryption feature
263+
// (build-time dumps are already prepared; templates handle .sql vs .sql.enc)
264+
// ----------------------------------------------------------------------------
244265
const collectionDump: Record<string, string[]> = {}
245266
const collectionChecksum: Record<string, string> = {}
246267
const collectionChecksumStructure: Record<string, string> = {}

src/presets/cloudflare.ts

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,28 @@
1-
import { addTemplate } from '@nuxt/kit'
2-
import { join } from 'pathe'
1+
// src/presets/cloudflare.ts
32
import { logger } from '../utils/dev'
43
import { definePreset } from '../utils/preset'
5-
import { collectionDumpTemplate } from '../utils/templates'
4+
import { applyContentDumpsPreset } from './shared-dumps'
65

76
export default definePreset({
87
name: 'cloudflare',
9-
async setupNitro(nitroConfig, { manifest, resolver }) {
8+
async setup(options) {
9+
if (!options.database || options.database.type !== 'd1') {
10+
options.database = { type: 'd1', bindingName: 'DB' }
11+
return
12+
}
13+
14+
if ('binding' in options.database && options.database.binding && !options.database.bindingName) {
15+
options.database.bindingName = options.database.binding
16+
}
17+
18+
options.database.bindingName ||= 'DB'
19+
},
20+
async setupNitro(nitroConfig, ctx) {
1021
if (nitroConfig.runtimeConfig?.content?.database?.type === 'sqlite') {
1122
logger.warn('Deploying to Cloudflare requires using D1 database, switching to D1 database with binding `DB`.')
1223
nitroConfig.runtimeConfig!.content!.database = { type: 'd1', bindingName: 'DB' }
1324
}
1425

15-
nitroConfig.publicAssets ||= []
16-
nitroConfig.alias = nitroConfig.alias || {}
17-
nitroConfig.handlers ||= []
18-
19-
// Add raw content dump
20-
manifest.collections.map(async (collection) => {
21-
if (!collection.private) {
22-
addTemplate(collectionDumpTemplate(collection.name, manifest))
23-
}
24-
})
25-
26-
// Add raw content dump to public assets
27-
nitroConfig.publicAssets.push({ dir: join(nitroConfig.buildDir!, 'content', 'raw'), maxAge: 60 })
28-
nitroConfig.handlers.push({
29-
route: '/__nuxt_content/:collection/sql_dump.txt',
30-
handler: resolver.resolve('./runtime/presets/cloudflare/database-handler'),
31-
})
26+
applyContentDumpsPreset(nitroConfig, { ...ctx, platform: 'cloudflare' })
3227
},
33-
3428
})

src/presets/node.ts

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
1-
import { addTemplate } from '@nuxt/kit'
2-
import { fullDatabaseCompressedDumpTemplate } from '../utils/templates'
1+
// src/presets/node.ts
32
import { definePreset } from '../utils/preset'
3+
import { applyContentDumpsPreset } from './shared-dumps'
44

55
export default definePreset({
66
name: 'node',
7-
setupNitro(nitroConfig, { manifest, resolver }) {
8-
nitroConfig.publicAssets ||= []
9-
nitroConfig.alias = nitroConfig.alias || {}
10-
nitroConfig.handlers ||= []
11-
12-
nitroConfig.alias['#content/dump'] = addTemplate(fullDatabaseCompressedDumpTemplate(manifest)).dst
13-
nitroConfig.handlers.push({
14-
route: '/__nuxt_content/:collection/sql_dump.txt',
15-
handler: resolver.resolve('./runtime/presets/node/database-handler'),
16-
})
7+
async setupNitro(nitroConfig, ctx) {
8+
applyContentDumpsPreset(nitroConfig, { ...ctx, platform: 'node' })
179
},
1810
})

0 commit comments

Comments
 (0)