@tryghost/algolia-netlify 0.3.4 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2013-2023 Ghost Foundation
3
+ Copyright (c) 2013-2025 Ghost Foundation
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,94 +1,95 @@
1
1
  # Algolia Netlify
2
2
 
3
- [Netlify Functions](https://www.netlify.com/products/functions/) to listen to [Ghost Webhooks](https://ghost.org/docs/api/webhooks/) on post changes and update defined [Algolia](https://www.algolia.com/) search index.
3
+ `@tryghost/algolia-netlify` provides Netlify Functions that listen to Ghost post webhooks and update an Algolia search index.
4
4
 
5
- ## Usage
6
-
7
- ### Set up Algolia
5
+ The public package exports the native Request/Response handlers from its root and from explicit subpaths:
8
6
 
9
- First step is to grab the API keys and Application ID from Algolia. For the setup we need both, the "Search-Only API Key" as well as the "Admin API Key".
7
+ ```js
8
+ import {postPublished, postUnpublished} from '@tryghost/algolia-netlify';
9
+ import publishedHandler from '@tryghost/algolia-netlify/post-published';
10
+ import unpublishedHandler from '@tryghost/algolia-netlify/post-unpublished';
11
+ ```
10
12
 
11
- The Admin API Key can either be the general one, or can be created just for this specific search index.
13
+ The named and subpath exports reference the same handlers. Consumers can wrap either subpath in a basename-preserving Netlify Function entry; this repository deploys the canonical `post-published` and `post-unpublished` entries directly.
12
14
 
13
- If you decide to create a new API key, you want to make sure that the generated key has the following authorizations on your index:
15
+ ## Security
14
16
 
15
- - Search (`search`)
16
- - Add records (`addObject`)
17
- - Delete records (`deleteObject`)
18
- - List indexes (`listIndexes`)
19
- - Delete index (`deleteIndex`)
17
+ > [!WARNING]
18
+ > The current handlers do not enforce authentication when the `key` query parameter is omitted; they reject only a supplied key that does not match `NETLIFY_KEY`. Netlify Function URLs are public endpoints, not secrets, and the user-agent check can be spoofed. Pause or restrict exposure outside the handlers until mandatory authentication is implemented before public use.
20
19
 
21
- ### Set up Netlify Functions
20
+ Use a restricted Algolia API key to limit the impact of unauthorized requests.
22
21
 
23
- The Ghost Algolia tooling uses [Ghost Webhooks](https://ghost.org/docs/api/webhooks/) to index and update posts. The scripts that receive and process the webhooks are hosted by [Netlify Functions](https://www.netlify.com/products/functions/):
22
+ ## Usage
24
23
 
25
- 1. Deploy to Netlify by clicking on this button:
26
- [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/TryGhost/algolia)
27
- 2. Click 'Connect to Github' and give Netlify permission
28
- 3. Configure your site
29
- - Choose a repository name
30
- - Set 'TRUE' to trigger indexing
31
- - Algolia Application ID
32
- - The Algolia Admin API key or and API key with the permissions as described above
33
- - The name of the index you want to use
34
- - Set the `NETLIFY_KEY` to be used with the target URL
24
+ ### Set up Algolia
35
25
 
36
- ### Set up Ghost Webhooks
26
+ The functions need the Algolia Application ID and an API key that can update the target index. The general Admin API key works, but a key scoped to this index is preferable. A scoped key needs these permissions:
37
27
 
38
- Ghost webhooks will initiate posts to be indexed to Algolia. This can be a new entry, an update, or a removal. On Ghost's admin panel, create a new **Custom Integration** (Ghost Admin → Settings → Integrations → Custom Integrations) and the following **webhooks**:
28
+ - Add records (`addObject`)
29
+ - Delete records matching a filter (`deleteIndex`)
30
+ - Get index settings (`settings`)
31
+ - Set index settings (`editSettings`)
39
32
 
40
- 1. `post.published`
41
- - Name: Post published
42
- - Event: Post published
43
- - Target URL: the endpoint of the post-published function, found on Netlify's admin panel plus the `NETLIFY_KEY` as a query parameter as defined in the configuration data above (https://YOUR-SITE-ID.netlify.com/.netlify/functions/post-published?key=NETLIFY_KEY)
33
+ ### Deploy the Netlify Functions
44
34
 
45
- 2. `post.published.edited`
46
- - Name: Post updated
47
- - Event: Published post updated
48
- - Target URL: the endpoint of the post-published function, found on Netlify's admin panel plus the `NETLIFY_KEY` as a query parameter as defined in the configuration data above (https://YOUR-SITE-ID.netlify.com/.netlify/functions/post-published?key=NETLIFY_KEY)
35
+ Deploy manually, and do not expose the deployed functions publicly until mandatory authentication is implemented or access is restricted outside the handlers:
49
36
 
50
- 3. `post.unpublished`
51
- - Name: Post unpublished
52
- - Event: Post unpublished
53
- - Target URL: the endpoint of the post-published function, found on Netlify's admin panel plus the `NETLIFY_KEY` as a query parameter as defined in the configuration data above (https://YOUR-SITE-ID.netlify.com/.netlify/functions/post-unpublished?key=NETLIFY_KEY)
37
+ 1. Create a Netlify site and connect this repository manually.
38
+ 2. Configure the site and set the environment variables described in [`.env.example`](.env.example):
39
+ - Set `ALGOLIA_ACTIVE` to `TRUE` to enable indexing.
40
+ - Set the Algolia Application ID, API key, and index name.
41
+ - Choose a `NETLIFY_KEY` for the webhook query parameter. Setting it does not protect current handlers when the parameter is omitted.
54
42
 
55
- 4. `post.deleted`
56
- - Name: Post deleted
57
- - Event: Post deleted
58
- - Target URL: the endpoint of the post-published function, found on Netlify's admin panel plus the `NETLIFY_KEY` as a query parameter as defined in the configuration data above (https://YOUR-SITE-ID.netlify.com/.netlify/functions/post-unpublished?key=NETLIFY_KEY)
43
+ The repository's [`netlify.toml`](../../netlify.toml) builds and deploys these functions from `packages/algolia-netlify`. The explicit `public` publish directory contains only a static landing page so package files, function sources, and function build artifacts are not exposed as site files.
59
44
 
60
- These webhooks will trigger an index on every **future change of posts**.
45
+ ### Set up Ghost webhooks
61
46
 
62
- > To run an initial index of all the content, you can use the handy CLI from our Ghost Algolia tooling. Head over [here](https://github.com/TryGhost/algolia/tree/master/packages/algolia) and follow the instructions from there.
47
+ In Ghost Admin, create a **Custom Integration** under **Settings Integrations** and add these webhooks:
63
48
 
49
+ | Ghost event | Function |
50
+ | --- | --- |
51
+ | `post.published` | `post-published` |
52
+ | `post.published.edited` | `post-published` |
53
+ | `post.unpublished` | `post-unpublished` |
54
+ | `post.deleted` | `post-unpublished` |
64
55
 
65
- ## Security
56
+ Use the function URL shown by Netlify and pass the configured `NETLIFY_KEY` as the `key` query parameter. For example:
66
57
 
67
- To avoid unauthorized access to the Netlify functions endpoints, we highly recommend to setup the `NETLIFY_KEY` variable. This key is currently optional but will be enforced in the future.
58
+ ```text
59
+ https://YOUR-SITE-ID.netlify.app/.netlify/functions/post-published?key=NETLIFY_KEY
60
+ ```
68
61
 
69
- ## Develop
62
+ These webhooks keep future post changes synchronized. Use the [`@tryghost/algolia` CLI](../algolia/README.md) to create the initial index.
70
63
 
71
- This is a mono repository, managed with [lerna](https://lernajs.io/).
64
+ ## Development
72
65
 
73
- Follow the instructions for the top-level repo.
74
- 1. `git clone` this repo & `cd` into it as usual
75
- 2. Run `yarn` to install top-level dependencies.
66
+ Install the monorepo dependencies from the repository root with `pnpm install`. Then configure and run this package locally:
76
67
 
77
- To run this package locally, you will need to copy the existing `.env.example` file to `.env` and fill it with the correct keys.
68
+ ```sh
69
+ cd packages/algolia-netlify
70
+ cp .env.example .env
71
+ # Replace the example values in .env with development credentials.
72
+ pnpm dev
73
+ ```
78
74
 
79
- By running
75
+ `pnpm dev` starts Netlify Dev, which builds the TypeScript functions as needed. Use the local URL it prints for endpoints such as `/.netlify/functions/post-published`.
80
76
 
81
- - `yarn serve`
77
+ Run this package's tests and lint checks from the repository root:
82
78
 
83
- you will create a server on `localhost:9000` where your functions will be exposed to listen to (e. g. http://localhost:9000/.netlify/functions/post-unpublished), so you can use them in your local Ghost instance as Webhook target URL.
79
+ ```sh
80
+ pnpm --filter @tryghost/algolia-netlify test
81
+ pnpm --filter @tryghost/algolia-netlify typecheck
82
+ pnpm --filter @tryghost/algolia-netlify build
83
+ ```
84
84
 
85
+ Run the full monorepo suite with `pnpm test`.
85
86
 
86
- ## Test
87
+ The modern handlers use native `Request` and `Response` objects. Malformed, empty, or structurally invalid JSON now receives `400 Invalid request body`; a valid Ghost envelope with no selected post remains a `200 No valid request body detected` response. Existing valid webhook behavior, endpoint URLs, and optional `key` handling are unchanged.
87
88
 
88
- - `yarn lint` run just eslint
89
- - `yarn test` run lint and tests
89
+ From this package directory, `pnpm pack` builds an ESM-only package with generated TypeScript declarations. The supported Node range is declared in [`package.json`](package.json). The package intentionally exposes only the two handlers; webhook utilities remain internal.
90
90
 
91
+ ---
91
92
 
92
- # Copyright & License
93
+ ## Copyright & License
93
94
 
94
- Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE).
95
+ Copyright (c) 2013-2026 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
@@ -0,0 +1 @@
1
+ export default function postPublished(request: Request): Promise<Response>;
@@ -0,0 +1,80 @@
1
+ // functions/utils/webhook.ts
2
+ var TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8" };
3
+ var GHOST_SOURCE = String.raw`https://github\.com/TryGhost/Ghost`;
4
+ var SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
5
+ var GHOST_USER_AGENT = new RegExp(
6
+ `^Ghost(?:\\(${GHOST_SOURCE}\\)|/${SEMVER} \\(${GHOST_SOURCE}\\))$`,
7
+ "u"
8
+ );
9
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ var isGhostUserAgent = (value) => value !== null && GHOST_USER_AGENT.test(value);
11
+ var textResponse = (body, status = 200) => new Response(body, { status, headers: TEXT_HEADERS });
12
+ var invalidBodyResponse = () => textResponse("Invalid request body", 400);
13
+ var noValidBodyResponse = () => textResponse("No valid request body detected");
14
+ var authorizeRequest = (request) => {
15
+ const keys = new URL(request.url).searchParams.getAll("key");
16
+ if (keys.length > 1 || keys[0] && keys[0] !== process.env.NETLIFY_KEY) {
17
+ return textResponse("Unauthorized", 401);
18
+ }
19
+ return void 0;
20
+ };
21
+ var algoliaSettings = () => ({
22
+ appId: process.env.ALGOLIA_APP_ID,
23
+ apiKey: process.env.ALGOLIA_API_KEY,
24
+ index: process.env.ALGOLIA_INDEX
25
+ });
26
+ var parseWebhookBody = async (request) => {
27
+ try {
28
+ const body = await request.json();
29
+ if (!isRecord(body) || !isRecord(body.post)) {
30
+ return void 0;
31
+ }
32
+ return { post: body.post };
33
+ } catch {
34
+ return void 0;
35
+ }
36
+ };
37
+
38
+ // functions/utils/algolia.mts
39
+ import IndexFactory from "@tryghost/algolia-indexer";
40
+ import transforms from "@tryghost/algolia-fragmenter";
41
+ import "algolia-html-extractor";
42
+ import "algoliasearch";
43
+
44
+ // functions/post-published.mts
45
+ async function postPublished(request) {
46
+ const authorizationResponse = authorizeRequest(request);
47
+ if (authorizationResponse) {
48
+ return authorizationResponse;
49
+ }
50
+ if (process.env.ALGOLIA_ACTIVE !== "TRUE") {
51
+ return textResponse("Algolia is not activated");
52
+ }
53
+ if (!isGhostUserAgent(request.headers.get("user-agent"))) {
54
+ return textResponse("Unauthorized", 401);
55
+ }
56
+ const payload = await parseWebhookBody(request);
57
+ if (!payload) {
58
+ return invalidBodyResponse();
59
+ }
60
+ const post = payload.post.current;
61
+ if (!isRecord(post) || Object.keys(post).length === 0) {
62
+ return noValidBodyResponse();
63
+ }
64
+ try {
65
+ const algoliaObjects = transforms.transformToAlgoliaObject([post]);
66
+ const fragments = algoliaObjects.reduce(transforms.fragmentTransformer, []);
67
+ const index = new IndexFactory(algoliaSettings());
68
+ await index.setSettingsForIndex();
69
+ await index.save(fragments);
70
+ console.log("Fragments successfully saved to Algolia index");
71
+ return textResponse(`Post "${String(post.title)}" has been added to the index.`);
72
+ } catch (error) {
73
+ console.log(error);
74
+ const message = error instanceof Error ? error.message : String(error);
75
+ return Response.json({ msg: message }, { status: 500 });
76
+ }
77
+ }
78
+ export {
79
+ postPublished as default
80
+ };
@@ -0,0 +1 @@
1
+ export default function postUnpublished(request: Request): Promise<Response>;
@@ -0,0 +1,81 @@
1
+ // functions/utils/webhook.ts
2
+ var TEXT_HEADERS = { "content-type": "text/plain; charset=utf-8" };
3
+ var GHOST_SOURCE = String.raw`https://github\.com/TryGhost/Ghost`;
4
+ var SEMVER = String.raw`(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`;
5
+ var GHOST_USER_AGENT = new RegExp(
6
+ `^Ghost(?:\\(${GHOST_SOURCE}\\)|/${SEMVER} \\(${GHOST_SOURCE}\\))$`,
7
+ "u"
8
+ );
9
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
10
+ var isGhostUserAgent = (value) => value !== null && GHOST_USER_AGENT.test(value);
11
+ var textResponse = (body, status = 200) => new Response(body, { status, headers: TEXT_HEADERS });
12
+ var invalidBodyResponse = () => textResponse("Invalid request body", 400);
13
+ var noValidBodyResponse = () => textResponse("No valid request body detected");
14
+ var authorizeRequest = (request) => {
15
+ const keys = new URL(request.url).searchParams.getAll("key");
16
+ if (keys.length > 1 || keys[0] && keys[0] !== process.env.NETLIFY_KEY) {
17
+ return textResponse("Unauthorized", 401);
18
+ }
19
+ return void 0;
20
+ };
21
+ var algoliaSettings = () => ({
22
+ appId: process.env.ALGOLIA_APP_ID,
23
+ apiKey: process.env.ALGOLIA_API_KEY,
24
+ index: process.env.ALGOLIA_INDEX
25
+ });
26
+ var parseWebhookBody = async (request) => {
27
+ try {
28
+ const body = await request.json();
29
+ if (!isRecord(body) || !isRecord(body.post)) {
30
+ return void 0;
31
+ }
32
+ return { post: body.post };
33
+ } catch {
34
+ return void 0;
35
+ }
36
+ };
37
+
38
+ // functions/utils/algolia.mts
39
+ import IndexFactory from "@tryghost/algolia-indexer";
40
+ import transforms from "@tryghost/algolia-fragmenter";
41
+ import "algolia-html-extractor";
42
+ import "algoliasearch";
43
+
44
+ // functions/post-unpublished.mts
45
+ async function postUnpublished(request) {
46
+ const authorizationResponse = authorizeRequest(request);
47
+ if (authorizationResponse) {
48
+ return authorizationResponse;
49
+ }
50
+ if (process.env.ALGOLIA_ACTIVE !== "TRUE") {
51
+ return textResponse("Algolia is not activated");
52
+ }
53
+ if (!isGhostUserAgent(request.headers.get("user-agent"))) {
54
+ return textResponse("Unauthorized", 401);
55
+ }
56
+ const payload = await parseWebhookBody(request);
57
+ if (!payload) {
58
+ return invalidBodyResponse();
59
+ }
60
+ const { current } = payload.post;
61
+ const { previous } = payload.post;
62
+ const post = isRecord(current) && Object.keys(current).length > 0 ? current : previous;
63
+ const slug = isRecord(post) && typeof post.slug === "string" ? post.slug : "";
64
+ if (!slug) {
65
+ return noValidBodyResponse();
66
+ }
67
+ try {
68
+ const index = new IndexFactory(algoliaSettings());
69
+ await index.initIndex();
70
+ await index.delete(slug);
71
+ console.log(`Fragments for slug "${slug}" successfully removed from Algolia index`);
72
+ return textResponse(`Post "${slug}" has been removed from the index.`);
73
+ } catch (error) {
74
+ console.log(error);
75
+ const message = error instanceof Error ? error.message : String(error);
76
+ return Response.json({ msg: message }, { status: 500 });
77
+ }
78
+ }
79
+ export {
80
+ postUnpublished as default
81
+ };
@@ -0,0 +1,2 @@
1
+ export { default as postPublished } from './functions/post-published.mjs';
2
+ export { default as postUnpublished } from './functions/post-unpublished.mjs';
package/dist/index.mjs ADDED
@@ -0,0 +1,6 @@
1
+ import { default as default2 } from "./functions/post-published.mjs";
2
+ import { default as default3 } from "./functions/post-unpublished.mjs";
3
+ export {
4
+ default2 as postPublished,
5
+ default3 as postUnpublished
6
+ };
package/package.json CHANGED
@@ -1,50 +1,58 @@
1
1
  {
2
2
  "name": "@tryghost/algolia-netlify",
3
- "version": "0.3.4",
4
- "repository": "https://github.com/TryGhost/algolia/tree/master/packages/algolia-netlify",
3
+ "version": "0.4.1",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/TryGhost/algolia.git",
7
+ "directory": "packages/algolia-netlify"
8
+ },
5
9
  "author": "Ghost Foundation",
6
10
  "license": "MIT",
7
- "main": "index.js",
8
- "scripts": {
9
- "dev": "yarn build && netlify dev",
10
- "test": "NODE_ENV=testing mocha './test/**/*.test.js'",
11
- "lint": "eslint . --ext .js --cache",
12
- "posttest": "yarn lint",
13
- "build": "NODE_ENV=production netlify functions:build --functions build/functions --src functions"
11
+ "engines": {
12
+ "node": ">=24"
13
+ },
14
+ "type": "module",
15
+ "main": "./dist/index.mjs",
16
+ "types": "./dist/index.d.mts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.mts",
20
+ "import": "./dist/index.mjs"
21
+ },
22
+ "./post-published": {
23
+ "types": "./dist/functions/post-published.d.mts",
24
+ "import": "./dist/functions/post-published.mjs"
25
+ },
26
+ "./post-unpublished": {
27
+ "types": "./dist/functions/post-unpublished.d.mts",
28
+ "import": "./dist/functions/post-unpublished.mjs"
29
+ }
14
30
  },
15
31
  "files": [
16
- "index.js",
17
- "lib"
32
+ "dist"
18
33
  ],
19
34
  "publishConfig": {
20
35
  "access": "public"
21
36
  },
22
- "eslintConfig": {
23
- "extends": [
24
- "airbnb-base",
25
- "plugin:ghost/browser"
26
- ],
27
- "plugins": [
28
- "ghost"
29
- ]
30
- },
31
- "eslintIgnore": [
32
- "webpack.functions.js",
33
- "build/*"
34
- ],
35
37
  "devDependencies": {
36
- "eslint": "8.52.0",
37
- "eslint-config-airbnb-base": "15.0.0",
38
- "eslint-plugin-ghost": "3.3.2",
39
- "eslint-plugin-import": "2.28.1",
40
- "mocha": "10.2.0",
41
- "netlify-cli": "16.3.1",
42
- "should": "13.2.3",
43
- "sinon": "17.0.0"
38
+ "@types/node": "24.13.3",
39
+ "esbuild": "0.28.2",
40
+ "netlify-cli": "27.1.1",
41
+ "typescript": "7.0.2"
44
42
  },
45
43
  "dependencies": {
46
- "@tryghost/algolia-fragmenter": "^0.2.7",
47
- "@tryghost/algolia-indexer": "^0.3.1"
44
+ "algolia-html-extractor": "0.0.1",
45
+ "algoliasearch": "5.56.0",
46
+ "@tryghost/algolia-fragmenter": "^0.2.9",
47
+ "@tryghost/algolia-indexer": "^0.3.3"
48
48
  },
49
- "gitHead": "0f5dd82ecdabe8ae3d7153c7476472b08ce8732a"
50
- }
49
+ "scripts": {
50
+ "dev": "pnpm --dir ../.. exec netlify dev --filter @tryghost/algolia-netlify",
51
+ "test": "NODE_ENV=testing vitest run --root .",
52
+ "typecheck": "tsc --project tsconfig.functions.json && tsc --project tsconfig.test.json",
53
+ "lint": "oxlint --quiet . && oxfmt --check .",
54
+ "posttest": "pnpm typecheck && pnpm lint",
55
+ "build": "NODE_ENV=production pnpm --dir ../.. exec netlify build --offline --filter @tryghost/algolia-netlify",
56
+ "build:package": "node scripts/build-package.mjs && tsc --project tsconfig.package.json && node scripts/clean-package.mjs"
57
+ }
58
+ }