From 4ccffc7d2dd8b44812be4a7af6337d454381bca0 Mon Sep 17 00:00:00 2001 From: Oliver-Tobias Ripka Date: Sun, 21 Sep 2025 15:57:22 +0200 Subject: [PATCH] feat: sql dump encryption --- .../docs/1.getting-started/3.configuration.md | 20 ++ docs/content/docs/8.advanced/9.private.md | 205 ++++++++++++++++++ src/module.ts | 26 ++- src/presets/cloudflare.ts | 38 ++-- src/presets/node.ts | 16 +- src/presets/nuxthub.ts | 53 +++-- src/presets/shared-dumps.ts | 80 +++++++ src/runtime/api/query.post.ts | 5 +- src/runtime/internal/api.ts | 185 ++++++++++++++-- src/runtime/internal/database.client.ts | 121 ++++++++++- src/runtime/internal/database.server.ts | 78 ++++++- src/runtime/internal/dump.ts | 74 ++++++- src/runtime/internal/encryption.ts | 195 +++++++++++++++++ .../presets/cloudflare/database-handler.ts | 171 ++++++++++++++- src/runtime/presets/node/database-handler.ts | 162 +++++++++++++- src/types/module.ts | 22 ++ src/utils/templates.ts | 37 +++- 17 files changed, 1375 insertions(+), 113 deletions(-) create mode 100644 docs/content/docs/8.advanced/9.private.md create mode 100644 src/presets/shared-dumps.ts create mode 100644 src/runtime/internal/encryption.ts diff --git a/docs/content/docs/1.getting-started/3.configuration.md b/docs/content/docs/1.getting-started/3.configuration.md index 6fda89392..a001ef1c7 100644 --- a/docs/content/docs/1.getting-started/3.configuration.md +++ b/docs/content/docs/1.getting-started/3.configuration.md @@ -492,6 +492,26 @@ preview: { } ``` + +## `encryption` + +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. + +### `content.encryption` + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + content: { + encryption: { + enabled: true, // turn on encrypted dumps + key endpoint + masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes) + } + } +}) +``` + +If `masterKey` is omitted, a random 32-byte key is generated at build time and kept on the server. + ## `experimental` Experimental features that are not yet stable. diff --git a/docs/content/docs/8.advanced/9.private.md b/docs/content/docs/8.advanced/9.private.md new file mode 100644 index 000000000..a77bf9afd --- /dev/null +++ b/docs/content/docs/8.advanced/9.private.md @@ -0,0 +1,205 @@ +--- +title: Encrypted Dumps +description: Encrypted Dumps in Nuxt Content allow you to serve content safely on a public CDN while requiring authentication to access it. +--- + +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. +Instead, dumps are encrypted at build time and decrypted in the browser only after the user has authenticated and received a short-lived key. + +They are especially useful for: + +- Hosting private content on static/CDN deployments +- Keeping v3’s fast client-side SQLite queries without leaking raw dumps +- Adding fine-grained access control using your own authentication + +## How it works + +1. **Build** – Each collection is compressed and encrypted with AES-256-GCM. +2. **Static hosting** – Only encrypted `.enc` files are published (`dump..sql.enc`). +3. **Key request** – The client requests a short-lived key from `/__nuxt_content/:collection/key`, passing the `kid` extracted from the encrypted dump envelope. +4. **Decrypt & hydrate** – The browser decrypts the dump in memory and hydrates its WASM SQLite database. + +Without the key, the dumps are useless. + +## Static files produced + +When `encryption.enabled = true`: + +- ✅ `dump..sql.enc` → Encrypted database dump, safe to host on CDN. +- ✅ `database/queries/*.sql` → Still generated internally, but not exposed publicly. +- ❌ No `.sql` or `.txt` raw dumps are emitted to `public/` or `_nuxt/`. + +When `encryption.enabled = false` (default): + +- Raw `.sql` or `.txt` dumps are emitted and directly fetched by the client (plain-text behavior). + + +## API endpoints + +Nuxt Content automatically provides endpoints for both **encrypted** and **unencrypted** modes. + +### 1. Encrypted mode + +- `GET /__nuxt_content/:collection/sql_dump.enc` + Returns the encrypted dump envelope (stringified JSON, base64). + Safe to cache on a CDN. + +- `GET /__nuxt_content/:collection/key?kid=` + 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. + Must be protected with **your authentication middleware**. + This endpoint is the only place the actual key is exposed. + +### 2. Plaintext (no encryption) + +- `GET /__nuxt_content/:collection/sql_dump.txt` + Returns the raw compressed SQL array (unsafe for private data). + Still available when `encryption.enabled = false`. + +- `POST /__nuxt_content/:collection/query` + Runs an SQL query against the collection database. + Used internally by the client after the dump is hydrated. + +## Offline access + +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. + +## Enable encryption + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + content: { + encryption: { + enabled: true, + masterKey: process.env.NUXT_CONTENT_MASTER_KEY, // base64(32 bytes) + } + } +}) +``` + +If `masterKey` isn't provided, Nuxt Content generates a random 32-byte key at build time and keeps it on the server. + +Generate a master key: + +```bash +openssl rand -base64 32 +# or +node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +``` + +## Authentication middleware + +You must protect the **key endpoint** and the `__nuxt_content` so only authenticated users receive decryption keys and content: + +``` +// server/middleware/content-auth.ts +import { defineEventHandler, createError, getRequestURL } from 'h3' + +// --- Provided elsewhere. Do NOT implement here. --- +declare function getUser(event: any): any | null +declare function hasAccess(user: any, collection: string): boolean +declare function isAdmin(user: any): boolean +// -------------------------------------------------- + +// all collections prefixed course_ will be private each collection has a different key +const PRIVATE_COLLECTION_PREFIXES: string[] = ['course_'] + +function getCollectionFromPath(pathname: string): string | null { + const m = pathname.match(/\/__nuxt_content\/([^/]+)/) + return m ? m[1] : null +} + +function isKeyEndpoint(pathname: string): boolean { + return /\/__nuxt_content\/[^/]+\/key\/?$/.test(pathname) +} + +function isPrivateCollection(collection: string): boolean { + return PRIVATE_COLLECTION_PREFIXES.some(prefix => + collection.startsWith(prefix) + ) +} + +export default defineEventHandler(async (event) => { + const url = getRequestURL(event).pathname + + // Skip auth for prerender or build phases + if ( + process.env.NODE_ENV === 'prerender' || + process.env.npm_lifecycle_event === 'build' + ) { + return + } + + // Handle admin endpoints + if (url.includes('/api/admin')) { + const user = getUser(event) + if (!user || !isAdmin(user)) { + throw createError({ + statusCode: 403, + statusMessage: 'Unauthorized', + }) + } + return + } + + // Only protect __nuxt_content routes + if (!url.includes('/__nuxt_content/')) return + + const collection = getCollectionFromPath(url) + const forKey = isKeyEndpoint(url) + + if (!collection) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + + // Public collections (e.g. "blog") are always allowed + if (!isPrivateCollection(collection)) return + + // Private collections: require user + const user = getUser(event) + if (!user) { + throw createError({ + statusCode: 401, + statusMessage: 'Unauthorized', + message: forKey + ? 'Sign in to request a decryption key for this collection.' + : 'Sign in to access this collection.', + }) + } + + // Authorization check via provided helper + if (!hasAccess(user, collection)) { + throw createError({ + statusCode: 403, + statusMessage: 'Forbidden', + message: forKey + ? 'You do not have permission to obtain a key for this collection.' + : 'You do not have permission to access this collection.', + }) + } + + // If reached here: allowed +}) +``` + +- The /__nuxt_content/:collection/key endpoint is invoked after this middleware. +- Because each collection (`course_*`) runs its own HKDF derivation, the API will hand out different decryption keys for different collections. +- A client with the course key cannot decrypt the premium dump, and vice versa — the separation is enforced cryptographically. + +## Why encrypted dumps are secure + +This design uses HKDF (HMAC-based Key Derivation Function) to ensure strong separation between collections: + +- If you **don’t hand out a key** from `/__nuxt_content/:collection/key`, the client cannot decrypt that collection’s dump. + The encrypted file on the CDN is useless without the key. +- If you hand out a key for one collection (e.g. `posts`), the client can only decrypt that dump. + They cannot derive or guess the key for another collection (e.g. `docs`) because: + - The HKDF `info` parameter is different (`content:posts` vs `content:docs`). + - The server never shares the **master key**. +- 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. + +## Summary + +* Encrypted dumps are **safe static artifacts**. +* API endpoints provide either the encrypted blob or a short-lived key. +* Middleware is required to control who can fetch keys. +* Clients transparently decrypt and hydrate, preserving v3’s offline & fast querying benefits. \ No newline at end of file diff --git a/src/module.ts b/src/module.ts index d7c1ed73f..0fe87ba96 100644 --- a/src/module.ts +++ b/src/module.ts @@ -1,4 +1,5 @@ import { stat } from 'node:fs/promises' +import { randomBytes } from 'node:crypto' import { defineNuxtModule, createResolver, @@ -149,12 +150,22 @@ export default defineNuxtModule({ // Prerender database.sql routes for each collection to fetch dump nuxt.options.routeRules ||= {} - // @ts-expect-error - Prevent nuxtseo from indexing nuxt-content routes - // @see https://github.com/nuxt/content/pull/3299 - nuxt.options.routeRules![`/__nuxt_content/**`] = { robots: false } + // Prevent nuxtseo from indexing nuxt-content routes + // @ts-expect-error - routeRules uses string index globs which Nuxt supports at runtime but TypeScript cannot type + nuxt.options.routeRules!['/__nuxt_content/**'] = { robots: false } + + if (options.encryption?.enabled && !options.encryption.masterKey) { + options.encryption.masterKey = randomBytes(32).toString('base64') + } + const encryptionEnabled = !!(options.encryption?.enabled && options.encryption.masterKey) manifest.collections.forEach((collection) => { - if (!collection.private) { + if (collection.private) return + + if (encryptionEnabled) { + nuxt.options.routeRules![`/__nuxt_content/${collection.name}/sql_dump.enc`] = { prerender: true } + } + else { nuxt.options.routeRules![`/__nuxt_content/${collection.name}/sql_dump.txt`] = { prerender: true } } }) @@ -170,13 +181,18 @@ export default defineNuxtModule({ // Module Options nuxt.options.runtimeConfig.public.content = { wsUrl: '', - } + encryptionEnabled, + } as never nuxt.options.runtimeConfig.content = { databaseVersion, version, database: options.database, localDatabase: options._localDatabase!, integrityCheck: true, + encryption: { + enabled: encryptionEnabled, + masterKey: encryptionEnabled ? options.encryption!.masterKey : undefined, + }, } as never nuxt.hook('nitro:config', async (config) => { diff --git a/src/presets/cloudflare.ts b/src/presets/cloudflare.ts index beb43bb2a..0baa4cbc3 100644 --- a/src/presets/cloudflare.ts +++ b/src/presets/cloudflare.ts @@ -1,34 +1,28 @@ -import { addTemplate } from '@nuxt/kit' -import { join } from 'pathe' +// src/presets/cloudflare.ts import { logger } from '../utils/dev' import { definePreset } from '../utils/preset' -import { collectionDumpTemplate } from '../utils/templates' +import { applyContentDumpsPreset } from './shared-dumps' export default definePreset({ name: 'cloudflare', - async setupNitro(nitroConfig, { manifest, resolver }) { + async setup(options) { + if (!options.database || options.database.type !== 'd1') { + options.database = { type: 'd1', bindingName: 'DB' } + return + } + + if ('binding' in options.database && options.database.binding && !options.database.bindingName) { + options.database.bindingName = options.database.binding + } + + options.database.bindingName ||= 'DB' + }, + async setupNitro(nitroConfig, ctx) { if (nitroConfig.runtimeConfig?.content?.database?.type === 'sqlite') { logger.warn('Deploying to Cloudflare requires using D1 database, switching to D1 database with binding `DB`.') nitroConfig.runtimeConfig!.content!.database = { type: 'd1', bindingName: 'DB' } } - nitroConfig.publicAssets ||= [] - nitroConfig.alias = nitroConfig.alias || {} - nitroConfig.handlers ||= [] - - // Add raw content dump - manifest.collections.map(async (collection) => { - if (!collection.private) { - addTemplate(collectionDumpTemplate(collection.name, manifest)) - } - }) - - // Add raw content dump to public assets - nitroConfig.publicAssets.push({ dir: join(nitroConfig.buildDir!, 'content', 'raw'), maxAge: 60 }) - nitroConfig.handlers.push({ - route: '/__nuxt_content/:collection/sql_dump.txt', - handler: resolver.resolve('./runtime/presets/cloudflare/database-handler'), - }) + applyContentDumpsPreset(nitroConfig, { ...ctx, platform: 'cloudflare' }) }, - }) diff --git a/src/presets/node.ts b/src/presets/node.ts index 6e4a15d09..4bdbb3a76 100644 --- a/src/presets/node.ts +++ b/src/presets/node.ts @@ -1,18 +1,10 @@ -import { addTemplate } from '@nuxt/kit' -import { fullDatabaseCompressedDumpTemplate } from '../utils/templates' +// src/presets/node.ts import { definePreset } from '../utils/preset' +import { applyContentDumpsPreset } from './shared-dumps' export default definePreset({ name: 'node', - setupNitro(nitroConfig, { manifest, resolver }) { - nitroConfig.publicAssets ||= [] - nitroConfig.alias = nitroConfig.alias || {} - nitroConfig.handlers ||= [] - - nitroConfig.alias['#content/dump'] = addTemplate(fullDatabaseCompressedDumpTemplate(manifest)).dst - nitroConfig.handlers.push({ - route: '/__nuxt_content/:collection/sql_dump.txt', - handler: resolver.resolve('./runtime/presets/node/database-handler'), - }) + async setupNitro(nitroConfig, ctx) { + applyContentDumpsPreset(nitroConfig, { ...ctx, platform: 'node' }) }, }) diff --git a/src/presets/nuxthub.ts b/src/presets/nuxthub.ts index b898a2130..6427a3ff6 100644 --- a/src/presets/nuxthub.ts +++ b/src/presets/nuxthub.ts @@ -2,7 +2,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { resolve } from 'pathe' import { logger } from '../utils/dev' import { definePreset } from '../utils/preset' -import cfPreset from './cloudflare' +import { applyContentDumpsPreset } from './shared-dumps' export default definePreset({ name: 'nuxthub', @@ -27,33 +27,42 @@ export default definePreset({ nitroConfig.runtimeConfig!.content!.database = { type: 'd1', bindingName: 'DB' } } - await cfPreset.setupNitro(nitroConfig, options) + // Apply Cloudflare-style dumps setup (encrypted or plaintext depending on config) + applyContentDumpsPreset(nitroConfig, { ...options, platform: 'nuxthub' }) if (nitroConfig.dev === false) { + const encryptionEnabled = !!nitroConfig.runtimeConfig?.content?.encryption?.enabled + + if (!encryptionEnabled) { // Write SQL dump to database queries when not in dev mode - await mkdir(resolve(nitroConfig.rootDir!, '.data/hub/database/queries'), { recursive: true }) - let i = 1 - // Drop info table and prepare for new dump - let dump = 'DROP TABLE IF EXISTS _content_info;' - const dumpFiles: Array<{ file: string, content: string }> = [] - Object.values(options.manifest.dump).forEach((value) => { - value.forEach((line) => { - if ((dump.length + line.length) < 1000000) { - dump += '\n' + line - } - else { - dumpFiles.push({ file: `content-database-${String(i).padStart(3, '0')}.sql`, content: dump.trim() }) - dump = line - i += 1 - } + await mkdir(resolve(nitroConfig.rootDir!, '.data/hub/database/queries'), { recursive: true }) + let i = 1 + // Drop info table and prepare for new dump + let dump = 'DROP TABLE IF EXISTS _content_info;' + const dumpFiles: Array<{ file: string, content: string }> = [] + Object.values(options.manifest.dump).forEach((value) => { + value.forEach((line) => { + if ((dump.length + line.length) < 1000000) { + dump += '\n' + line + } + else { + dumpFiles.push({ file: `content-database-${String(i).padStart(3, '0')}.sql`, content: dump.trim() }) + dump = line + i += 1 + } + }) }) - }) - if (dump.length > 0) { - dumpFiles.push({ file: `content-database-${String(i).padStart(3, '0')}.sql`, content: dump.trim() }) + if (dump.length > 0) { + dumpFiles.push({ file: `content-database-${String(i).padStart(3, '0')}.sql`, content: dump.trim() }) + } + for (const dumpFile of dumpFiles) { + await writeFile(resolve(nitroConfig.rootDir!, '.data/hub/database/queries', dumpFile.file), dumpFile.content) + } } - for (const dumpFile of dumpFiles) { - await writeFile(resolve(nitroConfig.rootDir!, '.data/hub/database/queries', dumpFile.file), dumpFile.content) + else { + logger.info('[content] encryption enabled — skipping NuxtHub plaintext query emission.') } + // Disable integrity check in production for performance nitroConfig.runtimeConfig ||= {} nitroConfig.runtimeConfig.content ||= {} diff --git a/src/presets/shared-dumps.ts b/src/presets/shared-dumps.ts new file mode 100644 index 000000000..9c254976e --- /dev/null +++ b/src/presets/shared-dumps.ts @@ -0,0 +1,80 @@ +// src/presets/shared-dumps.ts +import type { NitroConfig } from 'nitropack' +import type { Manifest } from '../types/manifest' +import { addTemplate } from '@nuxt/kit' +import { join } from 'pathe' +import { collectionDumpTemplate, collectionEncryptedDumpTemplate, fullDatabaseCompressedDumpTemplate } from '../utils/templates' + +export interface ApplyDumpsOptions { + manifest: Manifest + resolver: { resolve: (p: string) => string } + moduleOptions: { encryption?: { enabled?: boolean, masterKey?: string } } + platform: 'cloudflare' | 'node' | 'nuxthub' + exposePublicAssets?: boolean // default true (you allow public, but encrypted) + includeLegacyCompressedModule?: boolean // default true for node preset +} + +/** + * One place to: + * - emit per-collection templates (.sql or .sql.enc) + * - expose content/raw if desired + * - register handlers for dump + key endpoints + * - wire legacy compressed dump module if needed (node) + */ +export function applyContentDumpsPreset( + nitroConfig: NitroConfig, + { manifest, resolver, moduleOptions, platform, exposePublicAssets = true, includeLegacyCompressedModule = platform === 'node' }: ApplyDumpsOptions, +) { + const masterKey = moduleOptions?.encryption?.masterKey + const encryptionEnabled = !!(moduleOptions?.encryption?.enabled && masterKey) + + nitroConfig.publicAssets ||= [] + nitroConfig.alias ||= {} + nitroConfig.handlers ||= [] + + // 1) Expose /_nuxt/content/raw if you want CDN to serve blobs + if (exposePublicAssets) { + nitroConfig.publicAssets.push({ dir: join(nitroConfig.buildDir!, 'content', 'raw'), maxAge: 60 }) + } + + // 2) Emit per-collection dump templates (skip private) + for (const col of manifest.collections) { + if (col.private) continue + if (encryptionEnabled) { + addTemplate(collectionEncryptedDumpTemplate(col.name, manifest, { enabled: true, masterKey })) + } + else { + addTemplate(collectionDumpTemplate(col.name, manifest)) + } + } + + // 3) Legacy single-file compressed module (node only; backward-compat) + if (includeLegacyCompressedModule) { + nitroConfig.alias['#content/dump'] = addTemplate(fullDatabaseCompressedDumpTemplate(manifest)).dst + } + + // 4) Route handlers: platform-specific handler file, same routes + const useCloudflareHandler = platform === 'cloudflare' || platform === 'nuxthub' + const handlerPath + = useCloudflareHandler + ? './runtime/presets/cloudflare/database-handler' + : './runtime/presets/node/database-handler' + + if (!encryptionEnabled) { + nitroConfig.handlers.push({ + route: '/__nuxt_content/:collection/sql_dump.txt', + handler: resolver.resolve(handlerPath), + }) + } + else { + nitroConfig.handlers.push( + // Encrypted dump + { route: '/__nuxt_content/:collection/sql_dump.enc', handler: resolver.resolve(handlerPath) }, + // Key endpoint + { route: '/__nuxt_content/:collection/key', handler: resolver.resolve(handlerPath) }, + // Ensure .txt 404s (or handled) while encryption is enabled + { route: '/__nuxt_content/:collection/sql_dump.txt', handler: resolver.resolve(handlerPath) }, + + ) + } +} diff --git a/src/runtime/api/query.post.ts b/src/runtime/api/query.post.ts index 5e12c0b2b..f9e1f23ae 100644 --- a/src/runtime/api/query.post.ts +++ b/src/runtime/api/query.post.ts @@ -1,5 +1,5 @@ import { eventHandler, getRouterParam, readBody } from 'h3' -import loadDatabaseAdapter, { checkAndImportDatabaseIntegrity } from '../internal/database.server' +import loadDatabaseAdapter, { checkAndImportDatabaseIntegrity, ensureDatabaseReady } from '../internal/database.server' import { assertSafeQuery } from '../internal/security' import type { RuntimeConfig } from '@nuxt/content' import { useRuntimeConfig } from '#imports' @@ -14,6 +14,9 @@ export default eventHandler(async (event) => { if (conf.integrityCheck) { await checkAndImportDatabaseIntegrity(event, collection, conf) } + else { + await ensureDatabaseReady(event, collection, conf) + } return loadDatabaseAdapter(conf).all(sql) }) diff --git a/src/runtime/internal/api.ts b/src/runtime/internal/api.ts index 05a6c671a..8bc764520 100644 --- a/src/runtime/internal/api.ts +++ b/src/runtime/internal/api.ts @@ -1,29 +1,178 @@ -import type { H3Event } from 'h3' +// src/runtime/internal/api.ts +import { useRuntimeConfig } from '#imports' import { checksums } from '#content/manifest' +import type { H3Event } from 'h3' + +// Local types to avoid `any` +type PublicRuntime = { content?: { encryptionEnabled?: boolean } } +type PrivateRuntime = { content?: { encryption?: { enabled?: boolean } } } +type ErrorLike = { status?: number, statusCode?: number, response?: { status?: number }, message?: string } + +function encOn() { + const runtime = useRuntimeConfig() + const pub = runtime.public as Partial + const priv = runtime as Partial + return Boolean(pub?.content?.encryptionEnabled ?? priv?.content?.encryption?.enabled) +} + +function isRecoverable(e: unknown) { + const err = e as ErrorLike + const s = Number(err?.status ?? err?.statusCode ?? err?.response?.status ?? 0) + const m = String(err?.message ?? '') + return [401, 403, 404].includes(s) + || /decrypt|aes|gcm|checksum|ciphertext|operationerror/i.test(m) +} + +async function selfHealOnce(event: H3Event | undefined, collection: string) { + // Minimal self-heal: only clear this collection’s cached dump + checksum + try { + localStorage.removeItem(`content_${'checksum_' + collection}`) + localStorage.removeItem(`content_${'collection_' + collection}`) + } + catch { + // Non-critical: best-effort cleanup + } + + // If encryption is enabled, proactively (best-effort) re-fetch the key + if (encOn()) { + try { + await fetchDumpKey(event, collection) + } + catch { + // Non-critical: key fetch may fail; we retry later + } + } +} + +function withCloudflareContext>(event: H3Event | undefined, options: T): T { + const cloudflare = event?.context?.cloudflare + if (!cloudflare) { + return options + } + + return { ...options, context: { cloudflare } } as T +} + +// override fetchDatabase export async function fetchDatabase(event: H3Event | undefined, collection: string): Promise { - return await $fetch(`/__nuxt_content/${collection}/sql_dump.txt`, { - context: event ? { cloudflare: event.context.cloudflare } : {}, - responseType: 'text', - headers: { - 'content-type': 'text/plain', - ...(event?.node?.req?.headers?.cookie ? { cookie: event.node.req.headers.cookie } : {}), - }, - query: { v: checksums[String(collection)], t: import.meta.dev ? Date.now() : undefined }, - }) + const encPreferred = encOn() + const checksum = checksums[String(collection)] + const headers = { + 'content-type': 'text/plain', + ...(event?.node?.req?.headers?.cookie ? { cookie: event.node.req.headers.cookie } : {}), + } + const attempts = encPreferred + ? [ + `/__nuxt_content/${collection}/sql_dump.enc`, + `/__nuxt_content/${collection}/sql_dump.txt`, + ] + : [ + `/__nuxt_content/${collection}/sql_dump.txt`, + `/__nuxt_content/${collection}/sql_dump.enc`, + ] + + let lastError: unknown + for (const path of attempts) { + const query = { v: checksum, t: import.meta.dev ? Date.now() : undefined } + const doFetch = async (stamp?: number) => { + const payload = await $fetch(path, withCloudflareContext(event, { + responseType: 'text' as const, + headers, + query: { v: checksum, t: stamp ?? query.t }, + })) + + if (!payload || !payload.trim()) { + const error = new Error(`Empty dump payload from ${path}`) + Object.assign(error, { status: 404 }) + throw error + } + + return payload + } + + try { + return await doFetch() + } + catch (err) { + if (!isRecoverable(err)) { + throw err + } + lastError = err + await selfHealOnce(event, collection) + const retryStamp = Date.now() + try { + return await doFetch(retryStamp) + } + catch (retryErr) { + if (!isRecoverable(retryErr)) { + throw retryErr + } + lastError = retryErr + } + } + } + + throw lastError ?? new Error('Failed to fetch content dump') } -export async function fetchQuery(event: H3Event | undefined, collection: string, sql: string): Promise { - return await $fetch(`/__nuxt_content/${collection}/query`, { - context: event ? { cloudflare: event.context.cloudflare } : {}, +// override fetchQuery +export async function fetchQuery( + event: H3Event | undefined, + collection: string, + sql: string, +): Promise { + const checksum = checksums[String(collection)] + + const opts = { + method: 'POST' as const, headers: { 'content-type': 'application/json', ...(event?.node?.req?.headers?.cookie ? { cookie: event.node.req.headers.cookie } : {}), }, - query: { v: checksums[String(collection)], t: import.meta.dev ? Date.now() : undefined }, - method: 'POST', - body: { - sql, - }, + body: { sql }, + } + const initialQuery = { v: checksum, t: import.meta.dev ? Date.now() : undefined } + + try { + const rows = await $fetch( + `/__nuxt_content/${collection}/query`, + withCloudflareContext(event, { ...opts, query: initialQuery }), + ) + return rows + } + catch (e) { + if (!isRecoverable(e)) { + throw e + } + await selfHealOnce(event, collection) + const retryStamp = Date.now() + return await $fetch( + `/__nuxt_content/${collection}/query`, + withCloudflareContext(event, { + ...opts, + query: { v: checksum, t: retryStamp }, + }), + ) + } +} + +// keep fetchDumpKey as-is +export async function fetchDumpKey( + event: H3Event | undefined, + collection: string, + kid?: string, +): Promise<{ kid: string, k: string }> { + return await $fetch(`/__nuxt_content/${collection}/key`, { + ...withCloudflareContext(event, { + headers: { + 'content-type': 'application/json', + ...(event?.node?.req?.headers?.cookie ? { cookie: event.node.req.headers.cookie } : {}), + }, + // Prefer kid when available; fall back to legacy v=checksum for backward compatibility + query: kid + ? { kid, t: import.meta.dev ? Date.now() : undefined } + : { v: checksums[String(collection)], t: import.meta.dev ? Date.now() : undefined }, + }), }) } diff --git a/src/runtime/internal/database.client.ts b/src/runtime/internal/database.client.ts index 6a389aa9e..97cfb1ece 100644 --- a/src/runtime/internal/database.client.ts +++ b/src/runtime/internal/database.client.ts @@ -1,13 +1,28 @@ +import { useRuntimeConfig } from '#imports' import type { Database } from '@sqlite.org/sqlite-wasm' -import { decompressSQLDump } from './dump' +import { decompressSQLDump, decryptAndDecompressSQLDump } from './dump' import { refineContentFields } from './collection' -import { fetchDatabase } from './api' +import { fetchDatabase, fetchDumpKey } from './api' import type { DatabaseAdapter, DatabaseBindParams } from '@nuxt/content' import { checksums, tables } from '#content/manifest' +function extractKidFromEnvelope(b64: string | null): string | null { + if (!b64) return null + try { + // Envelopes are base64-encoded JSON. Decode and look for a `kid` field. + const decoded = atob(b64) + const m = decoded.match(/"kid"\s*:\s*"([^"]+)"/) + return m ? (m[1] ?? null) : null + } + catch { + return null + } +} + let db: Database const loadedCollections: Record = {} const dbPromises: Record> = {} + export function loadDatabaseAdapter(collection: T): DatabaseAdapter { async function loadAdapter(collection: T) { if (!db) { @@ -119,7 +134,107 @@ async function loadCollectionDatabase(collection: T) { } } - const dump = await decompressSQLDump(compressedDump!) + const encEnabled = !!( + useRuntimeConfig().public as unknown as { content?: { encryptionEnabled?: boolean } } + )?.content?.encryptionEnabled + + const dump = await (encEnabled + ? (async () => { + const kid = extractKidFromEnvelope(compressedDump!) + + // 1) Try cached derived key first (enables offline reads) + if (kid) { + try { + const cachedK = window.localStorage.getItem(`content_key_${kid}`) + if (cachedK) { + try { + const ok = await decryptAndDecompressSQLDump(compressedDump!, cachedK) + // Decryption worked with cached key + return ok + } + catch { + // Cached key is stale — purge it and continue to fetch a fresh one + try { + window.localStorage.removeItem(`content_key_${kid}`) + } + catch (purgeErr) { + console.debug?.('[content] could not remove stale cached key', purgeErr) + } + } + } + } + catch (readErr) { + console.debug?.('[content] reading cached key failed', readErr) + } + } + + // 2) No (working) cached key — fetch using kid when available (source of truth = envelope) + try { + const { k } = await fetchDumpKey(undefined, String(collection), kid || undefined) + const result = await decryptAndDecompressSQLDump(compressedDump!, k) + // Cache the derived key for offline use, keyed by kid + if (kid) { + try { + window.localStorage.setItem(`content_key_${kid}`, k) + } + catch (cacheKeyErr) { + console.debug?.('[content] failed to cache derived key for offline use', cacheKeyErr) + } + } + return result + } + catch (e) { + console.error('Failed to decrypt encrypted dump (first attempt):', e) + + // Minimal self-heal: wipe only this collection’s local cache + try { + window.localStorage.removeItem(`content_${checksumId}`) + window.localStorage.removeItem(`content_${dumpId}`) + if (kid) { + try { + window.localStorage.removeItem(`content_key_${kid}`) + } + catch (purgeKeyErr) { + console.debug?.('[content] failed to remove cached derived key', purgeKeyErr) + } + } + } + catch (purgeErr) { + console.debug?.('[content] decrypt retry: localStorage cleanup failed', purgeErr) + } + + // Force-refetch a fresh dump + key, bypassing stale local cache + try { + compressedDump = await fetchDatabase(undefined, String(collection)) + if (!import.meta.dev) { + try { + window.localStorage.setItem(`content_${checksumId}`, checksums[String(collection)]!) + window.localStorage.setItem(`content_${dumpId}`, compressedDump!) + } + catch (cacheErr) { + console.error('Database integrity check failed while caching refreshed dump', cacheErr) + } + } + const kid2 = extractKidFromEnvelope(compressedDump!) + const { k: k2 } = await fetchDumpKey(undefined, String(collection), kid2 || undefined) + const ok2 = await decryptAndDecompressSQLDump(compressedDump!, k2) + if (kid2) { + try { + window.localStorage.setItem(`content_key_${kid2}`, k2) + } + catch (cacheKeyErr) { + console.debug?.('[content] failed to cache derived key for offline use', cacheKeyErr) + } + } + return ok2 + } + catch (e2) { + console.error('Failed to decrypt encrypted dump (after local purge & refetch):', e2) + throw e2 + } + } + })() + : decompressSQLDump(compressedDump!)) await db.exec({ sql: `DROP TABLE IF EXISTS ${tables[String(collection)]}` }) if (checksumState === 'mismatch') { diff --git a/src/runtime/internal/database.server.ts b/src/runtime/internal/database.server.ts index c2db002a5..1ebb4171f 100644 --- a/src/runtime/internal/database.server.ts +++ b/src/runtime/internal/database.server.ts @@ -9,6 +9,12 @@ import type { DatabaseAdapter, RuntimeConfig } from '@nuxt/content' import { tables, checksums, checksumsStructure } from '#content/manifest' import adapter from '#content/adapter' import localAdapter from '#content/local-adapter' +import { + deriveContentKeyB64, + isEncryptedEnvelope, + decryptEnvelopeToGzipBytes, + bytesToB64, +} from './encryption' let db: Connector export default function loadDatabaseAdapter(config: RuntimeConfig['content']) { @@ -59,7 +65,44 @@ export async function checkAndImportDatabaseIntegrity(event: H3Event, collection } } -async function _checkAndImportDatabaseIntegrity(event: H3Event, collection: string, integrityVersion: string, structureIntegrityVersion: string, config: RuntimeConfig['content']) { +export async function ensureDatabaseReady( + event: H3Event, + collection: string, + config: RuntimeConfig['content'], +): Promise { + const key = String(collection) + const expectedVersion = checksums[key] + const expectedStructure = checksumsStructure[key] + + if (!expectedVersion || !expectedStructure) { + return + } + + const db = loadDatabaseAdapter(config) + const status = await db + .first<{ version: string, structureVersion: string, ready: boolean }>( + `SELECT version, structureVersion, ready FROM ${tables.info} WHERE id = ?`, + [`checksum_${collection}`], + ) + .catch(() => null) + + if (status?.ready && status.version === expectedVersion && status.structureVersion === expectedStructure) { + return + } + + checkDatabaseIntegrity[key] = true + integrityCheckPromise[key] = null + + await checkAndImportDatabaseIntegrity(event, collection, config) +} + +async function _checkAndImportDatabaseIntegrity( + event: H3Event, + collection: string, + integrityVersion: string, + structureIntegrityVersion: string, + config: RuntimeConfig['content'], +) { const db = loadDatabaseAdapter(config) const before = await db.first<{ version: string, structureVersion: string, ready: boolean }>(`SELECT * FROM ${tables.info} WHERE id = ?`, [`checksum_${collection}`]).catch((): null => null) @@ -98,7 +141,15 @@ async function _checkAndImportDatabaseIntegrity(event: H3Event, collection: stri } } - const dump = await loadDatabaseDump(event, collection).then(decompressSQLDump) + // --- fetch and normalize dump (handle encrypted envelope or plaintext) --- + + const raw = await loadDatabaseDump(event, collection) + const envelopeDetected = isEncryptedEnvelope(raw) + const dump = await (envelopeDetected + ? decryptEncryptedDump(raw, collection, config) + : decompressSQLDump(raw)) + // ---------------------------------------------------------------------- + const dumpLinesHash = dump.map(row => row.split(' -- ').pop()) let hashesInDb = new Set() @@ -200,6 +251,29 @@ async function loadDatabaseDump(event: H3Event, collection: string): Promise { + const checksum = checksums[String(collection)] || '' + const masterKey = config?.encryption?.masterKey + + if (!masterKey) { + throw new Error('Missing encryption masterKey') + } + + const derivedKeyB64 = await deriveContentKeyB64( + masterKey, + checksum, + collection, + ) + + const gzBytes = await decryptEnvelopeToGzipBytes(envelope, derivedKeyB64) + const gzBase64 = bytesToB64(gzBytes) + return await decompressSQLDump(gzBase64) +} + function refineDatabaseConfig(config: RuntimeConfig['content']['database']) { if (config.type === 'd1') { return { ...config, bindingName: config.bindingName || config.binding } diff --git a/src/runtime/internal/dump.ts b/src/runtime/internal/dump.ts index 2b92bdd35..a4811fd2d 100644 --- a/src/runtime/internal/dump.ts +++ b/src/runtime/internal/dump.ts @@ -1,11 +1,69 @@ -export async function decompressSQLDump(base64Str: string, compressionType: CompressionFormat = 'gzip'): Promise { - // Decode Base64 to binary data - const binaryData = Uint8Array.from(atob(base64Str), c => c.charCodeAt(0)) - - // Create a Response from the Blob and use the DecompressionStream - const response = new Response(new Blob([binaryData])) - const decompressedStream = response.body?.pipeThrough(new DecompressionStream(compressionType)) - // Parse the decompress text back into an array +import { b64ToBytes, normalizeBase64, toArrayBuffer } from './encryption' + +export async function decompressSQLDump( + base64Str: string, + compressionType: 'gzip' | 'deflate' = 'gzip', +): Promise { + // Browser/Workers fast path + if ( + typeof atob === 'function' + && typeof (globalThis as unknown as { DecompressionStream?: unknown }).DecompressionStream !== 'undefined' + ) { + const binaryData = b64ToBytes(base64Str) + const response = new Response(new Blob([toArrayBuffer(binaryData)])) + const decompressedStream = response.body?.pipeThrough(new DecompressionStream(compressionType)) + const text = await new Response(decompressedStream).text() + return JSON.parse(text) + } + + // Node fallback (no atob / DecompressionStream) + const { gunzipSync, inflateSync } = await import('node:zlib') + const buf = Buffer.from(normalizeBase64(base64Str), 'base64') + const out = compressionType === 'gzip' ? gunzipSync(buf) : inflateSync(buf) + return JSON.parse(out.toString('utf8')) +} + +interface DumpEnvelope { v: number, alg: string, iv: string, ciphertext: string } +function isDumpEnvelope(o: unknown): o is DumpEnvelope { + return !!o && typeof o === 'object' + && typeof (o as Record).v === 'number' + && typeof (o as Record).alg === 'string' + && typeof (o as Record).iv === 'string' + && typeof (o as Record).ciphertext === 'string' +} + +/** + * Decrypts an encrypted dump envelope (AES-256-GCM), then gunzips → JSON array. + * Accepts either base64(JSON) or raw JSON envelope (stringified). + */ +export async function decryptAndDecompressSQLDump( + envelopeInput: string, + keyRawB64: string, // base64(32 bytes) +): Promise { + // Try base64(JSON) first; fall back to raw JSON string + let envelope: unknown + try { + const jsonBytes = b64ToBytes(envelopeInput) + envelope = JSON.parse(new TextDecoder().decode(jsonBytes)) + } + catch { + envelope = JSON.parse(envelopeInput) + } + + if (!isDumpEnvelope(envelope) || envelope.v !== 1 || envelope.alg !== 'A256GCM') { + throw new Error('Unsupported dump envelope') + } + + const iv = b64ToBytes(envelope.iv) + const ciphertext = b64ToBytes(envelope.ciphertext) + const keyBytes = b64ToBytes(keyRawB64) + const cryptoKey = await crypto.subtle.importKey('raw', toArrayBuffer(keyBytes), { name: 'AES-GCM' }, false, ['decrypt']) + + const gzBytes = new Uint8Array( + await crypto.subtle.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, cryptoKey, toArrayBuffer(ciphertext)), + ) + const response = new Response(new Blob([toArrayBuffer(gzBytes)])) + const decompressedStream = response.body?.pipeThrough(new DecompressionStream('gzip')) const text = await new Response(decompressedStream).text() return JSON.parse(text) } diff --git a/src/runtime/internal/encryption.ts b/src/runtime/internal/encryption.ts new file mode 100644 index 000000000..c197aca0a --- /dev/null +++ b/src/runtime/internal/encryption.ts @@ -0,0 +1,195 @@ +// src/runtime/internal/encryption.ts + +import { subtle, getRandomValues } from 'uncrypto' + +// Use uncrypto to provide a consistent Web Crypto across Node, Cloudflare Workers, and browsers. +// Falls back to the platform crypto if available. +const te = new TextEncoder() + +const base64WhitespaceRE = /[\t\n\f\r ]+/g // TAB, LF, FF, CR, SPACE + +export function normalizeBase64(input: string): string { + return input.replace(base64WhitespaceRE, '') +} + +export function b64ToBytes(b64: string): Uint8Array { + const normalized = normalizeBase64(b64) + // Browser + if (typeof atob === 'function') { + return Uint8Array.from(atob(normalized), c => c.charCodeAt(0)) + } + // Node: build a fresh Uint8Array backed by a plain ArrayBuffer + const buf = Buffer.from(normalized, 'base64') + const out = new Uint8Array(buf.length) + out.set(buf) + return out +} + +export function bytesToB64(arr: Uint8Array): string { + // Prefer Node Buffer when available to handle large arrays without stack overflow + const hasBuffer = typeof Buffer !== 'undefined' && typeof Buffer.from === 'function' + if (hasBuffer) { + // Node (or environments exposing Buffer) + return Buffer.from(arr).toString('base64') + } + + // Browser fallback: build a binary string in chunks to avoid "Maximum call stack size exceeded" + if (typeof btoa === 'function') { + let binary = '' + const chunkSize = 0x8000 // 32k chars per chunk + for (let i = 0; i < arr.length; i += chunkSize) { + const chunk = arr.subarray(i, i + chunkSize) + binary += String.fromCharCode(...chunk) + } + return btoa(binary) + } + + throw new Error('[content] No base64 encoder available in this runtime') +} + +/** Ensure a clean ArrayBuffer view of a Uint8Array (no SharedArrayBuffer typing). */ +export function toArrayBuffer(u8: Uint8Array): ArrayBuffer { + const { buffer, byteOffset, byteLength } = u8 + + if (buffer instanceof ArrayBuffer) { + if (byteOffset === 0 && byteLength === buffer.byteLength) { + return buffer + } + + return buffer.slice(byteOffset, byteOffset + byteLength) + } + + const out = new ArrayBuffer(byteLength) + new Uint8Array(out).set(u8) + return out +} + +/** HKDF(master, salt=checksum, info=`content:${collection}`) → raw 32 bytes (AES-256) */ +export async function deriveContentKeyRaw( + masterKeyB64: string, + checksum: string, + collection: string, +): Promise { + const master = b64ToBytes(masterKeyB64) + const hkdfKey = await subtle.importKey( + 'raw', + toArrayBuffer(master), + { name: 'HKDF' }, + false, + ['deriveKey'], + ) + const derived = await subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: te.encode(checksum || ''), info: te.encode(`content:${collection}`) }, + hkdfKey, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'], + ) + const raw = new Uint8Array(await subtle.exportKey('raw', derived)) + return raw +} + +export async function deriveContentKeyB64( + masterKeyB64: string, + checksum: string, + collection: string, +): Promise { + const raw = await deriveContentKeyRaw(masterKeyB64, checksum, collection) + return bytesToB64(raw) +} + +/** Encrypt base64(gzip(JSON)) → base64(JSON envelope) */ +export async function encryptGzBase64Envelope( + gzBase64: string, + masterKeyB64: string, + checksum: string, + collection: string, +): Promise { + const keyRaw = await deriveContentKeyRaw(masterKeyB64, checksum, collection) + const key = await subtle.importKey( + 'raw', + toArrayBuffer(keyRaw), + { name: 'AES-GCM' }, + false, + ['encrypt'], + ) + + // IV generation via Web Crypto; required in all supported runtimes + const iv = getRandomValues(new Uint8Array(12)) + + const gzBytes = b64ToBytes(gzBase64) + const ct = new Uint8Array( + await subtle.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(gzBytes)), + ) + + const envelope = { + v: 1, + alg: 'A256GCM', + kid: `v1:${collection}:${checksum}`, + iv: bytesToB64(iv), + ciphertext: bytesToB64(ct), + } + const json = JSON.stringify(envelope) + // return base64(JSON) + if (typeof btoa === 'function') return btoa(json) + return Buffer.from(json, 'utf8').toString('base64') +} + +// Types +export interface DumpEnvelope { + v: 1 + alg: 'A256GCM' + kid: string + iv: string // base64(12) + ciphertext: string // base64 +} + +// Parse base64(JSON) or raw JSON string → envelope (or null) +export function parseEnvelopeMaybeBase64(input: string): DumpEnvelope | null { + try { + // base64(JSON) + const bytes = b64ToBytes(input) + const json = new TextDecoder().decode(bytes) + const e = JSON.parse(json) + return (e?.v === 1 && e?.alg === 'A256GCM') ? e as DumpEnvelope : null + } + catch { + try { + const e = JSON.parse(input) + return (e?.v === 1 && e?.alg === 'A256GCM') ? e as DumpEnvelope : null + } + catch { + return null + } + } +} + +export function isEncryptedEnvelope(input: string): boolean { + return !!parseEnvelopeMaybeBase64(input) +} + +/** + * Decrypt an envelope (given raw 32-byte key as base64) → gzipped bytes (Uint8Array) + * Useful for both server and client paths. + */ +export async function decryptEnvelopeToGzipBytes( + envelopeInput: string, + keyRawB64: string, +): Promise { + const env = parseEnvelopeMaybeBase64(envelopeInput) + if (!env) throw new Error('Invalid encrypted dump envelope') + const keyBytes = b64ToBytes(keyRawB64) + const key = await subtle.importKey( + 'raw', + toArrayBuffer(keyBytes), + { name: 'AES-GCM' }, + false, + ['decrypt'], + ) + const iv = b64ToBytes(env.iv) + const ciphertext = b64ToBytes(env.ciphertext) + const gz = new Uint8Array( + await subtle.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) }, key, toArrayBuffer(ciphertext)), + ) + return gz +} diff --git a/src/runtime/presets/cloudflare/database-handler.ts b/src/runtime/presets/cloudflare/database-handler.ts index 93c9eb445..69956b6ef 100644 --- a/src/runtime/presets/cloudflare/database-handler.ts +++ b/src/runtime/presets/cloudflare/database-handler.ts @@ -1,16 +1,171 @@ -import { eventHandler, getRouterParam, setHeader } from 'h3' -import { useStorage } from 'nitropack/runtime' +import { eventHandler, getRouterParam, setHeader, createError } from 'h3' +import type { H3Event } from 'h3' +import { useRuntimeConfig, useStorage } from 'nitropack/runtime' +import { deriveContentKeyB64, encryptGzBase64Envelope } from '../../internal/encryption' + +type AssetsBinding = { fetch: typeof fetch } + +type AssetFetchResult = { body: string, pathname: string } + +const encryptedAssetCandidates = (collection: string) => [ + `/__nuxt_content/${collection}/sql_dump.enc`, + `/_nuxt/content/raw/dump.${collection}.sql.enc`, + `/_nuxt/content/${collection}/sql_dump.enc`, +] + +const plaintextAssetCandidates = (collection: string) => [ + `/__nuxt_content/${collection}/sql_dump.txt`, + `/_nuxt/content/raw/dump.${collection}.sql`, + `/_nuxt/content/${collection}/sql_dump.txt`, +] + +async function fetchFromAssets(event: H3Event, pathnames: string[]): Promise { + const binding = (event?.context?.cloudflare?.env?.ASSETS as AssetsBinding | undefined) + ?? (typeof process !== 'undefined' + ? (process.env.ASSETS as unknown as AssetsBinding | undefined) + : undefined) + + if (!binding?.fetch) { + return null + } + + const requestUrl = event?.context?.cloudflare?.request?.url + + for (const candidate of pathnames) { + const normalizedPath = candidate.startsWith('/') ? candidate : `/${candidate}` + try { + const baseUrl = requestUrl + ? new URL(requestUrl) + : new URL(normalizedPath, 'http://localhost') + baseUrl.pathname = normalizedPath + baseUrl.search = '' + const response = await binding.fetch(baseUrl.toString()) + const contentType = response.headers.get('content-type') || '' + if (!response.ok) { + continue + } + const text = await response.text() + if ((/text\/html/i.test(contentType) || /^\s* { const collection = getRouterParam(event, 'collection')! + // `event.node.req.url` may be relative; supply a base URL to avoid runtime errors. + const url = new URL(event.node.req.url || '', 'http://localhost') + const runtime = useRuntimeConfig() + const encEnabled = !!runtime?.content?.encryption?.enabled + const masterB64 = runtime?.content?.encryption?.masterKey + const storage = useStorage() + const getStorageItem = async (key: string): Promise => { + try { + return await storage.getItem(key) + } + catch { + return null + } + } + + if (url.pathname.endsWith('/key')) { + if (!encEnabled) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + // TODO: Add your AuthN/Z here; deny if not allowed. + const kidParam = url.searchParams.get('kid') || '' + const vParam = url.searchParams.get('v') || '' + if (!masterB64) { + throw createError({ statusCode: 500, statusMessage: 'Missing content.encryption.masterKey' }) + } + + let derivedCollection = collection + let derivedChecksum = vParam + + // Prefer kid if provided: format expected "v1::" + if (kidParam) { + const parts = kidParam.split(':') + if (parts.length >= 3) { + derivedCollection = parts[1] || derivedCollection + derivedChecksum = parts[2] || derivedChecksum + } + } + + const k = await deriveContentKeyB64(masterB64, derivedChecksum, derivedCollection) + setHeader(event, 'Content-Type', 'application/json') + setHeader(event, 'Cache-Control', 'no-store') + return { kid: kidParam || `v1:${derivedCollection}:${derivedChecksum}`, k } + } + + // --- /__nuxt_content/:collection/sql_dump.enc --- + if (url.pathname.endsWith('/sql_dump.enc')) { + if (!encEnabled) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + setHeader(event, 'Content-Type', 'application/octet-stream') + // Prefer prebuilt encrypted dump if present + const encKey = `build:content:raw:dump.${collection}.sql.enc` + const plainKey = `build:content:raw:dump.${collection}.sql` + const prebuilt = await getStorageItem(encKey) + if (prebuilt !== null && prebuilt !== undefined) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return prebuilt + } + + const assetPrebuilt = await fetchFromAssets(event, encryptedAssetCandidates(collection)) + if (assetPrebuilt) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return assetPrebuilt.body + } + + // Fallback: encrypt the prebuilt plaintext on the fly + const gzBase64 = (await getStorageItem(plainKey)) + ?? await (async () => { + const asset = await fetchFromAssets( + event, + plaintextAssetCandidates(collection), + ) + return asset ? asset.body : null + })() + if (!masterB64) { + throw createError({ statusCode: 500, statusMessage: 'Missing content.encryption.masterKey' }) + } + if (!gzBase64) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + const checksum = url.searchParams.get('v') || '' + const envelopeB64 = await encryptGzBase64Envelope(gzBase64, masterB64, checksum, collection) + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return envelopeB64 + } + + // --- plaintext dump /__nuxt_content/:collection/sql_dump.txt --- + // Served only when encryption is disabled setHeader(event, 'Content-Type', 'text/plain') + if (encEnabled) { + // If someone hits .txt while enc is on, hide it + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } - const ASSETS = event?.context?.cloudflare?.env.ASSETS || process.env.ASSETS - if (ASSETS) { - const url = new URL(event.context.cloudflare?.request?.url || 'http://localhost') - url.pathname = `/dump.${collection}.sql` - return await ASSETS.fetch(url).then((r: Response) => r.text()) + // Prefer prebuilt plaintext from Nitro storage + const plainKey = `build:content:raw:dump.${collection}.sql` + const plain = await getStorageItem(plainKey) + if (plain !== null && plain !== undefined) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return plain } - return await useStorage().getItem(`build:content:raw:dump.${collection}.sql`) || '' + const assetPlain = await fetchFromAssets(event, plaintextAssetCandidates(collection)) + if (assetPlain) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return assetPlain.body + } + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) }) diff --git a/src/runtime/presets/node/database-handler.ts b/src/runtime/presets/node/database-handler.ts index 2df88eeb9..ee3ecc93c 100644 --- a/src/runtime/presets/node/database-handler.ts +++ b/src/runtime/presets/node/database-handler.ts @@ -1,19 +1,159 @@ -import { eventHandler, getRouterParam, setHeader } from 'h3' -import { useStorage } from 'nitropack/runtime' +import { eventHandler, getRouterParam, setHeader, createError } from 'h3' +import type { H3Event } from 'h3' +import { useRuntimeConfig, useStorage } from 'nitropack/runtime' +import { deriveContentKeyB64, encryptGzBase64Envelope } from '../../internal/encryption' + +type AssetsBinding = { fetch: typeof fetch } + +type AssetFetchResult = { body: string, pathname: string } + +const encryptedAssetCandidates = (collection: string) => [ + `/_nuxt/content/raw/dump.${collection}.sql.enc`, + `/__nuxt_content/${collection}/sql_dump.enc`, + `/_nuxt/content/${collection}/sql_dump.enc`, +] + +const plaintextAssetCandidates = (collection: string) => [ + `/_nuxt/content/raw/dump.${collection}.sql`, + `/__nuxt_content/${collection}/sql_dump.txt`, + `/_nuxt/content/${collection}/sql_dump.txt`, +] + +async function fetchFromAssets(event: H3Event, pathnames: string[]): Promise { + const binding = (event?.context?.cloudflare?.env?.ASSETS as AssetsBinding | undefined) + ?? (typeof process !== 'undefined' + ? (process.env.ASSETS as unknown as AssetsBinding | undefined) + : undefined) + + if (!binding?.fetch) { + return null + } + + const requestUrl = event?.context?.cloudflare?.request?.url + + for (const candidate of pathnames) { + const normalizedPath = candidate.startsWith('/') ? candidate : `/${candidate}` + try { + const baseUrl = requestUrl + ? new URL(requestUrl) + : new URL(normalizedPath, 'http://localhost') + baseUrl.pathname = normalizedPath + baseUrl.search = '' + const response = await binding.fetch(baseUrl.toString()) + if (!response.ok) { + continue + } + const text = await response.text() + return { body: text, pathname: normalizedPath } + } + catch { + // Ignore failed asset fetch attempts and continue with other candidates. + } + } + + return null +} export default eventHandler(async (event) => { const collection = getRouterParam(event, 'collection')! - setHeader(event, 'Content-Type', 'text/plain') + // `event.node.req.url` can be a relative path, which causes `new URL` to throw. + // Provide a base to ensure a valid absolute URL is always created. + const url = new URL(event.node.req.url || '', 'http://localhost') + + const runtime = useRuntimeConfig() + const encEnabled = !!runtime?.content?.encryption?.enabled + const masterB64 = runtime?.content?.encryption?.masterKey + const storage = useStorage() + + // --- /__nuxt_content/:collection/key --- + if (url.pathname.endsWith('/key')) { + if (!encEnabled) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + // TODO: AuthN/Z — implement in your app before returning a key + const kidParam = url.searchParams.get('kid') || '' + const vParam = url.searchParams.get('v') || '' + if (!masterB64) { + throw createError({ statusCode: 500, statusMessage: 'Missing content.encryption.masterKey' }) + } - const data = await useStorage().getItem(`build:content:database.compressed.mjs`) || '' - if (data) { - const lineStart = `export const ${collection} = "` - const content = String(data).split('\n').find(line => line.startsWith(lineStart)) - if (content) { - return content - .substring(lineStart.length, content.length - 1) + let derivedCollection = collection + let derivedChecksum = vParam + + // Prefer kid if provided: expected format "v1::" + if (kidParam) { + const parts = kidParam.split(':') + if (parts.length >= 3) { + derivedCollection = parts[1] || derivedCollection + derivedChecksum = parts[2] || derivedChecksum + } + } + + const k = await deriveContentKeyB64(masterB64, derivedChecksum, derivedCollection) + setHeader(event, 'Content-Type', 'application/json') + setHeader(event, 'Cache-Control', 'no-store') + return { kid: kidParam || `v1:${derivedCollection}:${derivedChecksum}`, k } + } + + // --- /__nuxt_content/:collection/sql_dump.enc --- + if (url.pathname.endsWith('/sql_dump.enc')) { + if (!encEnabled) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + setHeader(event, 'Content-Type', 'application/octet-stream') + + // Prefer prebuilt encrypted dump + const encKey = `build:content:raw:dump.${collection}.sql.enc` + const prebuilt = await storage.getItem(encKey) + if (prebuilt) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return prebuilt + } + + const assetPrebuilt = await fetchFromAssets(event, encryptedAssetCandidates(collection)) + if (assetPrebuilt) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return assetPrebuilt.body } + + // Fallback: encrypt the prebuilt plaintext on the fly + const plainKey = `build:content:raw:dump.${collection}.sql` + const storedPlain = await storage.getItem(plainKey) + const gzBase64 = storedPlain ?? await (async () => { + const asset = await fetchFromAssets(event, plaintextAssetCandidates(collection)) + return asset ? asset.body : null + })() + if (!masterB64) { + throw createError({ statusCode: 500, statusMessage: 'Missing content.encryption.masterKey' }) + } + if (!gzBase64) { + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + const checksum = url.searchParams.get('v') || '' + const envelopeB64 = await encryptGzBase64Envelope(gzBase64, masterB64, checksum, collection) + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return envelopeB64 + } + + // --- /__nuxt_content/:collection/sql_dump.txt --- (plaintext) + setHeader(event, 'Content-Type', 'text/plain') + + if (encEnabled) { + // Hide plaintext in encrypted mode + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) + } + + // Prefer prebuilt plaintext from Nitro storage + const plainKey = `build:content:raw:dump.${collection}.sql` + const storedPlain = await storage.getItem(plainKey) + const plain = storedPlain ?? await (async () => { + const asset = await fetchFromAssets(event, plaintextAssetCandidates(collection)) + return asset ? asset.body : null + })() + if (plain) { + setHeader(event, 'Cache-Control', 'public, max-age=31536000, immutable') + return plain } - return await import('#content/dump').then(m => m[collection]) + throw createError({ statusCode: 404, statusMessage: 'Not Found' }) }) diff --git a/src/types/module.ts b/src/types/module.ts index f8d5eae8a..91fefc09a 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -61,6 +61,21 @@ export interface ModuleOptions { * @default { type: 'sqlite', filename: './contents.sqlite' } */ database: D1DatabaseConfig | SqliteDatabaseConfig | PostgreSQLDatabaseConfig | LibSQLDatabaseConfig + /** + * Encrypted dumps configuration + */ + encryption?: { + /** + * Enable encrypted dumps + key endpoint. + * When enabled, static plaintext dumps are not exposed on Cloudflare preset. + */ + enabled?: boolean + /** + * Base64(32 bytes) master key used to derive per-dump keys (HKDF). + * Keep this private. Not exposed to the client. + */ + masterKey?: string + } /** * Preview mode configuration * @default {} @@ -215,6 +230,10 @@ export interface RuntimeConfig { database: D1DatabaseConfig | SqliteDatabaseConfig | PostgreSQLDatabaseConfig localDatabase: SqliteDatabaseConfig integrityCheck: boolean + encryption?: { + enabled?: boolean + masterKey?: string + } } } @@ -223,6 +242,9 @@ export interface PublicRuntimeConfig { api?: string iframeMessagingAllowedOrigins?: string } + content: { + encryptionEnabled?: boolean + } } export type SQLiteConnector = 'native' | 'sqlite3' | 'better-sqlite3' diff --git a/src/utils/templates.ts b/src/utils/templates.ts index ac1e9dba4..0f7310f2d 100644 --- a/src/utils/templates.ts +++ b/src/utils/templates.ts @@ -10,6 +10,7 @@ import type { CollectionInfo, ResolvedCollection } from '../types/collection' import type { Manifest } from '../types/manifest' import type { GitInfo } from './git' import { generateCollectionTableDefinition } from './collection' +import { encryptGzBase64Envelope } from '../runtime/internal/encryption' const compress = (text: string): Promise => { return new Promise((resolve, reject) => gzip(text, (err, buff) => { @@ -79,7 +80,7 @@ export const fullDatabaseCompressedDumpTemplate = (manifest: Manifest) => ({ for (const [key, dump] of Object.entries(options.manifest.dump)) { // Ignore provate collections if (options.manifest.collections.find(c => c.name === key)?.private) { - return '' + continue } const compressedDump = await compress(JSON.stringify(dump)) result.push(`export const ${key} = "${compressedDump}"`) @@ -117,6 +118,40 @@ export const collectionDumpTemplate = (collection: string, manifest: Manifest) = }, }) +/** + * Encrypted per-collection dump template (.sql.enc) + * Requires you to pass `encryption` options when registering the template. + */ +export const collectionEncryptedDumpTemplate = ( + collection: string, + manifest: Manifest, + encryption: { enabled?: boolean, masterKey?: string }, +) => ({ + filename: `content/raw/dump.${collection}.sql.enc`, + getContents: async ({ options }: { options: { manifest: Manifest } }) => { + // Fallback to plain if encryption is not enabled/configured + if (!encryption?.enabled || !encryption.masterKey) { + const gzBase64 = await compress( + JSON.stringify(options.manifest.dump[collection] || []), + ) + return gzBase64 + } + + const dumpArr = options.manifest.dump[collection] || [] + const gzBase64 = await compress(JSON.stringify(dumpArr)) + const checksum = (options.manifest.checksum?.[collection] as string) || '' + // Produce base64(JSON envelope) + return await encryptGzBase64Envelope( + gzBase64, + encryption.masterKey, + checksum, + collection, + ) + }, + write: true, + options: { manifest }, +}) + export const componentsManifestTemplate = (manifest: Manifest) => { return { filename: moduleTemplates.components,