Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
**/node_modules
**/.next
**/.env*
**/*.tsbuildinfo
.git
designer-extension
105 changes: 105 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
name: Webflow app

on:
pull_request:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
verify:
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: |
data-client/package-lock.json
designer-extension/package-lock.json
- run: npm ci --prefix data-client && npm ci --prefix designer-extension
- run: npm run lint --prefix data-client && npm test --prefix data-client
- run: npm run lint --prefix designer-extension && npm run build:prod --prefix designer-extension
- uses: actions/upload-artifact@v4
with:
name: webflow-extension
path: designer-extension/bundle.zip
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
load: true
tags: webflow-app:smoke
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Container smoke checks
shell: bash
run: |
set -euo pipefail
trap 'docker rm -f webflow-smoke-backend webflow-smoke-redis >/dev/null 2>&1; docker network rm webflow-smoke >/dev/null 2>&1' EXIT
docker network create webflow-smoke
docker run -d --name webflow-smoke-redis --network webflow-smoke redis:7.4.8 redis-server --appendonly yes
for attempt in {1..30}; do
if docker exec webflow-smoke-redis redis-cli ping | grep -q PONG; then break; fi
sleep 1
done
docker run -d --name webflow-smoke-backend --network webflow-smoke --read-only --tmpfs /tmp -p 127.0.0.1:3039:3000 \
-e REDIS_URL=redis://webflow-smoke-redis:6379 \
-e WEBFLOW_CLIENT_ID=smoke-test-client \
-e WEBFLOW_CLIENT_SECRET=smoke-test-secret \
-e APP_BASE_URL=http://127.0.0.1:3039 \
-e DESIGNER_EXTENSION_URI=https://smoke-test.webflow-ext.com webflow-app:smoke
for attempt in {1..30}; do
if curl --fail --silent http://127.0.0.1:3039/api/health >/dev/null; then break; fi
sleep 1
done
node scripts/smoke.mjs
docker stop webflow-smoke-redis
node scripts/smoke.mjs --unhealthy

publish:
needs: verify
if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v4
- uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/simpleanalytics/webflow-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
needs: publish
uses: simpleanalytics/github-actions/.github/workflows/deploy.yml@main
secrets: inherit
with:
app: webflow_backend
hosts: esapp04
strategy: replace
image_repo: ghcr.io/simpleanalytics/webflow-app
image_tag: ${{ github.sha }}
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM node:24-slim AS build
ENV NEXT_TELEMETRY_DISABLED=1
WORKDIR /app
COPY data-client/package.json data-client/package-lock.json ./
RUN npm ci
COPY data-client/ ./
RUN npm run build

FROM node:24-slim AS production
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=3000
WORKDIR /app
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
COPY --from=build --chown=node:node /app/public ./public
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 CMD node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]
131 changes: 131 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Simple Analytics Webflow App

A Webflow Hybrid App that lets site owners add and configure
[Simple Analytics](https://www.simpleanalytics.com/) from the Webflow Designer.
The Designer Extension provides the interface; the Next.js data client handles
OAuth and manages the app's custom code through the Webflow Data API.

## Architecture

```text
designer-extension/ Vite + React extension shown in Webflow Designer
src/App.tsx Authentication gate and configuration panel
src/hooks/useAuth.ts Designer ID-token exchange
src/services/api.ts Calls to the data-client API

data-client/ Next.js API and OAuth service
app/api/auth/ OAuth, session, and disconnect routes
app/api/scripts/ Register, apply, inspect, and remove app scripts
app/lib/utils/database.ts Encrypted OAuth-token storage in Redis
```

## Prerequisites

- Node.js 24 or newer
- Redis 7
- A Webflow site and workspace
- A registered Webflow App

## Local setup

1. Clone this repository and install the locked dependencies:

```bash
git clone https://github.com/simpleanalytics/webflow-app.git
cd webflow-app
npm ci
npm run install:all
```

2. Start a local Redis instance. This example exposes Redis only on localhost:

```bash
docker run --rm --name webflow-app-redis -p 127.0.0.1:6379:6379 redis:7.4.8
```

3. Register a Webflow App and configure this redirect URI:

```text
http://localhost:3001/api/auth/callback
```

Enable these scopes:

- `authorized_user:read`
- `sites:read`
- `custom_code:read`
- `custom_code:write`

4. Create the backend environment file:

```bash
cp data-client/.env.example data-client/.env
```

Set the Webflow client ID and secret. The checked-in defaults point the
backend at local Redis and the local Designer Extension.

5. Start both applications:

```bash
npm run dev
```

- Data client: `http://localhost:3001`
- Designer Extension: `http://localhost:1337`

6. Open `http://localhost:3001` to authorize the app, then launch the
development app from the Webflow Designer Apps panel.

## Environment variables

| Variable | Required | Description |
|---|---|---|
| `WEBFLOW_CLIENT_ID` | Yes | OAuth client ID from Webflow |
| `WEBFLOW_CLIENT_SECRET` | Yes | OAuth client secret; also derives the at-rest encryption key |
| `REDIS_URL` | Yes | Redis connection URL |
| `APP_BASE_URL` | Yes | Public origin of the data client; its OAuth callback must match Webflow exactly |
| `DESIGNER_EXTENSION_URI` | Yes | Exact Designer Extension origin allowed by CORS and `postMessage` |
| `PORT` | No | Local data-client port; defaults to `3001` |
| `NGROK_AUTH_TOKEN` | No | Development tunnel credential |

Keep `.env` files out of version control; they contain credentials.

## Script ownership and uninstalling

Webflow only exposes custom code owned by this app. It cannot detect a Simple
Analytics script added manually in Site settings or by another app. Remove any
existing manual installation before using this app to avoid duplicate pageviews.

Before uninstalling the Webflow App, choose **Disconnect Webflow** in the
extension and confirm the action. This removes the app-owned site script,
deletes the stored site authorization, and revokes the Webflow grant when its
last connected site is removed. Publish the Webflow site afterward so script
removal takes effect.

## Validation

```bash
npm test --prefix data-client
npm run lint --prefix data-client
npm run lint --prefix designer-extension
npm run build
```

## Security

- OAuth tokens are encrypted with AES-256-GCM before Redis persistence.
- Designer sessions are short-lived, site-bound JWTs.
- OAuth callbacks use a one-time state value and the same configured redirect
URI for authorization and token exchange.
- API CORS is restricted to `DESIGNER_EXTENSION_URI`.
- Disconnect removes app-owned custom code and stored authorization data.

## Tech stack

- Next.js, Redis, and the Webflow Data API
- React, Vite, and the Webflow Designer API

## License

MIT
9 changes: 9 additions & 0 deletions data-client/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
WEBFLOW_CLIENT_ID=
WEBFLOW_CLIENT_SECRET=
REDIS_URL=redis://127.0.0.1:6379
APP_BASE_URL=http://localhost:3001
DESIGNER_EXTENSION_URI=http://localhost:1337
PORT=3001

# Development tunnel only
NGROK_AUTH_TOKEN=
43 changes: 43 additions & 0 deletions data-client/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# env files (can opt-in for committing if needed)
.env

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts

# database
.db
36 changes: 36 additions & 0 deletions data-client/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).

## Getting Started

First, run the development server:

```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```

Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.

You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.

This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.

## Learn More

To learn more about Next.js, take a look at the following resources:

- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.

You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!

## Deploy on Vercel

The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.

Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
29 changes: 29 additions & 0 deletions data-client/app/api/auth/authorize/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { randomBytes } from "node:crypto";
import { WebflowClient } from "webflow-api";
import { NextRequest, NextResponse } from "next/server";
import { getRedis } from "../../../lib/utils/database";
import { appOrigin, requiredEnv } from "../../../lib/utils/config";

export const dynamic = "force-dynamic";

export async function GET(request: NextRequest) {
try {
const state = randomBytes(32).toString("hex");
const popup = request.nextUrl.searchParams.get("popup") === "true";
await (await getRedis()).set(`oauth:${state}`, JSON.stringify({ popup }), { EX: 600 });
const url = WebflowClient.authorizeURL({
clientId: requiredEnv("WEBFLOW_CLIENT_ID"),
redirectUri: `${appOrigin()}/api/auth/callback`,
scope: ["sites:read", "custom_code:read", "custom_code:write", "authorized_user:read"],
state,
});
const response = NextResponse.redirect(url);
response.cookies.set("webflow_oauth_state", state, {
httpOnly: true, secure: appOrigin().startsWith("https:"), sameSite: "lax",
path: "/api/auth", maxAge: 600,
});
return response;
} catch {
return NextResponse.json({ error: "Authorization is temporarily unavailable" }, { status: 503 });
}
}
Loading
Loading