diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e15e7357a..3fc5949fa 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -9,9 +9,31 @@ jobs: - name: Checkout uses: actions/checkout@v3 - + + # Runs before the Docker suites for fast feedback. Needs the root + # dependencies, not the ones in test/. + - name: Install root dependencies + run: npm install + + - name: sqlite unit tests + # Serial: the test files share the plugin's hardcoded /tmp cache and + # etag paths, and node --test runs files in parallel processes. + run: node --test --test-concurrency=1 sqliteS3.concurrency.test.js sqliteVercelBlob.etag.test.js sqliteVercelBlob.concurrency.test.js + working-directory: ./test + - run: ./run-db-test.sh working-directory: ./test - run: ./run-s3-test.sh - working-directory: ./test \ No newline at end of file + working-directory: ./test + + - run: ./run-blob-test.sh + working-directory: ./test + + # A warm container gets its ETag from its own upload and then only ever + # revalidates, so it never writes using a download's ETag. Cold instances + # do that on every first request, which is where conditional writes broke + # in production. BLOB_NO_CONDITIONAL_READS makes every read a full one. + - name: blob tests with cold-instance reads + run: BLOB_NO_CONDITIONAL_READS=1 ./run-blob-test.sh + working-directory: ./test diff --git a/api/index.js b/api/index.js index 0d62883f4..4c37fdb3d 100644 --- a/api/index.js +++ b/api/index.js @@ -2,94 +2,46 @@ const serverlesswp = require('serverlesswp'); const { validate } = require('../util/install.js'); const { setup } = require('../util/directory.js'); -const sqliteS3 = require('../util/sqliteS3.js'); +const storage = require('../util/storage.js'); const sandbox = require('../util/sandbox.js'); const readOnly = require('../util/readOnly.js'); const pathToWP = '/tmp/wp'; -let initSqliteS3 = false; +const wpContentPath = pathToWP + '/wp-content'; +const sqlitePluginPath = wpContentPath + '/plugins/sqlite-database-integration'; + +// Which database this deployment uses. See util/storage.js. +const database = storage.resolve(); + +const readOnlyActive = !!process.env['SERVERLESSWP_READ_ONLY_MODE'] + && !['false', '0', 'no'].includes(process.env['SERVERLESSWP_READ_ONLY_MODE'].toLowerCase()); + +let initDone = false; // Move the /wp directory to /tmp/wp so that it is writeable. setup(); // This is where all requests to WordPress are routed through. -// See vercel.json or netlify.toml for the redirection rules. +// See vercel.json, netlify.toml, or serverless.yml for the redirection rules. exports.handler = async function (event, context, callback) { - if ((process.env['SQLITE_S3_BUCKET'] || process.env['SERVERLESSWP_DATA_SECRET']) && !initSqliteS3) { - let wpContentPath = pathToWP + '/wp-content'; - let sqlitePluginPath = wpContentPath + '/plugins/sqlite-database-integration'; - await sqliteS3.prepPlugin(wpContentPath, sqlitePluginPath); - - let branchSlug = ''; - let bucketFallback = ''; - - // Vercel - if (process.env['VERCEL']) { - const branch = sqliteS3.branchNameToS3file(process.env['VERCEL_GIT_COMMIT_REF']); - branchSlug = branch ? '-' + branch : ''; - bucketFallback = process.env['VERCEL_PROJECT_ID']; + if (!initDone) { + // Register readOnly first so blocked mutations short-circuit before the + // sqlite plugin tries to hit storage. + if (readOnlyActive) { + serverlesswp.registerPlugin(readOnly); } - - // Configure the sqliteS3 plugin. - let sqliteS3Config = { - bucket: process.env['SQLITE_S3_BUCKET'] || bucketFallback, - file:`wp-sqlite-s3${branchSlug}.sqlite`, - S3Client: { - credentials: { - "accessKeyId": process.env['SQLITE_S3_API_KEY'] || process.env['VERCEL_PROJECT_ID'], - "secretAccessKey": process.env['SQLITE_S3_API_SECRET'] || process.env['SERVERLESSWP_DATA_SECRET'] - }, - region: process.env['SQLITE_S3_REGION'], - } - }; - - if (process.env['SQLITE_S3_ENDPOINT']) { - sqliteS3Config.S3Client.endpoint = process.env['SQLITE_S3_ENDPOINT']; - } - - if (process.env['SQLITE_S3_FORCE_PATH_STYLE'] || process.env['SERVERLESSWP_DATA_SECRET']) { - sqliteS3Config.S3Client.forcePathStyle = true; + if (database.plugin) { + await database.plugin.prepPlugin(wpContentPath, sqlitePluginPath); + database.plugin.config(database.config); + serverlesswp.registerPlugin(database.plugin); } - if (process.env['SERVERLESSWP_DATA_SECRET']) { - sqliteS3Config.S3Client.endpoint = 'https://data.serverlesswp.com'; - sqliteS3Config.onAuthError = () => sandbox.register( - sqliteS3Config.bucket, - process.env['SERVERLESSWP_DATA_SECRET'] - ); + serverlesswp.registerPlugin(sandbox); } - - sqliteS3.config(sqliteS3Config); - initSqliteS3 = true; - } - - // Send the request (event object) to the serverlesswp library. - // It includes the PHP server that allows WordPress to handle the request. - let response = await serverlesswp({docRoot: pathToWP, event: event}); - - // Check to see if the database environment variables are in place. - let checkInstall = validate(response); - - if (checkInstall) { - return checkInstall; + initDone = true; } - else { - // Return the response for serving. - return response; - } -} - -if (process.env['SERVERLESSWP_READ_ONLY_MODE'] && !['false', '0', 'no'].includes(process.env['SERVERLESSWP_READ_ONLY_MODE'].toLowerCase())) { - // Register before sqliteS3 so blocked requests force a response before the S3 fetch. - serverlesswp.registerPlugin(readOnly); -} - -if (process.env['SQLITE_S3_BUCKET'] || process.env['SERVERLESSWP_DATA_SECRET']) { - // Register the sqlite serverlesswp plugin. - serverlesswp.registerPlugin(sqliteS3); -} -if (process.env['SERVERLESSWP_DATA_SECRET']) { - // Register the sandbox widget plugin. - serverlesswp.registerPlugin(sandbox); -} + const response = await serverlesswp({ docRoot: pathToWP, event: event }); + const checkInstall = validate(response); + return checkInstall || response; +}; diff --git a/api/vercel.js b/api/vercel.js index 93c582f36..b0022b7c3 100644 --- a/api/vercel.js +++ b/api/vercel.js @@ -1,98 +1,7 @@ -const serverlesswp = require('serverlesswp'); +// Vercel entry point, referenced by vercel.json. No Vercel-specific setup left +// here - util/storage.js reads the VERCEL_* variables itself. -const { validate } = require('../util/install.js'); -const { setup } = require('../util/directory.js'); -const sqliteS3 = require('../util/sqliteS3.js'); -const sandbox = require('../util/sandbox.js'); -const readOnly = require('../util/readOnly.js'); +const core = require('./index.js'); -const pathToWP = '/tmp/wp'; -let initSqliteS3 = false; - -// Move the /wp directory to /tmp/wp so that it is writeable. -setup(); - -// This is where all requests to WordPress are routed through. -// See vercel.json or netlify.toml for the redirection rules. -async function handler(event, context, callback) { - if ((process.env['SQLITE_S3_BUCKET'] || process.env['SERVERLESSWP_DATA_SECRET']) && !initSqliteS3) { - let wpContentPath = pathToWP + '/wp-content'; - let sqlitePluginPath = wpContentPath + '/plugins/sqlite-database-integration'; - await sqliteS3.prepPlugin(wpContentPath, sqlitePluginPath); - - let branchSlug = ''; - let bucketFallback = ''; - - // Vercel - if (process.env['VERCEL']) { - const branch = sqliteS3.branchNameToS3file(process.env['VERCEL_GIT_COMMIT_REF']); - branchSlug = branch ? '-' + branch : ''; - bucketFallback = process.env['VERCEL_PROJECT_ID']; - } - - // Configure the sqliteS3 plugin. - let sqliteS3Config = { - bucket: process.env['SQLITE_S3_BUCKET'] || bucketFallback, - file:`wp-sqlite-s3${branchSlug}.sqlite`, - S3Client: { - credentials: { - "accessKeyId": process.env['SQLITE_S3_API_KEY'] || process.env['VERCEL_PROJECT_ID'], - "secretAccessKey": process.env['SQLITE_S3_API_SECRET'] || process.env['SERVERLESSWP_DATA_SECRET'] - }, - region: process.env['SQLITE_S3_REGION'], - } - }; - - if (process.env['SQLITE_S3_ENDPOINT']) { - sqliteS3Config.S3Client.endpoint = process.env['SQLITE_S3_ENDPOINT']; - } - - if (process.env['SQLITE_S3_FORCE_PATH_STYLE'] || process.env['SERVERLESSWP_DATA_SECRET']) { - sqliteS3Config.S3Client.forcePathStyle = true; - } - - if (process.env['SERVERLESSWP_DATA_SECRET']) { - sqliteS3Config.S3Client.endpoint = 'https://data.serverlesswp.com'; - sqliteS3Config.onAuthError = () => sandbox.register( - sqliteS3Config.bucket, - process.env['SERVERLESSWP_DATA_SECRET'] - ); - } - - sqliteS3.config(sqliteS3Config); - initSqliteS3 = true; - } - - // Send the request (event object) to the serverlesswp library. - // It includes the PHP server that allows WordPress to handle the request. - let response = await serverlesswp({docRoot: pathToWP, event: event}); - - // Check to see if the database environment variables are in place. - let checkInstall = validate(response); - - if (checkInstall) { - return checkInstall; - } - else { - // Return the response for serving. - return response; - } -} - -if (process.env['SERVERLESSWP_READ_ONLY_MODE'] && !['false', '0', 'no'].includes(process.env['SERVERLESSWP_READ_ONLY_MODE'].toLowerCase())) { - // Register before sqliteS3 so blocked requests force a response before the S3 fetch. - serverlesswp.registerPlugin(readOnly); -} - -if (process.env['SQLITE_S3_BUCKET'] || process.env['SERVERLESSWP_DATA_SECRET']) { - // Register the sqlite serverlesswp plugin. - serverlesswp.registerPlugin(sqliteS3); -} - -if (process.env['SERVERLESSWP_DATA_SECRET']) { - // Register the sandbox widget plugin. - serverlesswp.registerPlugin(sandbox); -} - -module.exports = handler; -module.exports.handler = handler; +module.exports = core.handler; +module.exports.handler = core.handler; diff --git a/package.json b/package.json index 5d4cb6b58..2c000f3da 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.772.0", - "serverlesswp": "^0.5.4", + "@vercel/blob": "^2.3.3", + "serverlesswp": "^0.5.5", "sqlite3": "^5.1.7" }, "engines": { diff --git a/readme.md b/readme.md index d0826fcd2..ed19d36f8 100644 --- a/readme.md +++ b/readme.md @@ -4,29 +4,36 @@ WordPress hosting is silly. **Low maintenance** and **low cost/free** WordPress hosting on Vercel, Netlify, or AWS Lambda. -ServerlessWP puts PHP & WordPress in serverless functions. Deploy this repository to give it a try. +ServerlessWP puts WordPress in serverless functions and the database in a file. Deploy this repository to give it a try. Stay up-to-date at the ServerlessWP repository: [github.com/mitchmac/serverlesswp](https://github.com/mitchmac/serverlesswp) -![PHP 8.3.33](https://img.shields.io/badge/version-8.3.33-blue?logo=php&labelColor=white) ![WordPress 7.0.2](https://img.shields.io/badge/version-7.0.2-blue?logo=wordpress&labelColor=white&logoColor=black) +![WordPress 7.0.2](https://img.shields.io/badge/version-7.0.2-blue?logo=wordpress&labelColor=white&logoColor=black) ![PHP 8.3.33](https://img.shields.io/badge/version-8.3.33-blue?logo=php&labelColor=white) + +## Is it a good fit? + +**This is currently an experimental project.** It's built for content sites rather than applications: + +✅ **Great fit:** personal blogs, documentation, portfolios, marketing and small business sites, dev and staging sites — anything mostly read, edited by one or two people. + +✅ **Also great: headless/decoupled WordPress.** Run WordPress here purely as the editing backend and content API (REST or GraphQL) for a separate frontend. + +⚠️ **Use MySQL, not SQLite when:** sites with several people publishing at once, take a lot of form submissions, ecommerce, membership sites, forums. SQLite+S3 and SQLite+Blob have [limited write concurrency](#sqlite--object-storage). + ## Quick Deploy -Try ServerlessWP nearly instantly on Vercel with a temporary SQLite database (database expires after a few days) +**The easiest way to run WordPress with ServerlessWP is entirely on Vercel.** This button creates a private [Vercel Blob](https://vercel.com/docs/vercel-blob) store during setup, and WordPress runs on a SQLite database kept in it. No database to host, no credentials to copy, no other accounts to sign up for — and each git branch gets its own database. -[![Deploy with Vercel](https://vercel.com/button)](https://serverlesswp.com/vercel-deploy) +[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmitchmac%2Fserverlesswp&project-name=serverlesswp&repository-name=serverlesswp&stores=%5B%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%2C%22envVarPrefix%22%3A%22SQLITE%22%7D%5D) -Or click one of the options below to deploy your serverless WordPress site with your database of choice: +More on [how SQLite + Vercel Blob works](#sqlite--vercel-blob) and [when to use MySQL instead](#mysql-database-option). -| Vercel | Netlify | -|---|---| -| [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fmitchmac%2Fserverlesswp&project-name=serverlesswp&repository-name=serverlesswp) | [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/mitchmac/serverlesswp) | -| 🕑 60 second max request duration | 10 second max request duration | -|  ⎇  automatic branch deploy config | manual branch config | -| 📈 [Web analytics](https://vercel.com/docs/analytics) | paid add-on | -| 🛡️ [Firewall](https://vercel.com/docs/vercel-firewall/vercel-waf) | paid add-on | +Other ways to deploy: -Want to use AWS Lambda with the Serverless Framework instead? `npm install && serverless deploy` +- **[Just kicking the tires?](https://serverlesswp.com/vercel-deploy)** Deploy on Vercel with a temporary SQLite database on S3 that expires after a few days. +- **[Netlify](https://app.netlify.com/start/deploy?repository=https://github.com/mitchmac/serverlesswp)** with your own database (SQLite+S3 or MySQL). Trade-offs vs. Vercel: 10 second max request duration instead of 60, manual branch config, and analytics/firewall are paid add-ons. +- **AWS Lambda** with the Serverless Framework: `npm install && serverless deploy` ## Project goals @@ -44,50 +51,33 @@ Want to use AWS Lambda with the Serverless Framework instead? `npm install && se ## Deploy ServerlessWP -**This is currently an experimental project.** - -It's a good fit for development, personal blogs, documentation sites, and small business sites. It shouldn't be used when considerable security or stability is required, yet. - ### 1. Deploy this repository to Vercel, Netlify, or AWS. One of the links above will get you started. You'll just need a GitHub account. ### 2. Setup a database. -You'll need to create a database for your site's content. - -[TiDB](https://www.pingcap.com/tidb-cloud-serverless/) provides a cloud database with a generous free tier. +**These docs lead with SQLite on object storage — Vercel Blob or S3 — because it's the quickest to get running and the least to maintain: nothing to provision, nothing running 24/7, and on Vercel no credentials to copy at all.** MySQL is equally supported and stays the better choice for the sites called out above — see [MySQL](#mysql-database-option). -Wouldn't it be great to skip hosting a database? [Skip below](#sqlite--s3-database-option) if you want to try something different with SQLite & S3. +If you used the Vercel button above, you're already done: the Blob store it created is your database. Skip to step 3. -### 3. Update the environment variables. -After creating your database you'll need to update environment variables for your project with the credentials. The WordPress config file ```wp-config.php``` is automatically configured to use these values to connect to the database. +Otherwise, pick your database below — [SQLite + object storage](#sqlite--object-storage) or [MySQL](#mysql-database-option) — then come back for uploads. -Update the environment variables in Vercel/Netlify: +Whichever you choose, you set it up with environment variables. See [here for Vercel](https://vercel.com/docs/concepts/projects/environment-variables) and [here for Netlify](https://docs.netlify.com/environment-variables/overview/) for how to manage them. **Remember to redeploy** your project if you change environment variables after the initial deploy. -| | | -|---|---| -| DATABASE | database name you created | -| USERNAME | database user to access the database | -| PASSWORD | database user's password | -| HOST | address to access the database | -| TABLE_PREFIX | optional: to use a prefix on the database tables | - -See [here for Vercel](https://vercel.com/docs/concepts/projects/environment-variables) and [here for Netlify](https://docs.netlify.com/environment-variables/overview/) for more about managing environment variables. **Remember to redeploy** your project after updating the environment variables if you update them after initially deploying your project. - -### 4. File and media uploads with S3 (optional, can be done later) +### 3. File and media uploads with S3 (optional, can be done later) File and media uploads can be enabled using the included WP Offload Media Lite for Amazon S3 plugin. S3 setup details can be found [here](https://deliciousbrains.com/wp-offload-media/doc/amazon-s3-quick-start-guide/). The wp-config.php file is setup to use the following environment variables for use by the plugin: - S3_KEY_ID - S3_ACCESS_KEY -## SQLite + S3 database option +## SQLite + object storage WordPress usually runs with a MySQL (or MariaDB) database. That means hosting a database that runs 24/7. -A [SQLite database](https://github.com/WordPress/sqlite-database-integration) option has been developed by members of the WordPress community. With the recent ability to *conditionally write* to S3-compatible object storage a decentralized and serverless data layer for ServerlessWP is possible. +A [SQLite database](https://github.com/WordPress/sqlite-database-integration) option has been developed by members of the WordPress community. With the recent ability to *conditionally write* to object storage - Vercel Blob, or S3 and S3-compatible buckets - a decentralized and serverless data layer for ServerlessWP is possible. Check out the [diagram of the SQLite+S3 logic](https://github.com/mitchmac/ServerlessWP/wiki/How-does-SQLite-with-S3-work-with-ServerlessWP%3F) if you're interested in how it works. -ServerlessWP supports both SQLite+S3 and MySQL as database options. Some of the trade-offs: +ServerlessWP supports both SQLite and MySQL as database options. Some of the trade-offs: -| SQLite+S3 | MySQL | +| SQLite + object storage | MySQL | |---|---| | 🕑 on demand | 24/7 hosting | | 💲 usage based (free tiers) | monthly fees (some limited free tiers) | @@ -95,10 +85,25 @@ ServerlessWP supports both SQLite+S3 and MySQL as database options. Some of the | ♾️ limited database update concurrency | few concurrency limitations | | ✔️ blogs, dev sites, documentation, single editor sites | any site | -The main trade-off of using SQLite+S3 with ServerlessWP is: -- if requests are handled by multiple underlying serverless functions at the same time and make a change to the database, the competing requests may fail. Sites with multiple editors working at the same time or receiving many form submissions aren't a great fit for SQLite+S3. +The main trade-off of using SQLite with ServerlessWP is: +- if requests are handled by multiple underlying serverless functions at the same time and make a change to the database, the competing requests may fail. Sites with multiple editors working at the same time or receiving many form submissions aren't a great fit for SQLite. + +### SQLite + Vercel Blob -Want to give it a try? Setup a private S3 bucket and use these environment variables: +The easiest option. On Vercel, the deploy button above creates a private [Vercel Blob](https://vercel.com/docs/vercel-blob) store for you during setup - no bucket or IAM credentials to create. The git branch is added to the name, so preview deployments each get their own database. + +| SQLite+Vercel Blob | | +|---|---| +| SQLITE_BLOB_READ_WRITE_TOKEN | token for the store holding the database | +| SQLITE_BLOB_PATHNAME | optional: base name for the database - defaults to `wp-sqlite` | + +`SQLITE_BLOB_READ_WRITE_TOKEN` is what Vercel injects for a store created with an env var prefix of `SQLITE`, which is what the deploy button asks for. To set one up on an existing project, create the store from the Storage tab with **private** access, then copy its `BLOB_READ_WRITE_TOKEN` into a `SQLITE_BLOB_READ_WRITE_TOKEN` environment variable. + +The database store is deliberately separate from any store you use for media uploads: the database has to stay private and uncached, while uploads want public reads and CDN caching. That's why the unprefixed `BLOB_READ_WRITE_TOKEN` isn't used here - a store connected for uploads would otherwise be picked up as the database and fail on the first write. + +### SQLite + S3 + +Works anywhere - Netlify, AWS, or Vercel - with any S3-compatible bucket, including Cloudflare R2. Setup a **private** bucket and use these environment variables: | SQLite+S3 | | |---|---| @@ -108,6 +113,29 @@ Want to give it a try? Setup a private S3 bucket and use these environment varia | SQLITE_S3_REGION | region where the bucket lives - create it near your serverless functions | | SQLITE_S3_ENDPOINT | optional: to update where the bucket is, like a Cloudflare R2 address | +## MySQL database option + +The right call when you need full plugin compatibility or more than a couple of people writing at once. [TiDB](https://www.pingcap.com/tidb-cloud-serverless/) provides a cloud MySQL database with a generous free tier. + +After creating your database, set these environment variables with the credentials. ```wp-config.php``` is automatically configured to use them to connect. + +| | | +|---|---| +| DATABASE | database name you created | +| USERNAME | database user to access the database | +| PASSWORD | database user's password | +| HOST | address to access the database | +| TABLE_PREFIX | optional: to use a prefix on the database tables | + +## Which database gets used + +The most explicitly configured option wins, so adding a Blob store for media won't take over an existing database: + +1. **MySQL** - `DATABASE`, `USERNAME`, `PASSWORD`, and `HOST` all set +2. **SQLite + S3** - `SQLITE_S3_BUCKET` set +3. **SQLite + Vercel Blob** - `SQLITE_BLOB_READ_WRITE_TOKEN` set on Vercel +4. otherwise the setup page is shown + ## Customizing WordPress - WordPress and its files are in the ```/wp``` directory. You can add plugins or themes there in their respective directories in ```wp-content``` then commit the files to your repository so it will re-deploy. - Plugins like [Cache-Control](https://wordpress.org/plugins/cache-control/) can enable CDN caching with the s-maxage directive and make your site super fast. Refer to [Vercel Edge Caching](https://vercel.com/docs/concepts/edge-network/caching) or [Netlfiy Cache Headers](https://docs.netlify.com/edge-functions/optional-configuration/#supported-headers) diff --git a/test/Dockerfile-blob b/test/Dockerfile-blob new file mode 100644 index 000000000..3b6472075 --- /dev/null +++ b/test/Dockerfile-blob @@ -0,0 +1,13 @@ +FROM public.ecr.aws/lambda/nodejs:22 + +COPY package.json ${LAMBDA_TASK_ROOT}/ +RUN npm install --omit=optional --omit=dev + +COPY api/ ${LAMBDA_TASK_ROOT}/api/ +COPY util/ ${LAMBDA_TASK_ROOT}/util/ +COPY wp/ ${LAMBDA_TASK_ROOT}/wp/ +COPY test/installer.php ${LAMBDA_TASK_ROOT}/wp/ +COPY test/vercel-blob-emulator/undici-patch.js ${LAMBDA_TASK_ROOT}/blob-test/undici-patch.js +COPY test/vercel-blob-emulator/handler.js ${LAMBDA_TASK_ROOT}/blob-test/handler.js + +CMD [ "blob-test/handler.handler" ] diff --git a/test/run-blob-test.sh b/test/run-blob-test.sh new file mode 100755 index 000000000..7ef84a446 --- /dev/null +++ b/test/run-blob-test.sh @@ -0,0 +1,90 @@ +#!/bin/bash +set -euo pipefail + +cd .. +docker build -t serverlesswp-blob-test -f test/Dockerfile-blob . +cd test + +# Clean up any leftovers from a previous run +pkill -f "node proxy.js" 2>/dev/null || true +pkill -f "node vercel-blob-emulator/server.js" 2>/dev/null || true +docker stop serverlesswp-test serverlesswp-test-readonly 2>/dev/null || true +docker rm serverlesswp-test serverlesswp-test-readonly 2>/dev/null || true + +VERCEL=${VERCEL:-1} +VERCEL_GIT_COMMIT_REF=${VERCEL_GIT_COMMIT_REF:-test_branch} + +# Token format: vercel_blob_rw__. The mock derives the storeId +# and rebuilds the hardcoded blob download URL from it. Must match STORE_ID. +STORE_ID=test +BLOB_TOKEN="vercel_blob_rw_${STORE_ID}_testsecret" +FAKE_BLOB_PORT=7000 + +PORT=$FAKE_BLOB_PORT STORE_ID=$STORE_ID ACCESS=private \ + node vercel-blob-emulator/server.js > /dev/null 2>&1 & +FAKE_BLOB_PID=$! + +# Wait for the emulator to be ready +until curl -s -o /dev/null -w "%{http_code}" http://localhost:$FAKE_BLOB_PORT/does-not-exist | grep -q 404; do sleep 1; done + +# host-gateway lets the container reach the host-side blob emulator via +# http://host.docker.internal. Works on Docker Desktop and Docker Engine >= 20.10. +docker run \ + --add-host=host.docker.internal:host-gateway \ + -e SQLITE_BLOB_READ_WRITE_TOKEN=$BLOB_TOKEN \ + -e VERCEL_BLOB_API_URL=http://host.docker.internal:$FAKE_BLOB_PORT \ + -e VERCEL_BLOB_MOCK_URL=http://host.docker.internal:$FAKE_BLOB_PORT \ + -e VERCEL=$VERCEL -e VERCEL_GIT_COMMIT_REF=$VERCEL_GIT_COMMIT_REF \ + -e SERVERLESSWP_TESTING=1 \ + -e SERVERLESSWP_READ_ONLY_MODE=false \ + -p 9000:8080 \ + -d --name serverlesswp-test serverlesswp-blob-test + +node proxy.js > /dev/null 2>&1 & +PROXY_PID=$! + +cleanup() { + kill $PROXY_PID 2>/dev/null || true + kill $FAKE_BLOB_PID 2>/dev/null || true + docker stop serverlesswp-test 2>/dev/null || true + docker rm serverlesswp-test 2>/dev/null || true + docker stop serverlesswp-test-readonly 2>/dev/null || true + docker rm serverlesswp-test-readonly 2>/dev/null || true +} +trap cleanup EXIT + +until curl -sfko /dev/null https://localhost:3000/; do sleep 1; done + +echo "Testing static file serving..." +static_check=$(curl -sk -o /dev/null -w "%{http_code} %{content_type}" https://localhost:3000/wp-includes/css/classic-themes.css) +http_code=${static_check%% *} +content_type=${static_check#* } +[[ "$http_code" == "200" ]] || { echo "Static file test FAILED: expected 200, got $http_code"; exit 1; } +[[ "$content_type" == *"text/css"* ]] || { echo "Static file content-type FAILED: expected text/css, got $content_type"; exit 1; } +echo "Static file test passed." + +npm install +npx playwright install chromium +ldconfig -p | grep -q libnspr4 || sudo env PATH="$PATH" node_modules/.bin/playwright install-deps chromium +SCREENSHOTS=${SCREENSHOTS:-} npx playwright test e2e.spec.js "$@" + +# Read-only mode tests — reuse the populated emulator state from above. +echo "Starting read-only mode tests..." +docker stop serverlesswp-test +docker rm serverlesswp-test + +docker run \ + --add-host=host.docker.internal:host-gateway \ + -e SQLITE_BLOB_READ_WRITE_TOKEN=$BLOB_TOKEN \ + -e VERCEL_BLOB_API_URL=http://host.docker.internal:$FAKE_BLOB_PORT \ + -e VERCEL_BLOB_MOCK_URL=http://host.docker.internal:$FAKE_BLOB_PORT \ + -e VERCEL=$VERCEL -e VERCEL_GIT_COMMIT_REF=$VERCEL_GIT_COMMIT_REF \ + -e SERVERLESSWP_TESTING=1 \ + -e SERVERLESSWP_READ_ONLY_MODE=1 \ + -e SERVERLESSWP_READ_ONLY_CACHE_MAX_AGE=3600 \ + -p 9000:8080 \ + -d --name serverlesswp-test-readonly serverlesswp-blob-test + +until curl -sfko /dev/null https://localhost:3000/; do sleep 1; done + +SKIP_AUTH=1 SCREENSHOTS=${SCREENSHOTS:-} npx playwright test e2e-read-only.spec.js "$@" diff --git a/test/sqliteS3.concurrency.test.js b/test/sqliteS3.concurrency.test.js index cd2bb1a29..d813aee61 100644 --- a/test/sqliteS3.concurrency.test.js +++ b/test/sqliteS3.concurrency.test.js @@ -40,6 +40,8 @@ function makeMockS3({ initialBody, initialEtag = 'etag-1' }) { putCalls: 0, // Force the next N PUTs to fail with 412. forcePutPreconditionFailures: 0, + // Force GETs to fail, e.g. { name: 'NoSuchKey' } or { httpStatusCode: 500 }. + forceGetError: null, }; const client = { async send(command) { @@ -47,6 +49,16 @@ function makeMockS3({ initialBody, initialEtag = 'etag-1' }) { if (name === 'GetObjectCommand') { state.getCalls++; const input = command.input; + if (state.forceGetError) { + const err = new Error(state.forceGetError.name || 'S3 error'); + if (state.forceGetError.name) { + err.name = state.forceGetError.name; + } + if (state.forceGetError.httpStatusCode) { + err.$metadata = { httpStatusCode: state.forceGetError.httpStatusCode }; + } + throw err; + } if (input.IfNoneMatch && input.IfNoneMatch === state.etag) { const err = new Error('Not Modified'); err.$metadata = { httpStatusCode: 304 }; @@ -83,6 +95,19 @@ function makeMockS3({ initialBody, initialEtag = 'etag-1' }) { class GetObjectCommand { constructor(input) { this.input = input; } } class PutObjectCommand { constructor(input) { this.input = input; } } +// Insert a row through a separate connection - PRAGMA data_version only +// increments when another connection commits, which is how PHP's writes look +// to the Node-held handle in production. +function insertRow(dbPath, value) { + return new Promise((resolve, reject) => { + const writer = new sqlite3.Database(dbPath); + writer.run('INSERT INTO t VALUES (?)', [value], (err) => { + if (err) return reject(err); + writer.close(() => resolve()); + }); + }); +} + async function cleanupTmp() { for (const p of [ETAG_CACHE, CACHE_FILE]) { try { await fs.unlink(p); } catch (e) {} @@ -212,6 +237,47 @@ test('412 on PUT returns retry response and does not refresh local cache', async await assert.rejects(fs.access(ctx.workingPath)); }); +test('a failed GET fails the request instead of writing an empty database', async () => { + // Cold instance (no cache file) + a read that errors out. Without the + // guard, SQLite would create an empty database for WordPress and + // postRequest would save it over the real one. + const body = await buildDbBytes('seed'); + const { client, state } = makeMockS3({ initialBody: body }); + state.forceGetError = { httpStatusCode: 500 }; + sqliteS3._setClientForTests(client, { bucket: 'b', file: 'f' }); + + const event = {}; + const response = await sqliteS3.preRequest(event); + + assert.ok(response, 'preRequest returned a response'); + assert.strictEqual(response.statusCode, 500); + assert.strictEqual(response._forceResponse, true, 'WordPress never runs'); + assert.strictEqual(state.putCalls, 0, 'nothing was written to S3'); + + const ctx = event[Symbol.for('serverlesswp.sqliteS3.context')]; + assert.strictEqual(ctx.db, null, 'no db handle was opened'); + await assert.rejects(fs.access(ctx.workingPath), 'no working file was created'); +}); + +test('a missing database is a new site and still gets saved', async () => { + const { client, state } = makeMockS3({ initialBody: await buildDbBytes('unused') }); + state.forceGetError = { name: 'NoSuchKey' }; + sqliteS3._setClientForTests(client, { bucket: 'b', file: 'f' }); + + const event = {}; + const response = await sqliteS3.preRequest(event); + assert.strictEqual(response, undefined, 'no database yet is not an error'); + + // Stand in for WordPress installing itself into the working file. + const ctx = event[Symbol.for('serverlesswp.sqliteS3.context')]; + await fs.writeFile(ctx.workingPath, await buildDbBytes('installed')); + + const result = await sqliteS3.postRequest(event, {}); + assert.strictEqual(result, undefined, 'the request succeeds'); + assert.strictEqual(state.putCalls, 1, 'the new database was saved'); + assert.deepStrictEqual(state.body, await fs.readFile(CACHE_FILE), 'local cache matches what was saved'); +}); + test('module state is not shared between concurrent requests', async () => { // Specifically: request B mutating its db must not affect request A's // dataVersion/db reference. @@ -239,6 +305,58 @@ test('module state is not shared between concurrent requests', async () => { await sqliteS3.postRequest(b, {}); }); +test('a stale working copy cannot silently overwrite a committed write', async () => { + // Lost-update regression: A and B start from the same version. A commits + // first, which advances the shared etag file on this instance. B's IfMatch + // must still be the version B *started from*, so S3 rejects it - reading + // the etag file again at write time would let B's put pass and silently + // revert A's committed write. + const body = await buildDbBytes('seed'); + const { client, state } = makeMockS3({ initialBody: body }); + sqliteS3._setClientForTests(client, { bucket: 'b', file: 'f' }); + + const ctxKey = Symbol.for('serverlesswp.sqliteS3.context'); + const a = {}, b = {}; + await sqliteS3.preRequest(a); + await sqliteS3.preRequest(b); + + await insertRow(a[ctxKey].workingPath, 'from-a'); + const resultA = await sqliteS3.postRequest(a, {}); + assert.strictEqual(resultA, undefined, 'A saves cleanly'); + const bodyAfterA = state.body; + + await insertRow(b[ctxKey].workingPath, 'from-b'); + const resultB = await sqliteS3.postRequest(b, {}); + assert.ok(resultB, 'B\'s save is rejected'); + assert.strictEqual(resultB.statusCode, 500); + assert.strictEqual(resultB.retry, true, 'B is retried on fresh data'); + assert.deepStrictEqual(state.body, bodyAfterA, 'A\'s committed write is preserved'); +}); + +test('an unknown starting version refuses to write instead of clobbering', async () => { + // The object exists but the read never bound an ETag (here: the 403 auth + // path). An unconditional put could overwrite another instance's commit, + // so postRequest must fail the request rather than save. + const body = await buildDbBytes('seed'); + const { client, state } = makeMockS3({ initialBody: body }); + state.forceGetError = { httpStatusCode: 403 }; + sqliteS3._setClientForTests(client, { bucket: 'b', file: 'f', onAuthError: async () => {} }); + + const event = {}; + const response = await sqliteS3.preRequest(event); + assert.strictEqual(response, undefined, 'the auth path lets the request continue'); + + // Stand in for WordPress writing into the (fresh) working file. + const ctx = event[Symbol.for('serverlesswp.sqliteS3.context')]; + await fs.writeFile(ctx.workingPath, await buildDbBytes('unbound')); + + const result = await sqliteS3.postRequest(event, {}); + assert.ok(result, 'the save is refused'); + assert.strictEqual(result.statusCode, 500); + assert.strictEqual(result.retry, true); + assert.strictEqual(state.putCalls, 0, 'nothing was written to S3'); +}); + test('client-supplied X-Serverlesswp-Sqlite-File header is stripped', async () => { const body = await buildDbBytes('seed'); const { client } = makeMockS3({ initialBody: body }); diff --git a/test/sqliteVercelBlob.concurrency.test.js b/test/sqliteVercelBlob.concurrency.test.js new file mode 100644 index 000000000..90f280b6d --- /dev/null +++ b/test/sqliteVercelBlob.concurrency.test.js @@ -0,0 +1,194 @@ +// Concurrency tests for util/sqliteVercelBlob.js. +// +// These mirror sqliteS3.concurrency.test.js: each request works on its own +// copy of the database, and the conditional write must use the blob version +// that copy came from - not whatever the shared etag file says at write time. + +const test = require('node:test'); +const assert = require('node:assert'); +const fs = require('fs').promises; +const path = require('path'); +const os = require('os'); +const { Readable } = require('node:stream'); +const sqlite3 = require('sqlite3').verbose(); +const { BlobPreconditionFailedError, BlobNotFoundError } = require('@vercel/blob'); + +const sqliteVercelBlob = require('../util/sqliteVercelBlob.js'); + +const ETAG_CACHE = '/tmp/etag-vercel-blob.txt'; +const CACHE_FILE = '/tmp/wp-sqlite-cache.sqlite'; +const CTX_KEY = Symbol.for('serverlesswp.sqliteVercelBlob.context'); + +// Build a small valid SQLite db file and return its bytes. +async function buildDbBytes(seedRow) { + const tmp = path.join(os.tmpdir(), `seed-${Date.now()}-${Math.random()}.sqlite`); + await new Promise((resolve, reject) => { + const db = new sqlite3.Database(tmp); + db.serialize(() => { + db.run('CREATE TABLE t (v TEXT)'); + db.run('INSERT INTO t VALUES (?)', [seedRow], (err) => err ? reject(err) : null); + db.close((err) => err ? reject(err) : resolve()); + }); + }); + const bytes = await fs.readFile(tmp); + await fs.unlink(tmp); + return bytes; +} + +// Insert a row through a separate connection - PRAGMA data_version only +// increments when another connection commits, which is how PHP's writes look +// to the Node-held handle in production. +function insertRow(dbPath, value) { + return new Promise((resolve, reject) => { + const writer = new sqlite3.Database(dbPath); + writer.run('INSERT INTO t VALUES (?)', [value], (err) => { + if (err) return reject(err); + writer.close(() => resolve()); + }); + }); +} + +// Mimics the store behavior the plugin depends on: downloads report the weak +// validator (W/"...") while put reports the strong form, and x-if-match is +// compared against the strong one. `etagOnDownload: false` simulates a +// download that arrives without an ETag. +function makeMockBlobStore({ initialBody = null, etagOnDownload = true } = {}) { + const state = { + body: initialBody, + etag: '"etag-1"', + getCalls: 0, + putCalls: 0, + }; + const api = { + async get(pathname, options = {}) { + state.getCalls++; + if (state.body == null) { + throw new BlobNotFoundError(); + } + const weakEtag = 'W/' + state.etag; + if (options.ifNoneMatch && options.ifNoneMatch === state.etag) { + return { statusCode: 304 }; + } + const result = { + statusCode: 200, + stream: Readable.toWeb(Readable.from([state.body])), + blob: {}, + }; + if (etagOnDownload) { + result.blob.etag = weakEtag; + } + return result; + }, + async put(pathname, body, options = {}) { + state.putCalls++; + if (options.ifMatch && options.ifMatch !== state.etag) { + throw new BlobPreconditionFailedError(); + } + state.body = Buffer.from(body); + state.etag = '"etag-' + (state.putCalls + 1) + '"'; + return { etag: state.etag }; + }, + }; + return { api, state }; +} + +async function cleanupTmp() { + for (const p of [ETAG_CACHE, CACHE_FILE]) { + try { await fs.unlink(p); } catch (e) {} + } + const entries = await fs.readdir('/tmp'); + await Promise.all(entries + .filter(e => e.startsWith('wp-sqlite-') && e !== 'wp-sqlite-cache.sqlite') + .map(e => fs.unlink('/tmp/' + e).catch(() => {}))); +} + +test.beforeEach(async () => { + await cleanupTmp(); +}); + +test('a stale working copy cannot silently overwrite a committed write', async () => { + // Lost-update regression: A and B start from the same version. A commits + // first, which advances the shared etag file on this instance. B's + // ifMatch must still be the version B *started from*, so the store + // rejects it - reading the etag file again at write time would let B's + // put pass and silently revert A's committed write. + const { api, state } = makeMockBlobStore({ initialBody: await buildDbBytes('seed') }); + sqliteVercelBlob._setBlobForTests(api); + sqliteVercelBlob.config({ pathname: 'wp-sqlite-test.sqlite' }); + + const a = {}, b = {}; + await sqliteVercelBlob.preRequest(a); + await sqliteVercelBlob.preRequest(b); + + await insertRow(a[CTX_KEY].workingPath, 'from-a'); + const resultA = await sqliteVercelBlob.postRequest(a, {}); + assert.strictEqual(resultA, undefined, 'A saves cleanly'); + const bodyAfterA = state.body; + + await insertRow(b[CTX_KEY].workingPath, 'from-b'); + const resultB = await sqliteVercelBlob.postRequest(b, {}); + assert.ok(resultB, 'B\'s save is rejected'); + assert.strictEqual(resultB.statusCode, 500); + assert.strictEqual(resultB.retry, true, 'B is retried on fresh data'); + assert.deepStrictEqual(state.body, bodyAfterA, 'A\'s committed write is preserved'); +}); + +test('a 304 revalidation binds the cached etag to the request', async () => { + const { api, state } = makeMockBlobStore({ initialBody: await buildDbBytes('seed') }); + sqliteVercelBlob._setBlobForTests(api); + sqliteVercelBlob.config({ pathname: 'wp-sqlite-test.sqlite' }); + + // First request warms the cache (full download), second revalidates. + const warm = {}; + await sqliteVercelBlob.preRequest(warm); + await sqliteVercelBlob.postRequest(warm, {}); + + const event = {}; + await sqliteVercelBlob.preRequest(event); + assert.strictEqual(state.getCalls, 2); + assert.strictEqual(event[CTX_KEY].etag, state.etag, '304 path bound the strong etag'); + + await insertRow(event[CTX_KEY].workingPath, 'row'); + const result = await sqliteVercelBlob.postRequest(event, {}); + assert.strictEqual(result, undefined, 'conditional write succeeds from the 304 path'); +}); + +test('a missing blob is a new site and still gets saved', async () => { + const { api, state } = makeMockBlobStore({ initialBody: null }); + sqliteVercelBlob._setBlobForTests(api); + sqliteVercelBlob.config({ pathname: 'wp-sqlite-test.sqlite' }); + + const event = {}; + const response = await sqliteVercelBlob.preRequest(event); + assert.strictEqual(response, undefined, 'no database yet is not an error'); + + // Stand in for WordPress installing itself into the working file. + const ctx = event[CTX_KEY]; + await fs.writeFile(ctx.workingPath, await buildDbBytes('installed')); + + const result = await sqliteVercelBlob.postRequest(event, {}); + assert.strictEqual(result, undefined, 'the request succeeds'); + assert.strictEqual(state.putCalls, 1, 'the new database was saved'); +}); + +test('an unknown starting version refuses to write instead of clobbering', async () => { + // The blob exists but the download carried no ETag, so the request never + // learned which version it started from. An unconditional put could + // overwrite another instance's commit - the save must fail instead. + const { api, state } = makeMockBlobStore({ + initialBody: await buildDbBytes('seed'), + etagOnDownload: false, + }); + sqliteVercelBlob._setBlobForTests(api); + sqliteVercelBlob.config({ pathname: 'wp-sqlite-test.sqlite' }); + + const event = {}; + await sqliteVercelBlob.preRequest(event); + + await insertRow(event[CTX_KEY].workingPath, 'row'); + const result = await sqliteVercelBlob.postRequest(event, {}); + assert.ok(result, 'the save is refused'); + assert.strictEqual(result.statusCode, 500); + assert.strictEqual(result.retry, true); + assert.strictEqual(state.putCalls, 0, 'nothing was written to the store'); +}); diff --git a/test/sqliteVercelBlob.etag.test.js b/test/sqliteVercelBlob.etag.test.js new file mode 100644 index 000000000..b7f90e533 --- /dev/null +++ b/test/sqliteVercelBlob.etag.test.js @@ -0,0 +1,37 @@ +// ETag handling for util/sqliteVercelBlob.js. +// +// Vercel Blob serves downloads with a weak validator (`W/"abc"`) but compares +// x-if-match against the strong form (`"abc"`) that put and head report. An +// ETag cached from a download therefore has to be canonicalized, or every +// conditional write from that instance is rejected with a 412 - which is what +// happens on a cold start, since a cold instance always does a full download. + +const test = require('node:test'); +const assert = require('node:assert'); + +const { _normalizeEtag: normalizeEtag } = require('../util/sqliteVercelBlob.js'); + +test('a weak download ETag becomes the strong form used for conditional writes', () => { + assert.strictEqual( + normalizeEtag('W/"5eee2d609d153b6ba014d93d93637e3d"'), + '"5eee2d609d153b6ba014d93d93637e3d"' + ); +}); + +test('a strong ETag from put or head is left alone', () => { + assert.strictEqual( + normalizeEtag('"5eee2d609d153b6ba014d93d93637e3d"'), + '"5eee2d609d153b6ba014d93d93637e3d"' + ); +}); + +test('only a leading weak prefix is stripped', () => { + // A W/ inside the value is part of the entity tag, not a validator prefix. + assert.strictEqual(normalizeEtag('"abcW/def"'), '"abcW/def"'); + assert.strictEqual(normalizeEtag('W/"W/abc"'), '"W/abc"'); +}); + +test('a missing ETag stays falsy so no ifMatch is sent', () => { + assert.strictEqual(normalizeEtag(''), ''); + assert.strictEqual(normalizeEtag(undefined), undefined); +}); diff --git a/test/vercel-blob-emulator/handler.js b/test/vercel-blob-emulator/handler.js new file mode 100644 index 000000000..d0adc5e16 --- /dev/null +++ b/test/vercel-blob-emulator/handler.js @@ -0,0 +1,4 @@ +// Test-only Lambda handler that applies the undici fetch patch before the real +// handler loads the Vercel Blob SDK. Used as the CMD for the blob-test image. +require('./undici-patch.js'); +exports.handler = require('../api/vercel.js').handler; diff --git a/test/vercel-blob-emulator/server.js b/test/vercel-blob-emulator/server.js new file mode 100644 index 000000000..b6fd090ac --- /dev/null +++ b/test/vercel-blob-emulator/server.js @@ -0,0 +1,212 @@ +// Minimal Vercel Blob mock for e2e tests. +// Implements the endpoints our sqliteVercelBlob plugin relies on: +// PUT /?pathname= upload (honors x-if-match, x-allow-overwrite) +// GET /?url= head metadata +// GET / download (honors If-None-Match and cache=0) +// POST /delete delete (honors x-if-match for single URL) +// +// ETags use SHA-1 of the body, wrapped in double quotes (RFC 7232). +// +// Downloads go through a simulated CDN cache, because that's what they do on +// Vercel: blobs are cached for up to a month and an overwrite takes up to 60 +// seconds to propagate, so a plain get() can return the previous version. +// Passing `useCache: false` (which the SDK turns into `?cache=0`) reads from +// origin instead. See https://vercel.com/docs/vercel-blob#caching + +const http = require('node:http'); +const crypto = require('node:crypto'); +const { URL } = require('node:url'); + +const PORT = parseInt(process.env.PORT || '7000', 10); +const STORE_ID = process.env.STORE_ID || 'test'; +const ACCESS = process.env.ACCESS || 'private'; +const BASE_HOST = `${STORE_ID}.${ACCESS}.blob.vercel-storage.com`; +// How long a cached download keeps being served after an overwrite. 0 disables +// the simulated cache entirely. +const CACHE_STALE_MS = parseInt(process.env.BLOB_CACHE_STALE_MS || '60000', 10); +// Downloads carry a weak ETag (`W/"..."`) while the API reports the strong one, +// which is what Vercel Blob does. A client that reuses a download's ETag for a +// conditional write verbatim gets rejected. Set BLOB_WEAK_DOWNLOAD_ETAG=0 to +// serve strong ETags everywhere instead. +const WEAK_DOWNLOAD_ETAG = process.env.BLOB_WEAK_DOWNLOAD_ETAG !== '0'; +// Never answer a download with 304, so every read is a full download and the +// client ends up holding a download-sourced ETag. That's what a cold serverless +// instance does on its first request, and a warm single-container test never +// reaches it otherwise. +const NO_CONDITIONAL_READS = process.env.BLOB_NO_CONDITIONAL_READS === '1'; + +// Weak comparison per RFC 7232: `W/"x"` and `"x"` are the same entity. +function etagsWeaklyEqual(a, b) { + const strip = (v) => (v || '').replace(/^W\//, ''); + return !!a && strip(a) === strip(b); +} + +const store = new Map(); +// pathname -> { entry, expiresAt }: what the CDN would still be serving. +const cdnCache = new Map(); + +function computeEtag(buffer) { + return `"${crypto.createHash('sha1').update(buffer).digest('hex')}"`; +} + +function jsonError(res, status, code, message = '') { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ error: { code, message } })); +} + +function metadata(pathname, entry) { + const url = `https://${BASE_HOST}/${pathname}`; + return { + url, + downloadUrl: url + '?download=1', + pathname, + contentType: entry.contentType, + contentDisposition: `attachment; filename="${pathname.split('/').pop()}"`, + cacheControl: 'public, max-age=31536000, must-revalidate', + size: entry.body.length, + uploadedAt: entry.uploadedAt, + etag: entry.etag, + }; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on('data', c => chunks.push(c)); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} + +function extractPathname(input) { + try { + return new URL(input).pathname.slice(1); + } catch { + return input.startsWith('/') ? input.slice(1) : input; + } +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`); + const method = req.method; + + try { + if (method === 'PUT' && url.pathname === '/' && url.searchParams.has('pathname')) { + const pathname = url.searchParams.get('pathname'); + const body = await readBody(req); + const current = store.get(pathname); + const ifMatch = req.headers['x-if-match']; + const allowOverwrite = req.headers['x-allow-overwrite'] === '1'; + + if (ifMatch) { + if (!current || current.etag !== ifMatch) { + return jsonError(res, 412, 'precondition_failed', 'ETag mismatch'); + } + } else if (current && !allowOverwrite) { + return jsonError(res, 400, 'bad_request', 'Blob exists and overwrite is not allowed'); + } + + const entry = { + body, + etag: computeEtag(body), + contentType: req.headers['x-content-type'] || 'application/octet-stream', + uploadedAt: new Date().toISOString(), + }; + store.set(pathname, entry); + + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(metadata(pathname, entry))); + return; + } + + if (method === 'GET' && url.pathname === '/' && url.searchParams.has('url')) { + const pathname = extractPathname(url.searchParams.get('url')); + const entry = store.get(pathname); + if (!entry) { + return jsonError(res, 404, 'not_found', 'Blob not found'); + } + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(metadata(pathname, entry))); + return; + } + + if (method === 'POST' && url.pathname === '/delete') { + const body = await readBody(req); + let urls = []; + try { ({ urls = [] } = JSON.parse(body.toString() || '{}')); } catch {} + const ifMatch = req.headers['x-if-match']; + for (const u of urls) { + const pathname = extractPathname(u); + const current = store.get(pathname); + if (ifMatch && current && current.etag !== ifMatch) { + return jsonError(res, 412, 'precondition_failed', 'ETag mismatch'); + } + store.delete(pathname); + } + res.statusCode = 200; + res.setHeader('content-type', 'application/json'); + res.end('{}'); + return; + } + + if (method === 'GET') { + const pathname = url.pathname.slice(1); + const bypassCache = url.searchParams.get('cache') === '0'; + + let entry = store.get(pathname); + let cacheState = bypassCache ? 'BYPASS' : 'MISS'; + + if (!bypassCache && CACHE_STALE_MS > 0) { + const cached = cdnCache.get(pathname); + if (cached && cached.expiresAt > Date.now()) { + // Still within the propagation window: serve what the CDN + // has, even if the blob was overwritten since. + entry = cached.entry; + cacheState = 'HIT'; + } else if (entry) { + cdnCache.set(pathname, { entry, expiresAt: Date.now() + CACHE_STALE_MS }); + } else { + cdnCache.delete(pathname); + } + } + + if (!entry) { + res.statusCode = 404; + res.setHeader('x-vercel-blob-cache', cacheState); + res.end(); + return; + } + res.setHeader('x-vercel-blob-cache', cacheState); + const lastModified = new Date(entry.uploadedAt).toUTCString(); + const downloadEtag = WEAK_DOWNLOAD_ETAG ? 'W/' + entry.etag : entry.etag; + if (!NO_CONDITIONAL_READS && etagsWeaklyEqual(req.headers['if-none-match'], downloadEtag)) { + res.statusCode = 304; + res.setHeader('etag', downloadEtag); + res.setHeader('last-modified', lastModified); + res.end(); + return; + } + res.statusCode = 200; + res.setHeader('etag', downloadEtag); + res.setHeader('content-type', entry.contentType); + res.setHeader('content-length', entry.body.length); + res.setHeader('last-modified', lastModified); + res.end(entry.body); + return; + } + + res.statusCode = 405; + res.end(); + } catch (err) { + console.error('vercel-blob-emulator error:', err); + res.statusCode = 500; + res.end(); + } +}); + +server.listen(PORT, () => { + console.log(`vercel-blob-emulator listening on :${PORT} (storeId=${STORE_ID}, access=${ACCESS})`); +}); diff --git a/test/vercel-blob-emulator/undici-patch.js b/test/vercel-blob-emulator/undici-patch.js new file mode 100644 index 000000000..991950efc --- /dev/null +++ b/test/vercel-blob-emulator/undici-patch.js @@ -0,0 +1,44 @@ +// Test-only monkey patch: redirects fetch() calls to `*.blob.vercel-storage.com` +// to the Vercel Blob emulator at VERCEL_BLOB_MOCK_URL. +// +// The Vercel Blob SDK hardcodes download URLs (`constructBlobUrl` in +// @vercel/blob/dist/index.cjs) and validates URL inputs to get() against +// `.blob.vercel-storage.com`, so we can't override the host via env vars. The +// SDK's compiled CJS does `_undici.fetch.call(...)` against the shared undici +// module exports, so replacing `undici.fetch` after it's been required is +// picked up by every subsequent SDK call. +// +// Put/head/list/delete go through VERCEL_BLOB_API_URL (set separately). + +const undici = require('undici'); + +const MOCK_URL = process.env.VERCEL_BLOB_MOCK_URL; +if (MOCK_URL) { + const mockBase = new URL(MOCK_URL); + const originalFetch = undici.fetch; + + undici.fetch = function patchedFetch(input, opts) { + let urlStr; + if (typeof input === 'string') { + urlStr = input; + } else if (input instanceof URL) { + urlStr = input.toString(); + } else if (input && typeof input.url === 'string') { + urlStr = input.url; + } + + if (urlStr) { + try { + const parsed = new URL(urlStr); + if (parsed.hostname.endsWith('.blob.vercel-storage.com')) { + const rewritten = new URL(parsed.pathname + parsed.search, mockBase).toString(); + return originalFetch.call(this, rewritten, opts); + } + } catch {} + } + + return originalFetch.call(this, input, opts); + }; + + console.log('[undici-patch] redirecting *.blob.vercel-storage.com to', MOCK_URL); +} diff --git a/util/install.js b/util/install.js index 8352f41c8..adb80b13b 100644 --- a/util/install.js +++ b/util/install.js @@ -1,17 +1,9 @@ +const storage = require('./storage.js'); + exports.validate = function(response) { - let hasSqliteS3 = false; - let hasSQL = false; let platform = 'AWS'; let dashboardLink; - if (process.env['SQLITE_S3_BUCKET'] || process.env['SERVERLESSWP_DATA_SECRET']) { - hasSqliteS3 = true; - } - - if (process.env['DATABASE'] && process.env['USERNAME'] && process.env['PASSWORD'] && process.env['HOST']) { - hasSQL = true; - } - if (process.env['SITE_NAME']) { platform = 'Netlify'; dashboardLink = `https://app.netlify.com/sites/${process.env['SITE_NAME']}/settings/env`; @@ -24,7 +16,7 @@ exports.validate = function(response) { dashboardLink = 'https://console.aws.amazon.com/console/home'; } - if (!hasSQL && !hasSqliteS3) { + if (storage.resolve().mode === 'none') { let data = {}; data.dashboardLink = dashboardLink; data.platform = platform; diff --git a/util/sqliteS3.js b/util/sqliteS3.js index 83c27dec0..20a75fb1b 100644 --- a/util/sqliteS3.js +++ b/util/sqliteS3.js @@ -48,6 +48,15 @@ exports.preRequest = async function(event) { workingPath: '/tmp/' + workingFileName, db: null, dataVersion: null, + // The S3 object version this request's working copy came from. Bound + // here, per request, because the shared etag file moves under + // concurrent requests: reading it again at write time would let a + // request whose copy predates another's committed write pass its + // IfMatch and silently revert that write. + etag: null, + // Set when the read established the object doesn't exist. Only then + // may postRequest write without IfMatch. + blobMissing: false, }; event[CONTEXT_KEY] = ctx; @@ -88,15 +97,19 @@ exports.preRequest = async function(event) { await fs.writeFile(tmp, response.Body); await fs.rename(tmp, CACHE_FILE); await setEtag(response.ETag); + ctx.etag = response.ETag; } else { // @TODO: if it doesn't exist, behave like it's a new site? console.log('db file not found'); + ctx.blobMissing = true; } } catch (err) { if (err.$metadata && err.$metadata.httpStatusCode === 304) { - // Cache is up to date; fall through to copy below. + // Cache is up to date; fall through to copy below. The 304 was + // earned by IfNoneMatch, so cachedEtag is the version we hold. + ctx.etag = cachedEtag; } else if (err.$metadata?.httpStatusCode === 403) { if (_config.onAuthError) { @@ -108,15 +121,16 @@ exports.preRequest = async function(event) { } return; } - else if (err.name === 'NoSuchKey') { + else if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) { // Handle case where the file doesn't exist on S3 console.log('Database file not found on server'); + ctx.blobMissing = true; return; } else { // Handle other errors console.error('Error fetching database:', err); - return; + return readError(); } } @@ -154,12 +168,29 @@ exports.postRequest = async function(event, response) { // See if the db has been mutated, if so, send the changes to s3 const readOnly = process.env['SERVERLESSWP_READ_ONLY_MODE'] && !['false', '0', 'no'].includes(process.env['SERVERLESSWP_READ_ONLY_MODE'].toLowerCase()); if (!readOnly && ctx.dataVersion !== versionNow && workingExists) { + // The object exists but we don't know which version the working + // copy came from (e.g. the read failed over to the auth flow). + // Writing anyway would be unconditional and could overwrite + // another instance's committed changes - fail and let the retry + // re-fetch. + if (!ctx.etag && !ctx.blobMissing) { + console.log('Refusing to save database without a bound ETag.'); + return { + statusCode: 500, + body: 'Database error. This can happen when simultaneous database updates happen. Re-try your request.', + retry: true, + }; + } + try { await dbClose(ctx.db); ctx.db = null; const sqliteContent = await fs.readFile(ctx.workingPath); - let currentEtag = await getEtag(); + // The version this request started from, captured in + // preRequest - never the shared etag file, which a concurrent + // request may have advanced since. + let currentEtag = ctx.etag; let putCommandParams = { Bucket: _config.bucket, @@ -211,8 +242,19 @@ exports.postRequest = async function(event, response) { } } -exports.branchNameToS3file = function(branch) { - return encodeURIComponent(branch); +// Fail the request when the database can't be read. A missing key is a new site +// and returns above, so this is only reached when the read itself failed. +// Letting the request through would hand WordPress an empty database, and +// postRequest would then save that over the real one. _forceResponse stops the +// plugin chain, otherwise a later plugin returning nothing would drop this +// response and WordPress would run anyway. +function readError() { + return { + statusCode: 500, + headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' }, + body: 'Database error. The database could not be read. Re-try your request.', + _forceResponse: true, + }; } async function getEtag() { diff --git a/util/sqliteVercelBlob.js b/util/sqliteVercelBlob.js new file mode 100644 index 000000000..23ce41a19 --- /dev/null +++ b/util/sqliteVercelBlob.js @@ -0,0 +1,339 @@ +const sqlite3 = require('sqlite3').verbose(); +const fs = require('fs').promises; +const fsSync = require('fs'); +const { randomUUID } = require('crypto'); +const { Readable } = require('node:stream'); +const { pipeline } = require('node:stream/promises'); +const { BlobPreconditionFailedError, BlobNotFoundError } = require('@vercel/blob'); +let { get, put } = require('@vercel/blob'); + +const ETAG_CACHE = '/tmp/etag-vercel-blob.txt'; +const CACHE_FILE = '/tmp/wp-sqlite-cache.sqlite'; +const CONTEXT_KEY = Symbol.for('serverlesswp.sqliteVercelBlob.context'); + +let init = false; +let _config; + +exports.name = 'ServerlessWP sqlite Vercel Blob'; + +exports.config = function(config) { + _config = config; +} + +exports.preRequest = async function(event) { + if (!_config?.pathname) { + throw new Error("Vercel Blob pathname is required"); + } + + const workingFileName = 'wp-sqlite-' + randomUUID() + '.sqlite'; + const ctx = { + workingPath: '/tmp/' + workingFileName, + db: null, + dataVersion: null, + // The blob version this request's working copy came from. Bound here, + // per request, because the shared etag file moves under concurrent + // requests: reading it again at write time would let a request whose + // copy predates another's committed write pass its ifMatch and + // silently revert that write. + etag: null, + // Set when the read established the blob doesn't exist. Only then may + // postRequest write without ifMatch. + blobMissing: false, + }; + event[CONTEXT_KEY] = ctx; + + // Tell PHP (wp-config.php) which DB file to open for this request. + // Strip any inbound variant first so a client can't point WP at the + // cache file or another request's working file. wp-config.php also + // passes the value through basename() defensively. + if (!event.headers) event.headers = {}; + for (const k of Object.keys(event.headers)) { + if (k.toLowerCase() === 'x-serverlesswp-sqlite-file') { + delete event.headers[k]; + } + } + event.headers['x-serverlesswp-sqlite-file'] = workingFileName; + + const cachedEtag = await getEtag(); + + // useCache: false reads from origin instead of the CDN cache. Reads have to + // be consistent here: a cached read can serve the previous version of the + // blob for up to 60 seconds after a write, which both hands WordPress a + // stale database and hands us a stale ETag - so the next conditional write + // fails its ifMatch and the request 500s. + // https://vercel.com/docs/vercel-blob/private-storage#consistent-reads + const options = { access: 'private', useCache: false }; + if (_config.token) { + options.token = _config.token; + } + // Only send ifNoneMatch if we actually have the cache file locally. + // Otherwise a 304 leaves us with no file to copy. + if (cachedEtag && await exists(CACHE_FILE)) { + options.ifNoneMatch = cachedEtag; + } + + try { + const response = await get(_config.pathname, options); + + if (!response) { + // Blob doesn't exist yet - behave like a new site. + ctx.blobMissing = true; + return; + } + + if (response.statusCode === 304) { + // Cache is up to date; fall through to copy below. The 304 was + // earned by ifNoneMatch, so cachedEtag is the version we hold. + ctx.etag = cachedEtag; + } + else if (response.statusCode === 200 && response.stream) { + // Stream to a tmp path then atomically rename into place. + // Existing open fds against the old inode keep working. + const tmp = CACHE_FILE + '.' + randomUUID() + '.tmp'; + await pipeline( + Readable.fromWeb(response.stream), + fsSync.createWriteStream(tmp) + ); + await fs.rename(tmp, CACHE_FILE); + const downloadedEtag = normalizeEtag(response.blob?.etag); + if (downloadedEtag) { + await setEtag(downloadedEtag); + ctx.etag = downloadedEtag; + } + } + } + catch (err) { + if (err instanceof BlobNotFoundError) { + console.log('Database blob not found'); + ctx.blobMissing = true; + return; + } + console.error('Error fetching database blob:', err); + return readError(); + } + + // If we have a cache file (from this request or a previous one), copy it + // to a per-invocation working file and open SQLite against that copy. + // This isolates concurrent requests on the same warm instance. + if (await exists(CACHE_FILE)) { + await fs.copyFile(CACHE_FILE, ctx.workingPath); + ctx.db = new sqlite3.Database(ctx.workingPath); + ctx.dataVersion = await getDataVersion(ctx.db); + } +} + +exports.postRequest = async function(event, response) { + const ctx = event[CONTEXT_KEY]; + if (!ctx) { + return; + } + + try { + // If db wasn't initialized but the working file somehow exists, treat + // it as a new database (e.g. fresh install path). + const workingExists = await exists(ctx.workingPath); + if (!ctx.db) { + if (workingExists) { + ctx.db = new sqlite3.Database(ctx.workingPath); + ctx.dataVersion = null; + } else { + return; + } + } + + const versionNow = await getDataVersion(ctx.db); + + // See if the db has been mutated, if so, send the changes to the blob store. + const readOnly = process.env['SERVERLESSWP_READ_ONLY_MODE']; + const readOnlyActive = readOnly && !['false', '0', 'no'].includes(readOnly.toLowerCase()); + if (!readOnlyActive && ctx.dataVersion !== versionNow && workingExists) { + // The blob exists but we don't know which version the working + // copy came from (e.g. a download without an ETag). Writing + // anyway would be unconditional and could overwrite another + // instance's committed changes - fail and let the retry re-fetch. + if (!ctx.etag && !ctx.blobMissing) { + console.log('Refusing to save database without a bound ETag.'); + return { + statusCode: 500, + headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' }, + body: 'Database error. This can happen when simultaneous database updates happen. Re-try your request.', + retry: true, + }; + } + + try { + await dbClose(ctx.db); + ctx.db = null; + + const sqliteContent = await fs.readFile(ctx.workingPath); + // The version this request started from, captured in + // preRequest - never the shared etag file, which a concurrent + // request may have advanced since. + const currentEtag = ctx.etag; + + const putOptions = { + access: 'private', + allowOverwrite: true, + addRandomSuffix: false, + }; + if (_config.token) { + putOptions.token = _config.token; + } + if (currentEtag) { + putOptions.ifMatch = currentEtag; + } + + const putResponse = await put(_config.pathname, sqliteContent, putOptions); + + // Refresh the local cache before writing the ETag so etag.txt + // never describes content newer than CACHE_FILE. If the copy + // fails, the old ETag stays on disk and the next request's + // ifNoneMatch will miss, triggering a clean re-fetch. + const tmp = CACHE_FILE + '.' + randomUUID() + '.tmp'; + await fs.copyFile(ctx.workingPath, tmp); + await fs.rename(tmp, CACHE_FILE); + if (putResponse?.etag) { + await setEtag(putResponse.etag); + } + return; + } + catch (err) { + console.error('Error saving database to Vercel Blob:', err); + const errResponse = { + statusCode: 500, + headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' }, + body: 'Database error. This can happen when simultaneous database updates happen. Re-try your request.' + } + if (err instanceof BlobPreconditionFailedError) { + errResponse.retry = true; + console.log('Retrying database save to Vercel Blob because of a conflicting update.'); + } + return errResponse; + } + } + } + catch (err) { + console.log(err); + } + finally { + if (ctx.db) { + try { await dbClose(ctx.db); } catch (e) { /* swallow */ } + ctx.db = null; + } + try { await fs.unlink(ctx.workingPath); } catch (e) { /* file may not exist */ } + delete event[CONTEXT_KEY]; + } +} + +// Fail the request when the blob can't be read. A blob that doesn't exist yet +// is a new site and returns null instead, so this is only reached when the read +// itself failed. Letting the request through would hand WordPress an empty +// database, and postRequest would then save that over the real one. +// _forceResponse stops the plugin chain, otherwise a later plugin returning +// nothing would drop this response and WordPress would run anyway. +function readError() { + return { + statusCode: 500, + headers: { 'content-type': 'text/plain', 'cache-control': 'no-store' }, + body: 'Database error. The database could not be read. Re-try your request.', + _forceResponse: true, + }; +} + +// Downloads carry a weak validator (`W/"abc"`) while put and head report the +// strong form (`"abc"`), and x-if-match is compared against the strong one. So +// an ETag taken from a download can't be used for a conditional write as-is: +// the store sees a mismatch even though it's the same database. Any instance +// that did a full download - every cold start - could never write again. +// Canonicalize on the way in and out so both sources agree. +function normalizeEtag(etag) { + return typeof etag === 'string' ? etag.replace(/^W\//, '') : etag; +} + +// Exported for tests. +exports._normalizeEtag = normalizeEtag; + +// Test-only: swap the blob API for a mock without touching the real store. +exports._setBlobForTests = function(mock) { + get = mock.get; + put = mock.put; +} + +async function getEtag() { + try { + return normalizeEtag(await fs.readFile(ETAG_CACHE, 'utf8')); + } catch (err) { + return ''; + } +} + +async function setEtag(newEtag) { + await fs.writeFile(ETAG_CACHE, normalizeEtag(newEtag)); +} + +async function getDataVersion(db) { + return new Promise((resolve, reject) => { + if (!db) { return reject('No db') } + try { + db.get("PRAGMA data_version", (err, row) => { + if (err) { + reject(err); + } else { + resolve(row['data_version']); + } + }); + } + catch (err) { + reject(err); + } + }); +} + +async function dbClose(db) { + return new Promise((resolve, reject) => { + if (!db) { return reject('No db') } + try { + db.close((closeErr) => { + if (closeErr) { + reject(closeErr); + } + resolve(); + }); + } + catch (err) { + reject(err); + } + }); +} + +async function exists(path) { + try { + await fs.access(path); + return true; + } catch (error) { + return false; + } +} + +// Put the sqlite db class in place if not already there. +// Paths should reference where they've been setup in /tmp. +exports.prepPlugin = async function (wpContentPath, sqlitePluginPath) { + if (!init) { + try { + const pluginPackagePath = sqlitePluginPath; + const oldPath = pluginPackagePath + '/db.copy'; + const newPath = wpContentPath + '/db.php'; + await fs.copyFile(oldPath, newPath); + const content = await fs.readFile(newPath, 'utf8'); + const modifiedContent = content + .replace(new RegExp(/{SQLITE_IMPLEMENTATION_FOLDER_PATH}/, 'g'), pluginPackagePath) + .replace(new RegExp(/{SQLITE_PLUGIN}/, 'g'), 'sqlite-database-integration/load.php'); + + await fs.writeFile(newPath, modifiedContent); + init = true; + } + catch (err) { + console.log(err); + } + } +} diff --git a/util/storage.js b/util/storage.js new file mode 100644 index 000000000..b19e7a253 --- /dev/null +++ b/util/storage.js @@ -0,0 +1,95 @@ +// Single source of truth for which database a deployment uses. +// +// The most explicitly configured option wins, so connecting a Blob store for +// uploads can't take over a site that already has a database: +// +// 1. MySQL DATABASE + USERNAME + PASSWORD + HOST +// 2. SQLite + S3 SQLITE_S3_BUCKET, or SERVERLESSWP_DATA_SECRET (sandbox) +// 3. SQLite + Blob SQLITE_BLOB_READ_WRITE_TOKEN on Vercel +// 4. none show the install page +// +// wp-config.php doesn't repeat this: the active plugin tells it which file to +// open via the x-serverlesswp-sqlite-file header. + +const sandbox = require('./sandbox.js'); + +function has(...names) { + return names.every((name) => !!process.env[name]); +} + +// Each Vercel branch gets its own database. Empty off Vercel. +function branchSlug() { + const ref = process.env['VERCEL_GIT_COMMIT_REF']; + return ref ? '-' + encodeURIComponent(ref) : ''; +} + +function sqliteS3Config() { + // The sandbox flow uses the project id as both bucket name and API key. + const vercelFallback = process.env['VERCEL'] ? process.env['VERCEL_PROJECT_ID'] : undefined; + + const config = { + bucket: process.env['SQLITE_S3_BUCKET'] || vercelFallback, + file: `wp-sqlite-s3${branchSlug()}.sqlite`, + S3Client: { + credentials: { + accessKeyId: process.env['SQLITE_S3_API_KEY'] || vercelFallback, + secretAccessKey: process.env['SQLITE_S3_API_SECRET'] || process.env['SERVERLESSWP_DATA_SECRET'], + }, + region: process.env['SQLITE_S3_REGION'], + } + }; + + if (process.env['SQLITE_S3_ENDPOINT']) { + config.S3Client.endpoint = process.env['SQLITE_S3_ENDPOINT']; + } + + if (process.env['SQLITE_S3_FORCE_PATH_STYLE'] || process.env['SERVERLESSWP_DATA_SECRET']) { + config.S3Client.forcePathStyle = true; + } + + if (process.env['SERVERLESSWP_DATA_SECRET']) { + config.S3Client.endpoint = 'https://data.serverlesswp.com'; + config.onAuthError = () => sandbox.register(config.bucket, process.env['SERVERLESSWP_DATA_SECRET']); + } + + return config; +} + +function sqliteBlobConfig() { + return { + // Optional override. + pathname: `${process.env['SQLITE_BLOB_PATHNAME'] || 'wp-sqlite'}${branchSlug()}.sqlite`, + // Injected by Vercel for a store created with an envVarPrefix of + // SQLITE. The unprefixed BLOB_READ_WRITE_TOKEN is deliberately not + // accepted: that's the token a store connected for uploads gets, and + // those stores are public, so every private write here would fail. + token: process.env['SQLITE_BLOB_READ_WRITE_TOKEN'], + }; +} + +// Returns { mode, plugin, config }; plugin and config are absent for 'mysql' +// and 'none', which need no request-time handling. +exports.resolve = function () { + if (has('DATABASE', 'USERNAME', 'PASSWORD', 'HOST')) { + return { mode: 'mysql' }; + } + + if (has('SQLITE_S3_BUCKET') || has('SERVERLESSWP_DATA_SECRET')) { + return { + mode: 'sqlite-s3', + plugin: require('./sqliteS3.js'), + config: sqliteS3Config(), + }; + } + + // Only wired up on Vercel, even if a token exists elsewhere. + if (has('VERCEL') && has('SQLITE_BLOB_READ_WRITE_TOKEN')) { + return { + mode: 'sqlite-vercel-blob', + plugin: require('./sqliteVercelBlob.js'), + config: sqliteBlobConfig(), + }; + } + + return { mode: 'none' }; +}; diff --git a/wp/wp-config.php b/wp/wp-config.php index 9a4e3f560..c87bbea6d 100644 --- a/wp/wp-config.php +++ b/wp/wp-config.php @@ -129,21 +129,17 @@ define('DISALLOW_FILE_EDIT', true ); define('DISALLOW_FILE_MODS', true ); -// If using SQLite + S3 instead of MySQL/MariaDB. -if (isset($_ENV['SQLITE_S3_BUCKET']) || isset($_ENV['SERVERLESSWP_DATA_SECRET'])) { +// If using SQLite (S3 or Vercel Blob) instead of MySQL/MariaDB. Node owns this +// decision (util/storage.js) and signals it with the header below, which the +// active plugin strips of any inbound value in preRequest. +if (!empty($_SERVER['HTTP_X_SERVERLESSWP_SQLITE_FILE'])) { define('DB_DIR', '/tmp'); - // Per-invocation working file path is supplied by the Node sqliteS3 plugin - // via a request header so concurrent requests on the same warm instance - // don't share one file. Falls back to a fixed name if the header is missing. - if (!empty($_SERVER['HTTP_X_SERVERLESSWP_SQLITE_FILE'])) { - define('DB_FILE', basename($_SERVER['HTTP_X_SERVERLESSWP_SQLITE_FILE'])); - } else { - define('DB_FILE', 'wp-sqlite-s3.sqlite'); - } + // Per-invocation working file so concurrent requests don't share one. + define('DB_FILE', basename($_SERVER['HTTP_X_SERVERLESSWP_SQLITE_FILE'])); define('DB_NAME', 'wp-sqlite'); - // Force the rollback journal mode. The Node sqliteS3 plugin uploads the - // single .sqlite file to S3 at the end of each request. + // Force the rollback journal mode. The Node sqlite plugin uploads the + // single .sqlite file to remote storage at the end of each request. define('SQLITE_JOURNAL_MODE', 'DELETE'); // Auto-cron can cause db race conditions on these urls, don't bother with it.