Compare commits
269 Commits
feature/re
...
8ca774ad1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
8ca774ad1c
|
|||
|
53c989462a
|
|||
|
58414f1035
|
|||
|
31fbe0a809
|
|||
|
1b23fee108
|
|||
|
3043a9cdd1
|
|||
|
655ea1f87e
|
|||
|
fcb4cf5fb0
|
|||
|
9b197abcfa
|
|||
|
|
a1ce71ffb6 | ||
|
|
20665789e1 | ||
|
|
2fb993fae5 | ||
|
|
b467a6ce7f | ||
|
|
b88b2844b3 | ||
|
|
ddc1b75b58 | ||
|
|
7239c59342 | ||
|
|
a82069c28c | ||
|
|
bca07ae0d7 | ||
|
|
21e51daa5f | ||
|
|
729722bf85 | ||
|
|
a8481a213f | ||
|
|
6287cb7958 | ||
|
|
ebc07e3a16 | ||
|
|
11e9ecac1a | ||
|
|
88a2d876ce | ||
|
|
d1b39c4c93 | ||
|
|
33431510b4 | ||
|
|
5557db83a6 | ||
|
|
eab33d3127 | ||
|
|
4483e464d1 | ||
|
|
4736f8509d | ||
|
|
05cf6bb39f | ||
|
|
6e1daf2c76 | ||
|
|
f2aa0273e6 | ||
|
|
1b898660ad | ||
|
|
9836f75e17 | ||
|
|
271d189ed0 | ||
|
|
70ca44f039 | ||
|
|
00f6cd6f50 | ||
|
|
227d629dc5 | ||
|
|
f2e19093f0 | ||
|
|
7f85b2ac0a | ||
|
|
38965387da | ||
|
|
74bcb7ead2 | ||
|
|
2377f95b86 | ||
|
|
de6ca96628 | ||
|
|
9e46099246 | ||
|
|
83761638f2 | ||
|
|
885f7225db | ||
|
|
553e4cf675 | ||
|
|
f2c414b4b4 | ||
|
|
043730444a | ||
|
|
8c377c2066 | ||
|
|
647ac2a4af | ||
|
|
6251d143d1 | ||
|
|
b801d6a8ef | ||
|
|
1272466235 | ||
|
|
2501ee1eef | ||
|
|
10da7d3a1d | ||
|
|
b0aa7f4196 | ||
|
|
f4602f8e56 | ||
|
|
efb50fafdb | ||
|
|
cd112237e9 | ||
|
|
9c6c7bb037 | ||
|
|
928c44ef6a | ||
|
|
a42adcdbfb | ||
|
|
8b18b86deb | ||
|
|
8db5905fb5 | ||
|
|
4b150dd987 | ||
|
|
baedf4343b | ||
|
|
22fb4acf12 | ||
|
|
cb3f8016df | ||
|
|
d9afeffbf1 | ||
|
|
4b9852b36f | ||
|
|
00e94ecb66 | ||
|
|
3d84e4e77b | ||
|
|
791668526c | ||
|
|
02ddcf9a3d | ||
|
|
55bd5c4614 | ||
|
|
aa81bbfe77 | ||
|
|
38d9b65ec8 | ||
|
|
f311146ade | ||
|
|
6dca57d7ce | ||
|
|
a4cb410d3e | ||
|
|
c69ff7053a | ||
|
|
ee27568824 | ||
|
|
7e2d93db45 | ||
|
|
03c18b37ec | ||
|
|
b81a2e0de6 | ||
|
|
d7e6e737c9 | ||
|
|
1281cfa7b3 | ||
|
|
81b85e09b8 | ||
|
|
5a0769c917 | ||
|
|
fef4941e06 | ||
|
|
cdc286b3fd | ||
|
|
fbb2606130 | ||
|
|
bef3eb2e90 | ||
|
|
ab59c12721 | ||
|
|
e3faab7588 | ||
|
|
0ebe2768be | ||
|
|
b7eafb0f37 | ||
|
|
edadda2c45 | ||
|
|
b6b716f1a1 | ||
|
|
d54a33b5c2 | ||
|
|
4f4b4a8d88 | ||
|
|
2f95c074c5 | ||
|
|
860223f22e | ||
|
|
6c55f7a759 | ||
|
|
5d802c40df | ||
|
|
3fc45acbd2 | ||
|
|
22fa473706 | ||
|
|
9d6087c32a | ||
|
|
1ccc862e9b | ||
|
|
b3afaf3d5f | ||
|
|
68b12b36cc | ||
|
|
2389b351e4 | ||
|
|
f1f932c58b | ||
|
|
c8d78e31a1 | ||
|
|
9f441fd9fa | ||
|
|
40a3178b57 | ||
|
|
e129a53ce5 | ||
|
|
6ce9b5dd1b | ||
|
|
ed8b5c667e | ||
|
|
fc3b6fb891 | ||
|
|
40b0774ef8 | ||
|
|
b0d531d793 | ||
|
|
063e80a659 | ||
|
|
c4d76d0a57 | ||
|
|
21792bfbfa | ||
|
|
19a48e86c1 | ||
|
|
c978904815 | ||
|
|
7a88b262c0 | ||
|
|
2d0478d626 | ||
|
|
ef8c2a4eee | ||
|
|
02897e11cb | ||
|
|
05ccbc372c | ||
|
|
f1c85c53cf | ||
|
|
18600aa5ab | ||
|
|
2c7edec274 | ||
|
|
b8469f8431 | ||
|
|
f6b2c6099f | ||
|
|
1cde7a3e35 | ||
|
|
64bcd5c155 | ||
|
|
d330053874 | ||
|
|
db62919825 | ||
|
|
286f8e160b | ||
|
|
f8f470adf9 | ||
|
|
e7c2834ea0 | ||
|
|
753d6dce4c | ||
|
|
9e5b482447 | ||
|
|
32ea28b2f6 | ||
|
|
b39d076b32 | ||
|
|
ec5937e55c | ||
|
|
f83fe7a0fc | ||
|
|
6c56efdf37 | ||
|
|
e5be28a49d | ||
|
|
e645c094b2 | ||
|
|
67301d928c | ||
|
|
deb3c3d20c | ||
|
|
6e997e62f1 | ||
|
|
2c5ca8adec | ||
|
|
3e573ae27f | ||
|
|
5b29f7502c | ||
|
|
d32a279949 | ||
|
|
ed6e5cd334 | ||
|
|
cf1bf95388 | ||
|
|
5830277ba9 | ||
|
|
aa13c87e87 | ||
|
|
83c3647f66 | ||
|
|
927613c09d | ||
|
|
24ee6b0b6c | ||
|
|
13d8b92cf3 | ||
|
|
4b2db351c4 | ||
|
|
334adec9f2 | ||
|
|
9a54daae55 | ||
|
|
7cd5f84c58 | ||
|
|
470ddbe8e7 | ||
|
|
c63578b35b | ||
|
|
b5792df69f | ||
|
|
00f2e2937d | ||
|
|
0d1773eb74 | ||
|
|
ed1c57dbb8 | ||
|
|
39251c8598 | ||
|
|
9a4aa51975 | ||
|
|
f008fb58e5 | ||
|
|
cabfb1f3f0 | ||
|
|
4867260ece | ||
|
|
87c919f700 | ||
|
|
3c085e445d | ||
|
|
6d9e3ce8e5 | ||
|
|
f187065d75 | ||
|
|
d5e4518e32 | ||
|
|
1f088d2208 | ||
|
|
3bd1f99d28 | ||
|
|
9a916f3171 | ||
|
|
34cb186ead | ||
|
|
5f38560373 | ||
|
|
1e4f02fb5e | ||
|
|
3158ebfbda | ||
|
|
d7c6e88adc | ||
|
|
3b61b28290 | ||
|
|
8dfeaa870c | ||
|
|
329f76b7ce | ||
|
|
3b74d8ae36 | ||
|
|
a2a53cf9f7 | ||
|
|
4e7dc16619 | ||
|
|
0f9ac4508a | ||
|
|
c46cda12eb | ||
|
|
546ef6673f | ||
|
|
3b2ed3afb1 | ||
|
|
95846f80e5 | ||
|
|
1f5c648afe | ||
|
|
3d8a3e8997 | ||
|
|
28692d82ae | ||
|
|
4bdbb31180 | ||
|
|
be248717d2 | ||
|
|
56bd1197a6 | ||
|
|
9ccca322e5 | ||
|
|
86a3da869b | ||
|
|
ae383001bc | ||
|
|
9bedd39e48 | ||
|
|
7131e3f461 | ||
|
|
9665a2593f | ||
|
|
c201bea682 | ||
|
|
8312556b38 | ||
|
|
d2b22867b9 | ||
|
|
2b8bcf1ed7 | ||
|
|
e22a5b3237 | ||
|
|
315e4a59a3 | ||
|
|
969c0bc8fe | ||
|
|
4e42689115 | ||
|
|
d3522c51f8 | ||
|
|
abf5353ab3 | ||
|
|
2dda50fc7c | ||
|
|
cbdb3a62c1 | ||
|
|
4b775ff2c5 | ||
|
|
a37e37c28b | ||
|
|
64afd04f7b | ||
|
|
2468dc29ff | ||
|
|
662975dc08 | ||
|
|
0a1564c798 | ||
|
|
56b01ca6d8 | ||
|
|
e5b9865850 | ||
|
|
5576519a2a | ||
|
|
1f74ab99ae | ||
|
|
3ae7d1322e | ||
|
|
e4b919c4da | ||
|
|
50ef4c0d94 | ||
|
|
a7a357eb0f | ||
|
|
7adc2903d2 | ||
|
|
ac4429d6d9 | ||
|
|
e2536774b0 | ||
|
|
a39796a829 | ||
|
|
8b31e4cfba | ||
|
|
ae09748e4e | ||
|
|
4ef0b1afe2 | ||
|
|
620904b4d4 | ||
|
|
d4e3470f7e | ||
|
|
d154e12d66 | ||
|
|
b421474616 | ||
|
|
828c8c4f91 | ||
|
|
6da8267509 | ||
|
|
86903b1937 | ||
|
|
4f9d66693e | ||
|
|
32415d31d9 | ||
|
|
f7055c0ebd | ||
|
|
c9cf0274af | ||
|
|
44184236a8 | ||
|
|
71270b3493 |
170
.claude/CLAUDE.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# CLAUDE.md
|
||||
|
||||
NEVER CALL FORMAT! WE'LL FORMAT IN THE FUTURE WHEN WE HAVE MERGED ALL BIG PRS!
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Openpanel is an open-source web/product analytics platform (Mixpanel alternative). It's a **pnpm monorepo** with apps, packages, tooling, and SDKs.
|
||||
|
||||
## Common Commands
|
||||
|
||||
```bash
|
||||
# Development
|
||||
pnpm dev # Run all services (api, worker, dashboard) in parallel
|
||||
pnpm dev:public # Run public/docs site only
|
||||
pnpm dock:up / dock:down # Start/stop Docker (PostgreSQL, Redis, ClickHouse)
|
||||
|
||||
# Code quality
|
||||
pnpm check # Lint check (Biome via Ultracite)
|
||||
pnpm fix # Auto-fix lint/format issues
|
||||
pnpm typecheck # Typecheck all packages
|
||||
|
||||
# Testing
|
||||
pnpm test # Run all tests (vitest)
|
||||
pnpm vitest run <path> # Run a single test file
|
||||
# Workspace: packages/* and apps/* (excluding apps/start)
|
||||
|
||||
# Database
|
||||
pnpm codegen # Generate Prisma types + geo data
|
||||
pnpm migrate # Run Prisma migrations (dev)
|
||||
pnpm migrate:deploy # Deploy migrations (production - never run this)
|
||||
|
||||
# Docker utilities
|
||||
pnpm dock:ch # ClickHouse CLI
|
||||
pnpm dock:redis # Redis CLI
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Apps
|
||||
|
||||
| App | Stack | Port | Purpose |
|
||||
|-----|-------|------|---------|
|
||||
| `apps/api` | Fastify + tRPC | 3333 | REST/RPC API server |
|
||||
| `apps/start` | TanStack Start (Vite + React 19) | 3000 | Dashboard SPA |
|
||||
| `apps/public` | Next.js 16 + Fumadocs | - | Marketing/docs site |
|
||||
| `apps/worker` | Express + BullMQ | 9999 | Background job processor |
|
||||
|
||||
### Key Packages
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `packages/db` | Prisma ORM (PostgreSQL) + ClickHouse client |
|
||||
| `packages/trpc` | tRPC router definitions, context, middleware |
|
||||
| `packages/auth` | Authentication (Arctic OAuth, Oslo sessions, argon2) |
|
||||
| `packages/queue` | BullMQ + GroupMQ job queue definitions |
|
||||
| `packages/redis` | Redis client + LRU caching |
|
||||
| `packages/validation` | Zod schemas shared across apps |
|
||||
| `packages/common` | Shared utilities (date-fns, ua-parser, nanoid) |
|
||||
| `packages/email` | React Email templates via Resend |
|
||||
| `packages/sdks/*` | Client SDKs (web, react, next, express, react-native, etc.) |
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Event ingestion**: Client SDKs → `apps/api` (track routes) → Redis queue
|
||||
2. **Processing**: `apps/worker` picks up jobs from BullMQ, batches events into ClickHouse
|
||||
3. **Dashboard queries**: `apps/start` → tRPC → `apps/api` → ClickHouse (analytics) / PostgreSQL (config)
|
||||
4. **Real-time**: WebSocket via Fastify, pub/sub via Redis
|
||||
|
||||
### Three-Database Strategy
|
||||
|
||||
- **PostgreSQL**: Relational data (users, orgs, projects, dashboards). Managed by Prisma.
|
||||
- **ClickHouse**: Analytics event storage (OLAP). High-volume reads/writes.
|
||||
- **Redis**: Caching, job queues (BullMQ), rate limiting, pub/sub.
|
||||
|
||||
### Dashboard (apps/start)
|
||||
|
||||
Uses TanStack Router with file-based routing (`src/routes/`). State management via Redux Toolkit. UI built on Radix primitives + Tailwind v4. Charts via Recharts. Modals in `src/modals/`.
|
||||
|
||||
### API (apps/api)
|
||||
|
||||
Fastify server with tRPC integration. Route files in `src/routes/`. Hooks for IP extraction, request logging, timestamps. Built with `tsdown`.
|
||||
---
|
||||
|
||||
## Core Principles
|
||||
|
||||
Write code that is **accessible, performant, type-safe, and maintainable**. Focus on clarity and explicit intent over brevity.
|
||||
|
||||
### Type Safety & Explicitness
|
||||
|
||||
- Use explicit types for function parameters and return values when they enhance clarity
|
||||
- Prefer `unknown` over `any` when the type is genuinely unknown
|
||||
- Use const assertions (`as const`) for immutable values and literal types
|
||||
- Leverage TypeScript's type narrowing instead of type assertions
|
||||
- Use meaningful variable names instead of magic numbers - extract constants with descriptive names
|
||||
|
||||
### Modern JavaScript/TypeScript
|
||||
|
||||
- Use arrow functions for callbacks and short functions
|
||||
- Prefer `for...of` loops over `.forEach()` and indexed `for` loops
|
||||
- Use optional chaining (`?.`) and nullish coalescing (`??`) for safer property access
|
||||
- Prefer template literals over string concatenation
|
||||
- Use destructuring for object and array assignments
|
||||
- Use `const` by default, `let` only when reassignment is needed, never `var`
|
||||
|
||||
### Async & Promises
|
||||
|
||||
- Always `await` promises in async functions - don't forget to use the return value
|
||||
- Use `async/await` syntax instead of promise chains for better readability
|
||||
- Handle errors appropriately in async code with try-catch blocks
|
||||
- Don't use async functions as Promise executors
|
||||
|
||||
### React & JSX
|
||||
|
||||
- Use function components over class components
|
||||
- Call hooks at the top level only, never conditionally
|
||||
- Specify all dependencies in hook dependency arrays correctly
|
||||
- Use the `key` prop for elements in iterables (prefer unique IDs over array indices)
|
||||
- Nest children between opening and closing tags instead of passing as props
|
||||
- Don't define components inside other components
|
||||
- Use semantic HTML and ARIA attributes for accessibility:
|
||||
- Provide meaningful alt text for images
|
||||
- Use proper heading hierarchy
|
||||
- Add labels for form inputs
|
||||
- Include keyboard event handlers alongside mouse events
|
||||
- Use semantic elements (`<button>`, `<nav>`, etc.) instead of divs with roles
|
||||
|
||||
### Error Handling & Debugging
|
||||
|
||||
- Remove `console.log`, `debugger`, and `alert` statements from production code
|
||||
- Throw `Error` objects with descriptive messages, not strings or other values
|
||||
- Use `try-catch` blocks meaningfully - don't catch errors just to rethrow them
|
||||
- Prefer early returns over nested conditionals for error cases
|
||||
|
||||
### Code Organization
|
||||
|
||||
- Keep functions focused and under reasonable cognitive complexity limits
|
||||
- Extract complex conditions into well-named boolean variables
|
||||
- Use early returns to reduce nesting
|
||||
- Prefer simple conditionals over nested ternary operators
|
||||
- Group related code together and separate concerns
|
||||
|
||||
### Security
|
||||
|
||||
- Add `rel="noopener"` when using `target="_blank"` on links
|
||||
- Avoid `dangerouslySetInnerHTML` unless absolutely necessary
|
||||
- Don't use `eval()` or assign directly to `document.cookie`
|
||||
- Validate and sanitize user input
|
||||
|
||||
### Performance
|
||||
|
||||
- Avoid spread syntax in accumulators within loops
|
||||
- Use top-level regex literals instead of creating them in loops
|
||||
- Prefer specific imports over namespace imports
|
||||
- Avoid barrel files (index files that re-export everything)
|
||||
- Use proper image components (e.g., Next.js `<Image>`) over `<img>` tags
|
||||
|
||||
### Framework-Specific Guidance
|
||||
|
||||
**Next.js:**
|
||||
- Use Next.js `<Image>` component for images
|
||||
- Use `next/head` or App Router metadata API for head elements
|
||||
- Use Server Components for async data fetching instead of async Client Components
|
||||
|
||||
**React 19+:**
|
||||
- Use ref as a prop instead of `React.forwardRef`
|
||||
|
||||
**Solid/Svelte/Vue/Qwik:**
|
||||
- Use `class` and `for` attributes (not `className` or `htmlFor`)
|
||||
55
.gitea/workflows/docker-build-api.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build and Push API
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.zias.be
|
||||
OWNER: zias
|
||||
|
||||
jobs:
|
||||
build-api:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/api/Dockerfile
|
||||
target: runner
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-api:buildcache
|
||||
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-api:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||
build-args: |-
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
53
.gitea/workflows/docker-build-dashboard.yml
Normal file
@@ -0,0 +1,53 @@
|
||||
name: Build and Push Dashboard
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.zias.be
|
||||
OWNER: zias
|
||||
|
||||
jobs:
|
||||
build-dashboard:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/start/Dockerfile
|
||||
target: runner
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-dashboard:buildcache
|
||||
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-dashboard:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||
55
.gitea/workflows/docker-build-worker.yml
Normal file
@@ -0,0 +1,55 @@
|
||||
name: Build and Push Worker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["*"]
|
||||
tags: ["v*"]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: git.zias.be
|
||||
OWNER: zias
|
||||
|
||||
jobs:
|
||||
build-worker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ env.OWNER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: apps/worker/Dockerfile
|
||||
target: runner
|
||||
platforms: linux/amd64
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
provenance: false
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.OWNER }}/openpanel-worker:buildcache
|
||||
cache-to: ${{ github.event_name != 'pull_request' && format('type=registry,ref={0}/{1}/openpanel-worker:buildcache,mode=max,image-manifest=true,oci-mediatypes=true', env.REGISTRY, env.OWNER) || '' }}
|
||||
build-args: |-
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
74
.github/workflows/docker-build.yml
vendored
@@ -3,54 +3,37 @@ name: Docker Build and Push
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# branches: [ "main" ]
|
||||
paths:
|
||||
- "apps/api/**"
|
||||
- "apps/worker/**"
|
||||
paths-ignore:
|
||||
# README and docs
|
||||
- "**/README*"
|
||||
- "**/readme*"
|
||||
- "**/*.md"
|
||||
- "**/docs/**"
|
||||
- "**/CHANGELOG*"
|
||||
- "**/LICENSE*"
|
||||
# Test files
|
||||
- "**/*.test.*"
|
||||
- "**/*.spec.*"
|
||||
- "**/__tests__/**"
|
||||
- "**/tests/**"
|
||||
# SDKs (published separately)
|
||||
- "packages/sdks/**"
|
||||
# Public app (docs/marketing, not part of Docker deploy)
|
||||
- "apps/public/**"
|
||||
- "packages/**"
|
||||
- "!packages/sdks/**"
|
||||
- "**Dockerfile"
|
||||
- ".github/workflows/**"
|
||||
# Dev / tooling
|
||||
- "**/.vscode/**"
|
||||
- "**/.cursor/**"
|
||||
- "**/.env.example"
|
||||
- "**/.env.*.example"
|
||||
- "**/.gitignore"
|
||||
- "**/.eslintignore"
|
||||
- "**/.prettierignore"
|
||||
|
||||
env:
|
||||
repo_owner: "openpanel-dev"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
api: ${{ steps.filter.outputs.api }}
|
||||
worker: ${{ steps.filter.outputs.worker }}
|
||||
public: ${{ steps.filter.outputs.public }}
|
||||
dashboard: ${{ steps.filter.outputs.dashboard }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dorny/paths-filter@v2
|
||||
id: filter
|
||||
with:
|
||||
base: "main"
|
||||
filters: |
|
||||
api:
|
||||
- 'apps/api/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
worker:
|
||||
- 'apps/worker/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
public:
|
||||
- 'apps/public/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
dashboard:
|
||||
- 'apps/start/**'
|
||||
- 'packages/**'
|
||||
- '.github/workflows/**'
|
||||
|
||||
lint-and-test:
|
||||
needs: changes
|
||||
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.worker == 'true' || needs.changes.outputs.public == 'true' || needs.changes.outputs.dashboard == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
redis:
|
||||
@@ -105,8 +88,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
needs: [changes, lint-and-test]
|
||||
if: ${{ needs.changes.outputs.api == 'true' }}
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -166,8 +148,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
needs: [changes, lint-and-test]
|
||||
if: ${{ needs.changes.outputs.worker == 'true' }}
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -227,8 +208,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
contents: write
|
||||
needs: [changes, lint-and-test]
|
||||
if: ${{ needs.changes.outputs.dashboard == 'true' }}
|
||||
needs: lint-and-test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
.secrets
|
||||
packages/db/src/generated/prisma
|
||||
packages/db/code-migrations/*.sql
|
||||
**/.open-next
|
||||
|
||||
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
|
||||
packages/sdk/profileId.txt
|
||||
|
||||
2
.vscode/settings.json
vendored
@@ -17,7 +17,7 @@
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
"editor.defaultFormatter": "vscode.json-language-features"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
|
||||
50
.zed/settings.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"formatter": "language_server",
|
||||
"format_on_save": "on",
|
||||
"lsp": {
|
||||
"typescript-language-server": {
|
||||
"settings": {
|
||||
"typescript": {
|
||||
"preferences": {
|
||||
"includePackageJsonAutoImports": "on"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"JavaScript": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
}
|
||||
},
|
||||
"TypeScript": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
}
|
||||
},
|
||||
"TSX": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
README.md
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<p align="center">
|
||||
<h1 align="center"><b>Openpanel</b></h1>
|
||||
@@ -28,6 +28,7 @@ Openpanel is an open-source web and product analytics platform that combines the
|
||||
## ✨ Features
|
||||
|
||||
- **🔍 Advanced Analytics**: Funnels, cohorts, user profiles, and session history
|
||||
- **🎬 Session Replay**: Record and replay user sessions with privacy controls built in
|
||||
- **📊 Real-time Dashboards**: Live data updates and interactive charts
|
||||
- **🎯 A/B Testing**: Built-in variant testing with detailed breakdowns
|
||||
- **🔔 Smart Notifications**: Event and funnel-based alerts
|
||||
@@ -48,6 +49,7 @@ Openpanel is an open-source web and product analytics platform that combines the
|
||||
| 🔁 Real-time dashboards | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🔍 Funnels & cohort analysis | ✅ | ✅ | ✅* | ✅*** |
|
||||
| 👤 User profiles & session history | ✅ | ✅ | ❌ | ❌ |
|
||||
| 🎬 Session replay | ✅ | ✅**** | ❌ | ❌ |
|
||||
| 📈 Custom dashboards & charts | ✅ | ✅ | ✅ | ❌ |
|
||||
| 💬 Event & funnel notifications | ✅ | ✅ | ❌ | ❌ |
|
||||
| 🌍 GDPR-compliant tracking | ✅ | ✅ | ❌** | ✅ |
|
||||
@@ -56,9 +58,10 @@ Openpanel is an open-source web and product analytics platform that combines the
|
||||
| 🚀 Built for developers | ✅ | ✅ | ❌ | ✅ |
|
||||
| 🔧 A/B testing & variant breakdowns | ✅ | ✅ | ❌ | ❌ |
|
||||
|
||||
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
|
||||
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
|
||||
> ✅* GA4 has a free tier but often requires BigQuery (paid) for raw data access.
|
||||
> ❌** GA4 has faced GDPR bans in several EU countries due to data transfers to US-based servers.
|
||||
> ✅*** Plausible has simple goals
|
||||
> ✅**** Mixpanel session replay is limited to 5k sessions/month on free and 20k on paid. OpenPanel has no limit.
|
||||
|
||||
## Stack
|
||||
|
||||
@@ -98,6 +101,10 @@ You can find the how to [here](https://openpanel.dev/docs/self-hosting/self-host
|
||||
### Start
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
echo "API_URL=http://localhost:3333" > apps/start/.env
|
||||
|
||||
pnpm dock:up
|
||||
pnpm codegen
|
||||
pnpm migrate:deploy # once to setup the db
|
||||
@@ -110,4 +117,5 @@ You can now access the following:
|
||||
- API: https://api.localhost:3333
|
||||
- Bullboard (queue): http://localhost:9999
|
||||
- `pnpm dock:ch` to access clickhouse terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
- `pnpm dock:redis` to access redis terminal
|
||||
---
|
||||
|
||||
@@ -24,7 +24,7 @@ async function main() {
|
||||
const CLICKHOUSE_URL = process.env.CLICKHOUSE_URL;
|
||||
const REDIS_URL = process.env.REDIS_URL;
|
||||
|
||||
if (!DATABASE_URL || !CLICKHOUSE_URL || !REDIS_URL) {
|
||||
if (!(DATABASE_URL && CLICKHOUSE_URL && REDIS_URL)) {
|
||||
console.error('Environment variables are not set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export async function clearCache() {
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
@@ -94,7 +94,7 @@ export async function clearCache() {
|
||||
console.log(chalk.yellow('\n📊 Projects:\n'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`,
|
||||
` - ${project.name} ${chalk.gray(`(${project.id})`)} - ${chalk.cyan(`${project.eventsCount.toLocaleString()} events`)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -119,9 +119,11 @@ export async function clearCache() {
|
||||
for (const project of organization.projects) {
|
||||
// Clear project access cache for each member
|
||||
for (const member of organization.members) {
|
||||
if (!member.user?.id) continue;
|
||||
if (!member.user?.id) {
|
||||
continue;
|
||||
}
|
||||
console.log(
|
||||
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`,
|
||||
`Clearing cache for project: ${project.name} and member: ${member.user?.email}`
|
||||
);
|
||||
await getProjectAccess.clear({
|
||||
userId: member.user?.id,
|
||||
@@ -141,8 +143,8 @@ export async function clearCache() {
|
||||
console.log(chalk.gray(`Organization ID: ${organization.id}`));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`,
|
||||
),
|
||||
`Project IDs: ${organization.projects.map((p) => p.id).join(', ')}`
|
||||
)
|
||||
);
|
||||
|
||||
// Example of what you might do:
|
||||
|
||||
@@ -21,8 +21,8 @@ export async function deleteOrganization() {
|
||||
console.log(chalk.red('\n🗑️ Delete Organization\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the organization and all its data!\n',
|
||||
),
|
||||
'⚠️ WARNING: This will permanently delete the organization and all its data!\n'
|
||||
)
|
||||
);
|
||||
console.log('Loading organizations...\n');
|
||||
|
||||
@@ -51,7 +51,7 @@ export async function deleteOrganization() {
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)} ${chalk.cyan(`- ${org.projects.length} projects, ${org.members.length} members`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
@@ -107,7 +107,7 @@ export async function deleteOrganization() {
|
||||
console.log(chalk.red('\n Projects that will be deleted:'));
|
||||
for (const project of organization.projects) {
|
||||
console.log(
|
||||
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`,
|
||||
` - ${project.name} ${chalk.gray(`(${project.eventsCount.toLocaleString()} events, ${project.clients.length} clients)`)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -122,8 +122,8 @@ export async function deleteOrganization() {
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!',
|
||||
),
|
||||
'\n⚠️ This will delete ALL projects, clients, events, and data associated with this organization!'
|
||||
)
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
@@ -132,7 +132,7 @@ export async function deleteOrganization() {
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`,
|
||||
`Are you ABSOLUTELY SURE you want to delete "${organization.name}"?`
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
@@ -154,7 +154,7 @@ export async function deleteOrganization() {
|
||||
|
||||
if (confirmName !== organization.name) {
|
||||
console.log(
|
||||
chalk.red('\n❌ Organization name does not match. Deletion cancelled.'),
|
||||
chalk.red('\n❌ Organization name does not match. Deletion cancelled.')
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +165,7 @@ export async function deleteOrganization() {
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?'
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
@@ -185,8 +185,8 @@ export async function deleteOrganization() {
|
||||
if (projectIds.length > 0) {
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`Deleting data from ClickHouse for ${projectIds.length} projects...`,
|
||||
),
|
||||
`Deleting data from ClickHouse for ${projectIds.length} projects...`
|
||||
)
|
||||
);
|
||||
await deleteFromClickhouse(projectIds);
|
||||
console.log(chalk.green('✓ ClickHouse data deletion initiated'));
|
||||
@@ -200,13 +200,13 @@ export async function deleteOrganization() {
|
||||
console.log(chalk.green('\n✅ Organization deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`,
|
||||
),
|
||||
`Deleted: ${organization.name} with ${organization.projects.length} projects and ${organization.members.length} members`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
chalk.gray(
|
||||
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.',
|
||||
),
|
||||
'\nNote: ClickHouse deletions are processed asynchronously and may take a few moments to complete.'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting organization:'), error);
|
||||
|
||||
@@ -19,8 +19,8 @@ export async function deleteUser() {
|
||||
console.log(chalk.red('\n🗑️ Delete User\n'));
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n',
|
||||
),
|
||||
'⚠️ WARNING: This will permanently delete the user and remove them from all organizations!\n'
|
||||
)
|
||||
);
|
||||
console.log('Loading users...\n');
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function deleteUser() {
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: UserSearchItem) =>
|
||||
`${item.email} ${item.firstName || ''} ${item.lastName || ''}`,
|
||||
@@ -107,46 +107,46 @@ export async function deleteUser() {
|
||||
console.log(` ${chalk.bold('User:')} ${user.email}`);
|
||||
if (user.firstName || user.lastName) {
|
||||
console.log(
|
||||
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`,
|
||||
` ${chalk.gray('Name:')} ${user.firstName || ''} ${user.lastName || ''}`
|
||||
);
|
||||
}
|
||||
console.log(` ${chalk.gray('ID:')} ${user.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`,
|
||||
` ${chalk.gray('Member of:')} ${user.membership.length} organizations`
|
||||
);
|
||||
console.log(` ${chalk.gray('Auth accounts:')} ${user.accounts.length}`);
|
||||
|
||||
if (user.createdOrganizations.length > 0) {
|
||||
console.log(
|
||||
chalk.red(
|
||||
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`,
|
||||
),
|
||||
`\n ⚠️ This user CREATED ${user.createdOrganizations.length} organization(s):`
|
||||
)
|
||||
);
|
||||
for (const org of user.createdOrganizations) {
|
||||
console.log(` - ${org.name} ${chalk.gray(`(${org.id})`)}`);
|
||||
}
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
' Note: These organizations will NOT be deleted, only the user reference.',
|
||||
),
|
||||
' Note: These organizations will NOT be deleted, only the user reference.'
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.membership.length > 0) {
|
||||
console.log(
|
||||
chalk.red('\n Organizations where user will be removed from:'),
|
||||
chalk.red('\n Organizations where user will be removed from:')
|
||||
);
|
||||
for (const member of user.membership) {
|
||||
console.log(
|
||||
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`,
|
||||
` - ${member.organization.name} ${chalk.gray(`(${member.role})`)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
chalk.red(
|
||||
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!',
|
||||
),
|
||||
'\n⚠️ This will delete the user account, all sessions, and remove them from all organizations!'
|
||||
)
|
||||
);
|
||||
|
||||
// First confirmation
|
||||
@@ -155,7 +155,7 @@ export async function deleteUser() {
|
||||
type: 'confirm',
|
||||
name: 'confirmFirst',
|
||||
message: chalk.red(
|
||||
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`,
|
||||
`Are you ABSOLUTELY SURE you want to delete user "${user.email}"?`
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
@@ -186,7 +186,7 @@ export async function deleteUser() {
|
||||
type: 'confirm',
|
||||
name: 'confirmFinal',
|
||||
message: chalk.red(
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?',
|
||||
'FINAL WARNING: This action CANNOT be undone. Delete now?'
|
||||
),
|
||||
default: false,
|
||||
},
|
||||
@@ -210,8 +210,8 @@ export async function deleteUser() {
|
||||
console.log(chalk.green('\n✅ User deleted successfully!'));
|
||||
console.log(
|
||||
chalk.gray(
|
||||
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`,
|
||||
),
|
||||
`Deleted: ${user.email} (removed from ${user.membership.length} organizations)`
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(chalk.red('\n❌ Error deleting user:'), error);
|
||||
|
||||
@@ -47,7 +47,7 @@ export async function lookupByClient() {
|
||||
displayText: `${client.organization.name} → ${client.project?.name || '[No Project]'} → ${client.name} ${chalk.gray(`(${client.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ClientSearchItem) =>
|
||||
`${item.organizationName} ${item.projectName || ''} ${item.name} ${item.id}`,
|
||||
@@ -101,4 +101,3 @@ export async function lookupByClient() {
|
||||
highlightClientId: selectedClient.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function lookupByEmail() {
|
||||
};
|
||||
});
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: EmailSearchItem) =>
|
||||
`${item.email} ${item.organizationName}`,
|
||||
@@ -103,10 +103,9 @@ export async function lookupByEmail() {
|
||||
|
||||
console.log(
|
||||
chalk.yellow(
|
||||
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`,
|
||||
),
|
||||
`\nShowing organization for: ${selectedMember.email} (${selectedMember.role})\n`
|
||||
)
|
||||
);
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ export async function lookupByOrg() {
|
||||
displayText: `${org.name} ${chalk.gray(`(${org.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: OrgSearchItem) => `${item.name} ${item.id}`,
|
||||
});
|
||||
@@ -85,4 +85,3 @@ export async function lookupByOrg() {
|
||||
|
||||
displayOrganizationDetails(organization);
|
||||
}
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function lookupByProject() {
|
||||
displayText: `${project.organization.name} → ${project.name} ${chalk.gray(`(${project.id})`)}`,
|
||||
}));
|
||||
|
||||
const searchFunction = async (_answers: unknown, input = '') => {
|
||||
const searchFunction = (_answers: unknown, input = '') => {
|
||||
const fuzzyResult = fuzzy.filter(input, searchItems, {
|
||||
extract: (item: ProjectSearchItem) =>
|
||||
`${item.organizationName} ${item.name} ${item.id}`,
|
||||
@@ -95,4 +95,3 @@ export async function lookupByProject() {
|
||||
highlightProjectId: selectedProject.id,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ interface DisplayOptions {
|
||||
|
||||
export function displayOrganizationDetails(
|
||||
organization: OrganizationWithDetails,
|
||||
options: DisplayOptions = {},
|
||||
options: DisplayOptions = {}
|
||||
) {
|
||||
console.log(`\n${'='.repeat(80)}`);
|
||||
console.log(chalk.bold.yellow(`\n📊 ORGANIZATION: ${organization.name}`));
|
||||
@@ -34,18 +34,18 @@ export function displayOrganizationDetails(
|
||||
console.log(` ${chalk.gray('ID:')} ${organization.id}`);
|
||||
console.log(` ${chalk.gray('Name:')} ${organization.name}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`,
|
||||
` ${chalk.gray('Created:')} ${organization.createdAt.toISOString()}`
|
||||
);
|
||||
console.log(` ${chalk.gray('Timezone:')} ${organization.timezone || 'UTC'}`);
|
||||
|
||||
// Subscription info
|
||||
if (organization.subscriptionStatus) {
|
||||
console.log(
|
||||
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`,
|
||||
` ${chalk.gray('Subscription Status:')} ${getSubscriptionStatusColor(organization.subscriptionStatus)}`
|
||||
);
|
||||
if (organization.subscriptionPriceId) {
|
||||
console.log(
|
||||
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`,
|
||||
` ${chalk.gray('Price ID:')} ${organization.subscriptionPriceId}`
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionPeriodEventsLimit) {
|
||||
@@ -61,24 +61,24 @@ export function displayOrganizationDetails(
|
||||
? chalk.yellow
|
||||
: chalk.green;
|
||||
console.log(
|
||||
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`,
|
||||
` ${chalk.gray('Event Usage:')} ${color(usage)} (${percentage.toFixed(1)}%)`
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionStartsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`,
|
||||
` ${chalk.gray('Starts:')} ${organization.subscriptionStartsAt.toISOString()}`
|
||||
);
|
||||
}
|
||||
if (organization.subscriptionEndsAt) {
|
||||
console.log(
|
||||
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`,
|
||||
` ${chalk.gray('Ends:')} ${organization.subscriptionEndsAt.toISOString()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (organization.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`,
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${organization.deleteAt.toISOString()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,7 +90,7 @@ export function displayOrganizationDetails(
|
||||
for (const member of organization.members) {
|
||||
const roleBadge = getRoleBadge(member.role);
|
||||
console.log(
|
||||
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`,
|
||||
` ${roleBadge} ${member.user?.email || member.email || 'Unknown'} ${chalk.gray(`(${member.role})`)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ export function displayOrganizationDetails(
|
||||
console.log(`\n${projectPrefix}${chalk.bold.green(project.name)}`);
|
||||
console.log(` ${chalk.gray('ID:')} ${project.id}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`,
|
||||
` ${chalk.gray('Events Count:')} ${project.eventsCount.toLocaleString()}`
|
||||
);
|
||||
|
||||
if (project.domain) {
|
||||
@@ -120,15 +120,15 @@ export function displayOrganizationDetails(
|
||||
}
|
||||
|
||||
console.log(
|
||||
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
` ${chalk.gray('Cross Domain:')} ${project.crossDomain ? chalk.green('✓') : chalk.red('✗')}`
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`,
|
||||
` ${chalk.gray('Created:')} ${project.createdAt.toISOString()}`
|
||||
);
|
||||
|
||||
if (project.deleteAt) {
|
||||
console.log(
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`,
|
||||
` ${chalk.red.bold('⚠️ Scheduled for deletion:')} ${project.deleteAt.toISOString()}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,10 +146,10 @@ export function displayOrganizationDetails(
|
||||
console.log(` ${chalk.gray('ID:')} ${client.id}`);
|
||||
console.log(` ${chalk.gray('Type:')} ${client.type}`);
|
||||
console.log(
|
||||
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`,
|
||||
` ${chalk.gray('Has Secret:')} ${client.secret ? chalk.green('✓') : chalk.red('✗')}`
|
||||
);
|
||||
console.log(
|
||||
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`,
|
||||
` ${chalk.gray('Ignore CORS/Secret:')} ${client.ignoreCorsAndSecret ? chalk.yellow('✓') : chalk.gray('✗')}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -159,7 +159,7 @@ export function displayOrganizationDetails(
|
||||
}
|
||||
|
||||
// Clients without projects (organization-level clients)
|
||||
const orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
||||
const _orgLevelClients = organization.projects.length > 0 ? [] : []; // We need to query these separately
|
||||
|
||||
console.log(`\n${'='.repeat(80)}\n`);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"rootDir": "src",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
"types": ["node"],
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ FROM node:${NODE_VERSION}-slim AS base
|
||||
# FIX: Bad workaround (https://github.com/nodejs/corepack/issues/612)
|
||||
ENV COREPACK_INTEGRITY_KEYS=0
|
||||
|
||||
RUN corepack enable && apt-get update && \
|
||||
RUN rm -f /usr/local/bin/pnpm /usr/local/bin/pnpx && npm install -g pnpm@10.6.2 && apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
openssl \
|
||||
@@ -38,11 +38,10 @@ COPY packages/redis/package.json packages/redis/
|
||||
COPY packages/logger/package.json packages/logger/
|
||||
COPY packages/common/package.json packages/common/
|
||||
COPY packages/payments/package.json packages/payments/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/constants/package.json packages/constants/
|
||||
COPY packages/validation/package.json packages/validation/
|
||||
COPY packages/integrations/package.json packages/integrations/
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/
|
||||
COPY packages/js-runtime/package.json packages/js-runtime/
|
||||
COPY patches ./patches
|
||||
|
||||
# BUILD
|
||||
@@ -107,10 +106,10 @@ COPY --from=build /app/packages/redis ./packages/redis
|
||||
COPY --from=build /app/packages/logger ./packages/logger
|
||||
COPY --from=build /app/packages/common ./packages/common
|
||||
COPY --from=build /app/packages/payments ./packages/payments
|
||||
COPY --from=build /app/packages/sdks/sdk ./packages/sdks/sdk
|
||||
COPY --from=build /app/packages/constants ./packages/constants
|
||||
COPY --from=build /app/packages/validation ./packages/validation
|
||||
COPY --from=build /app/packages/integrations ./packages/integrations
|
||||
COPY --from=build /app/packages/js-runtime ./packages/js-runtime
|
||||
COPY --from=build /app/tooling/typescript ./tooling/typescript
|
||||
RUN pnpm db:codegen
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"start": "dotenv -e ../../.env node dist/index.js",
|
||||
"build": "rm -rf dist && tsdown",
|
||||
"gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts",
|
||||
"test:manage": "jiti scripts/test-manage-api.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -38,7 +39,7 @@
|
||||
"fastify": "^5.6.1",
|
||||
"fastify-metrics": "^12.1.0",
|
||||
"fastify-raw-body": "^5.0.0",
|
||||
"groupmq": "1.1.0-next.6",
|
||||
"groupmq": "catalog:",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ramda": "^0.29.1",
|
||||
"sharp": "^0.33.5",
|
||||
@@ -46,13 +47,12 @@
|
||||
"sqlstring": "^2.3.3",
|
||||
"superjson": "^1.13.3",
|
||||
"svix": "^1.24.0",
|
||||
"url-metadata": "^4.1.1",
|
||||
"url-metadata": "^5.4.1",
|
||||
"uuid": "^9.0.1",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^9.0.1",
|
||||
"@openpanel/sdk": "workspace:*",
|
||||
"@openpanel/tsconfig": "workspace:*",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/jsonwebtoken": "^9.0.9",
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dirname } from 'node:path';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
import yaml from 'js-yaml';
|
||||
|
||||
// Regex special characters that indicate we need actual regex
|
||||
const regexSpecialChars = /[|^$.*+?(){}\[\]\\]/;
|
||||
const regexSpecialChars = /[|^$.*+?(){}[\]\\]/;
|
||||
|
||||
function transformBots(bots: any[]): any[] {
|
||||
return bots.map((bot) => {
|
||||
@@ -28,7 +28,7 @@ async function main() {
|
||||
// Get document, or throw exception on error
|
||||
try {
|
||||
const data = await fetch(
|
||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml',
|
||||
'https://raw.githubusercontent.com/matomo-org/device-detector/master/regexes/bots.yml'
|
||||
).then((res) => res.text());
|
||||
|
||||
const parsedData = yaml.load(data) as any[];
|
||||
@@ -45,11 +45,11 @@ async function main() {
|
||||
'export default bots;',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf-8',
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
console.log(
|
||||
`✅ Generated bots.ts with ${transformedBots.length} bot entries`,
|
||||
`✅ Generated bots.ts with ${transformedBots.length} bot entries`
|
||||
);
|
||||
const regexCount = transformedBots.filter((b) => 'regex' in b).length;
|
||||
const includesCount = transformedBots.filter((b) => 'includes' in b).length;
|
||||
|
||||
@@ -133,7 +133,7 @@ function generateEvents(): Event[] {
|
||||
clientId,
|
||||
profile: profiles[i % PROFILE_COUNT]!,
|
||||
eventsCount: Math.floor(Math.random() * 10),
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -150,7 +150,7 @@ let lastTriggeredIndex = 0;
|
||||
async function triggerEvents(generatedEvents: any[]) {
|
||||
const EVENTS_PER_SECOND = Number.parseInt(
|
||||
process.env.EVENTS_PER_SECOND || '100',
|
||||
10,
|
||||
10
|
||||
);
|
||||
const INTERVAL_MS = 1000 / EVENTS_PER_SECOND;
|
||||
|
||||
@@ -164,7 +164,7 @@ async function triggerEvents(generatedEvents: any[]) {
|
||||
await trackit(event);
|
||||
console.log(`Event ${lastTriggeredIndex + 1} sent successfully`);
|
||||
console.log(
|
||||
`sending ${event.track.payload?.properties?.__path} from user ${event.headers['user-agent']}`,
|
||||
`sending ${event.track.payload?.properties?.__path} from user ${event.headers['user-agent']}`
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(`Failed to send event ${lastTriggeredIndex + 1}:`, error);
|
||||
@@ -174,7 +174,7 @@ async function triggerEvents(generatedEvents: any[]) {
|
||||
const remainingEvents = generatedEvents.length - lastTriggeredIndex;
|
||||
|
||||
console.log(
|
||||
`Triggered ${lastTriggeredIndex} events. Remaining: ${remainingEvents}`,
|
||||
`Triggered ${lastTriggeredIndex} events. Remaining: ${remainingEvents}`
|
||||
);
|
||||
|
||||
if (remainingEvents > 0) {
|
||||
@@ -215,7 +215,7 @@ async function createMock(file: string) {
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify(insertFakeEvents(scrambleEvents(generateEvents())), null, 2),
|
||||
'utf-8',
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@ async function simultaneousRequests() {
|
||||
if (group.parallel && group.tracks.length > 1) {
|
||||
// Parallel execution for same-flagged tracks
|
||||
console.log(
|
||||
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`,
|
||||
`Firing ${group.tracks.length} parallel requests with flag '${group.parallel}'`
|
||||
);
|
||||
const promises = group.tracks.map(async (track) => {
|
||||
const { name, parallel, ...properties } = track;
|
||||
|
||||
354
apps/api/scripts/test-manage-api.ts
Normal file
@@ -0,0 +1,354 @@
|
||||
/**
|
||||
* One-off script to test all /manage/ API endpoints
|
||||
*
|
||||
* Usage:
|
||||
* pnpm test:manage
|
||||
* or
|
||||
* pnpm jiti scripts/test-manage-api.ts
|
||||
*
|
||||
* Set API_URL environment variable to test against a different server:
|
||||
* API_URL=http://localhost:3000 pnpm test:manage
|
||||
*/
|
||||
|
||||
const CLIENT_ID = process.env.CLIENT_ID!;
|
||||
const CLIENT_SECRET = process.env.CLIENT_SECRET!;
|
||||
const API_BASE_URL = process.env.API_URL || 'http://localhost:3333';
|
||||
|
||||
if (!(CLIENT_ID && CLIENT_SECRET)) {
|
||||
console.error('CLIENT_ID and CLIENT_SECRET must be set');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
method: string;
|
||||
url: string;
|
||||
status: number;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function makeRequest(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: any
|
||||
): Promise<TestResult> {
|
||||
const url = `${API_BASE_URL}${path}`;
|
||||
const headers: Record<string, string> = {
|
||||
'openpanel-client-id': CLIENT_ID,
|
||||
'openpanel-client-secret': CLIENT_SECRET,
|
||||
};
|
||||
|
||||
// Only set Content-Type if there's a body
|
||||
if (body) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: response.status,
|
||||
success: response.ok,
|
||||
error: response.ok ? undefined : data.message || 'Request failed',
|
||||
data: response.ok ? data : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
name: `${method} ${path}`,
|
||||
method,
|
||||
url,
|
||||
status: 0,
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function testProjects() {
|
||||
console.log('\n📁 Testing Projects endpoints...\n');
|
||||
|
||||
// Create project
|
||||
const createResult = await makeRequest('POST', '/manage/projects', {
|
||||
name: `Test Project ${Date.now()}`,
|
||||
domain: 'https://example.com',
|
||||
cors: ['https://example.com', 'https://www.example.com'],
|
||||
crossDomain: false,
|
||||
types: ['website'],
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/projects: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
|
||||
);
|
||||
if (createResult.error) {
|
||||
console.log(` Error: ${createResult.error}`);
|
||||
}
|
||||
|
||||
const projectId = createResult.data?.data?.id;
|
||||
const clientId = createResult.data?.data?.client?.id;
|
||||
const clientSecret = createResult.data?.data?.client?.secret;
|
||||
|
||||
if (projectId) {
|
||||
console.log(` Created project: ${projectId}`);
|
||||
if (clientId) {
|
||||
console.log(` Created client: ${clientId}`);
|
||||
}
|
||||
if (clientSecret) {
|
||||
console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
}
|
||||
|
||||
// List projects
|
||||
const listResult = await makeRequest('GET', '/manage/projects');
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} projects`);
|
||||
}
|
||||
|
||||
if (projectId) {
|
||||
// Get project
|
||||
const getResult = await makeRequest('GET', `/manage/projects/${projectId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/projects/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
|
||||
);
|
||||
|
||||
// Update project
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/projects/${projectId}`,
|
||||
{
|
||||
name: 'Updated Test Project',
|
||||
crossDomain: true,
|
||||
}
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/projects/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
|
||||
);
|
||||
|
||||
// Delete project (soft delete)
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/projects/${projectId}`
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/projects/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
|
||||
);
|
||||
}
|
||||
|
||||
return { projectId, clientId };
|
||||
}
|
||||
|
||||
async function testClients(projectId?: string) {
|
||||
console.log('\n🔑 Testing Clients endpoints...\n');
|
||||
|
||||
// Create client
|
||||
const createResult = await makeRequest('POST', '/manage/clients', {
|
||||
name: `Test Client ${Date.now()}`,
|
||||
projectId: projectId || undefined,
|
||||
type: 'read',
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/clients: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
|
||||
);
|
||||
if (createResult.error) {
|
||||
console.log(` Error: ${createResult.error}`);
|
||||
}
|
||||
|
||||
const clientId = createResult.data?.data?.id;
|
||||
const clientSecret = createResult.data?.data?.secret;
|
||||
|
||||
if (clientId) {
|
||||
console.log(` Created client: ${clientId}`);
|
||||
if (clientSecret) {
|
||||
console.log(` Client secret: ${clientSecret}`);
|
||||
}
|
||||
}
|
||||
|
||||
// List clients
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients'
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} clients`);
|
||||
}
|
||||
|
||||
if (clientId) {
|
||||
// Get client
|
||||
const getResult = await makeRequest('GET', `/manage/clients/${clientId}`);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/clients/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
|
||||
);
|
||||
|
||||
// Update client
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/clients/${clientId}`,
|
||||
{
|
||||
name: 'Updated Test Client',
|
||||
}
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/clients/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
|
||||
);
|
||||
|
||||
// Delete client
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/clients/${clientId}`
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/clients/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testReferences(projectId?: string) {
|
||||
console.log('\n📚 Testing References endpoints...\n');
|
||||
|
||||
if (!projectId) {
|
||||
console.log(' ⚠️ Skipping references tests - no project ID available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create reference
|
||||
const createResult = await makeRequest('POST', '/manage/references', {
|
||||
projectId,
|
||||
title: `Test Reference ${Date.now()}`,
|
||||
description: 'This is a test reference',
|
||||
datetime: new Date().toISOString(),
|
||||
});
|
||||
results.push(createResult);
|
||||
console.log(
|
||||
`✓ POST /manage/references: ${createResult.success ? '✅' : '❌'} ${createResult.status}`
|
||||
);
|
||||
if (createResult.error) {
|
||||
console.log(` Error: ${createResult.error}`);
|
||||
}
|
||||
|
||||
const referenceId = createResult.data?.data?.id;
|
||||
|
||||
if (referenceId) {
|
||||
console.log(` Created reference: ${referenceId}`);
|
||||
}
|
||||
|
||||
// List references
|
||||
const listResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references?projectId=${projectId}`
|
||||
);
|
||||
results.push(listResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references: ${listResult.success ? '✅' : '❌'} ${listResult.status}`
|
||||
);
|
||||
if (listResult.data?.data?.length) {
|
||||
console.log(` Found ${listResult.data.data.length} references`);
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
// Get reference
|
||||
const getResult = await makeRequest(
|
||||
'GET',
|
||||
`/manage/references/${referenceId}`
|
||||
);
|
||||
results.push(getResult);
|
||||
console.log(
|
||||
`✓ GET /manage/references/:id: ${getResult.success ? '✅' : '❌'} ${getResult.status}`
|
||||
);
|
||||
|
||||
// Update reference
|
||||
const updateResult = await makeRequest(
|
||||
'PATCH',
|
||||
`/manage/references/${referenceId}`,
|
||||
{
|
||||
title: 'Updated Test Reference',
|
||||
description: 'Updated description',
|
||||
datetime: new Date().toISOString(),
|
||||
}
|
||||
);
|
||||
results.push(updateResult);
|
||||
console.log(
|
||||
`✓ PATCH /manage/references/:id: ${updateResult.success ? '✅' : '❌'} ${updateResult.status}`
|
||||
);
|
||||
|
||||
// Delete reference
|
||||
const deleteResult = await makeRequest(
|
||||
'DELETE',
|
||||
`/manage/references/${referenceId}`
|
||||
);
|
||||
results.push(deleteResult);
|
||||
console.log(
|
||||
`✓ DELETE /manage/references/:id: ${deleteResult.success ? '✅' : '❌'} ${deleteResult.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 Testing Manage API Endpoints\n');
|
||||
console.log(`API Base URL: ${API_BASE_URL}`);
|
||||
console.log(`Client ID: ${CLIENT_ID}\n`);
|
||||
|
||||
try {
|
||||
// Test projects first (creates a project we can use for other tests)
|
||||
const { projectId } = await testProjects();
|
||||
|
||||
// Test clients
|
||||
await testClients(projectId);
|
||||
|
||||
// Test references (requires a project)
|
||||
await testReferences(projectId);
|
||||
|
||||
// Summary
|
||||
console.log(`\n${'='.repeat(60)}`);
|
||||
console.log('📊 Test Summary\n');
|
||||
const successful = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
console.log(`Total tests: ${results.length}`);
|
||||
console.log(`✅ Successful: ${successful}`);
|
||||
console.log(`❌ Failed: ${failed}\n`);
|
||||
|
||||
if (failed > 0) {
|
||||
console.log('Failed tests:');
|
||||
results
|
||||
.filter((r) => !r.success)
|
||||
.forEach((r) => {
|
||||
console.log(` ❌ ${r.name} (${r.status})`);
|
||||
if (r.error) {
|
||||
console.log(` Error: ${r.error}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fatal error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -1,11 +1,10 @@
|
||||
import { type IClickhouseEvent, ch, createEvent } from '@openpanel/db';
|
||||
import { formatClickhouseDate } from '@openpanel/db';
|
||||
import { ch, formatClickhouseDate, type IClickhouseEvent } from '@openpanel/db';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
async function main() {
|
||||
const startDate = new Date('2025-01-01T00:00:00Z');
|
||||
const endDate = new Date();
|
||||
const eventsPerDay = 25000;
|
||||
const eventsPerDay = 25_000;
|
||||
const variance = 3000;
|
||||
|
||||
// Event names to randomly choose from
|
||||
@@ -36,7 +35,7 @@ async function main() {
|
||||
device_id: `device_${Math.floor(Math.random() * 1000)}`,
|
||||
profile_id: `profile_${Math.floor(Math.random() * 1000)}`,
|
||||
project_id: 'testing',
|
||||
session_id: `session_${Math.floor(Math.random() * 10000)}`,
|
||||
session_id: `session_${Math.floor(Math.random() * 10_000)}`,
|
||||
properties: {
|
||||
hash: 'test-hash',
|
||||
'query.utm_source': 'test',
|
||||
@@ -63,6 +62,7 @@ async function main() {
|
||||
imported_at: null,
|
||||
sdk_name: 'test-script',
|
||||
sdk_version: '1.0.0',
|
||||
groups: [],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ async function main() {
|
||||
|
||||
// Log progress
|
||||
console.log(
|
||||
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`,
|
||||
`Created ${dailyEvents} events for ${currentDate.toISOString().split('T')[0]}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { cacheable, cacheableLru } from '@openpanel/redis';
|
||||
import { cacheable } from '@openpanel/redis';
|
||||
import bots from './bots';
|
||||
|
||||
// Pre-compile regex patterns at module load time
|
||||
@@ -15,7 +15,7 @@ const compiledBots = bots.map((bot) => {
|
||||
const regexBots = compiledBots.filter((bot) => 'compiledRegex' in bot);
|
||||
const includesBots = compiledBots.filter((bot) => 'includes' in bot);
|
||||
|
||||
export const isBot = cacheableLru(
|
||||
export const isBot = cacheable(
|
||||
'is-bot',
|
||||
(ua: string) => {
|
||||
// Check simple string patterns first (fast)
|
||||
@@ -40,8 +40,5 @@ export const isBot = cacheableLru(
|
||||
|
||||
return null;
|
||||
},
|
||||
{
|
||||
maxSize: 1000,
|
||||
ttl: 60 * 5,
|
||||
},
|
||||
60 * 5
|
||||
);
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||
import { getProjectAccess } from '@openpanel/trpc/src/access';
|
||||
import { appendResponseMessages, type Message, streamText } from 'ai';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getChatModel, getChatSystemPrompt } from '@/utils/ai';
|
||||
import {
|
||||
getAllEventNames,
|
||||
@@ -8,10 +12,6 @@ import {
|
||||
getReport,
|
||||
} from '@/utils/ai-tools';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||
import { getProjectAccess } from '@openpanel/trpc/src/access';
|
||||
import { type Message, appendResponseMessages, streamText } from 'ai';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function chat(
|
||||
request: FastifyRequest<{
|
||||
@@ -22,7 +22,7 @@ export async function chat(
|
||||
messages: Message[];
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { session } = request.session;
|
||||
const { messages } = request.body;
|
||||
@@ -117,7 +117,7 @@ export async function chat(
|
||||
},
|
||||
});
|
||||
},
|
||||
onError: async (error) => {
|
||||
onError: (error) => {
|
||||
request.log.error('chat error', { error });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - OpenPanel</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Error - OpenPanel</title>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap"
|
||||
rel="stylesheet"
|
||||
>
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: "Inter", sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
@@ -47,16 +49,21 @@
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<img src="https://openpanel.dev/logo.svg" alt="OpenPanel Logo" class="logo">
|
||||
<h1>Oops! Something went wrong</h1>
|
||||
<p>We encountered an error while processing your request. Please try again later or contact support if the problem
|
||||
persists.</p>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
<body>
|
||||
<div class="error-container">
|
||||
<img
|
||||
src="https://openpanel.dev/logo.svg"
|
||||
alt="OpenPanel Logo"
|
||||
class="logo"
|
||||
>
|
||||
<h1>Oops! Something went wrong</h1>
|
||||
<p>
|
||||
We encountered an error while processing your request. Please try again
|
||||
later or contact support if the problem persists.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,26 +1,25 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import type { PostEventPayload } from '@openpanel/sdk';
|
||||
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import type { DeprecatedPostEventPayload } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getStringHeaders, getTimestamp } from './track.controller';
|
||||
import { getDeviceId } from '@/utils/ids';
|
||||
|
||||
export async function postEvent(
|
||||
request: FastifyRequest<{
|
||||
Body: PostEventPayload;
|
||||
Body: DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { timestamp, isTimestampFromThePast } = getTimestamp(
|
||||
request.timestamp,
|
||||
request.body,
|
||||
request.body
|
||||
);
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||
const projectId = request.client?.projectId;
|
||||
const headers = getStringHeaders(request.headers);
|
||||
|
||||
@@ -30,34 +29,22 @@ export async function postEvent(
|
||||
}
|
||||
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
const { deviceId, sessionId } = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
});
|
||||
|
||||
const uaInfo = parseUserAgent(ua, request.body?.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? request.body?.profileId
|
||||
? `${projectId}:${request.body?.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
? `${projectId}:${request.body?.profileId ?? generateId()}`
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
request.body.name,
|
||||
slug(request.body.name),
|
||||
timestamp,
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
deviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
@@ -74,8 +61,8 @@ export async function postEvent(
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
deviceId,
|
||||
sessionId: sessionId ?? '',
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { DateTime } from '@openpanel/common';
|
||||
import type { GetEventListOptions } from '@openpanel/db';
|
||||
import {
|
||||
ChartEngine,
|
||||
ClientType,
|
||||
db,
|
||||
getEventList,
|
||||
getEventsCountCached,
|
||||
getEventsCount,
|
||||
getSettingsForProject,
|
||||
} from '@openpanel/db';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartEvent, zChartInput } from '@openpanel/validation';
|
||||
import { omit } from 'ramda';
|
||||
import { zChartEvent, zReport } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
|
||||
async function getProjectId(
|
||||
request: FastifyRequest<{
|
||||
@@ -22,8 +20,7 @@ async function getProjectId(
|
||||
project_id?: string;
|
||||
projectId?: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
}>
|
||||
) {
|
||||
let projectId = request.query.projectId || request.query.project_id;
|
||||
|
||||
@@ -74,10 +71,22 @@ const eventsScheme = z.object({
|
||||
page: z.coerce.number().optional().default(1),
|
||||
limit: z.coerce.number().optional().default(50),
|
||||
includes: z
|
||||
.preprocess(
|
||||
(arg) => (typeof arg === 'string' ? [arg] : arg),
|
||||
z.array(z.string()),
|
||||
)
|
||||
.preprocess((arg) => {
|
||||
if (arg == null) {
|
||||
return undefined;
|
||||
}
|
||||
if (Array.isArray(arg)) {
|
||||
return arg;
|
||||
}
|
||||
if (typeof arg === 'string') {
|
||||
const parts = arg
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return parts;
|
||||
}
|
||||
return arg;
|
||||
}, z.array(z.string()))
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -85,7 +94,7 @@ export async function events(
|
||||
request: FastifyRequest<{
|
||||
Querystring: z.infer<typeof eventsScheme>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const query = eventsScheme.safeParse(request.query);
|
||||
|
||||
@@ -97,7 +106,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const projectId = await getProjectId(request);
|
||||
const limit = query.data.limit;
|
||||
const page = Math.max(query.data.page, 1);
|
||||
const take = Math.max(Math.min(limit, 1000), 1);
|
||||
@@ -118,20 +127,20 @@ export async function events(
|
||||
meta: false,
|
||||
...query.data.includes?.reduce(
|
||||
(acc, key) => ({ ...acc, [key]: true }),
|
||||
{},
|
||||
{}
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const [data, totalCount] = await Promise.all([
|
||||
getEventList(options),
|
||||
getEventsCountCached(omit(['cursor', 'take'], options)),
|
||||
getEventsCount(options),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
meta: {
|
||||
count: data.length,
|
||||
totalCount: totalCount,
|
||||
totalCount,
|
||||
pages: Math.ceil(totalCount / options.take),
|
||||
current: cursor + 1,
|
||||
},
|
||||
@@ -139,7 +148,7 @@ export async function events(
|
||||
});
|
||||
}
|
||||
|
||||
const chartSchemeFull = zChartInput
|
||||
const chartSchemeFull = zReport
|
||||
.pick({
|
||||
breakdowns: true,
|
||||
interval: true,
|
||||
@@ -151,21 +160,34 @@ const chartSchemeFull = zChartInput
|
||||
.extend({
|
||||
project_id: z.string().optional(),
|
||||
projectId: z.string().optional(),
|
||||
events: z.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
}),
|
||||
),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
// Backward compatibility - events will be migrated to series via preprocessing
|
||||
events: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string(),
|
||||
filters: zChartEvent.shape.filters.optional(),
|
||||
segment: zChartEvent.shape.segment.optional(),
|
||||
property: zChartEvent.shape.property.optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export async function charts(
|
||||
request: FastifyRequest<{
|
||||
Querystring: Record<string, string>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const query = chartSchemeFull.safeParse(parseQueryString(request.query));
|
||||
|
||||
@@ -177,11 +199,19 @@ export async function charts(
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = await getProjectId(request, reply);
|
||||
const projectId = await getProjectId(request);
|
||||
const { timezone } = await getSettingsForProject(projectId);
|
||||
const { events, ...rest } = query.data;
|
||||
const { events, series, ...rest } = query.data;
|
||||
|
||||
return getChart({
|
||||
// Use series if available, otherwise fall back to events (backward compat)
|
||||
const eventSeries = (series ?? events ?? []).map((event: any) => ({
|
||||
...event,
|
||||
type: event.type ?? 'event',
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
}));
|
||||
|
||||
return ChartEngine.execute({
|
||||
...rest,
|
||||
startDate: rest.startDate
|
||||
? DateTime.fromISO(rest.startDate)
|
||||
@@ -194,11 +224,7 @@ export async function charts(
|
||||
.toFormat('yyyy-MM-dd HH:mm:ss')
|
||||
: undefined,
|
||||
projectId,
|
||||
events: events.map((event) => ({
|
||||
...event,
|
||||
segment: event.segment ?? 'event',
|
||||
filters: event.filters ?? [],
|
||||
})),
|
||||
series: eventSeries,
|
||||
chartType: 'linear',
|
||||
metric: 'sum',
|
||||
});
|
||||
|
||||
167
apps/api/src/controllers/gsc-oauth-callback.controller.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { googleGsc } from '@openpanel/auth';
|
||||
import { db, encrypt } from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { LogError } from '@/utils/errors';
|
||||
|
||||
const OAUTH_SENSITIVE_KEYS = ['code', 'state'];
|
||||
|
||||
function sanitizeOAuthQuery(
|
||||
query: Record<string, unknown> | null | undefined
|
||||
): Record<string, string> {
|
||||
if (!query || typeof query !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).map(([k, v]) => [
|
||||
k,
|
||||
OAUTH_SENSITIVE_KEYS.includes(k) ? '<redacted>' : String(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
export async function gscGoogleCallback(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const schema = z.object({
|
||||
code: z.string(),
|
||||
state: z.string(),
|
||||
});
|
||||
|
||||
const query = schema.safeParse(req.query);
|
||||
if (!query.success) {
|
||||
throw new LogError(
|
||||
'Invalid GSC callback query params',
|
||||
sanitizeOAuthQuery(req.query as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
const { code, state } = query.data;
|
||||
|
||||
const rawStoredState = req.cookies.gsc_oauth_state ?? null;
|
||||
const rawCodeVerifier = req.cookies.gsc_code_verifier ?? null;
|
||||
const rawProjectId = req.cookies.gsc_project_id ?? null;
|
||||
|
||||
const storedStateResult =
|
||||
rawStoredState !== null ? req.unsignCookie(rawStoredState) : null;
|
||||
const codeVerifierResult =
|
||||
rawCodeVerifier !== null ? req.unsignCookie(rawCodeVerifier) : null;
|
||||
const projectIdResult =
|
||||
rawProjectId !== null ? req.unsignCookie(rawProjectId) : null;
|
||||
|
||||
if (
|
||||
!(
|
||||
storedStateResult?.value &&
|
||||
codeVerifierResult?.value &&
|
||||
projectIdResult?.value
|
||||
)
|
||||
) {
|
||||
throw new LogError('Missing GSC OAuth cookies', {
|
||||
storedState: !storedStateResult?.value,
|
||||
codeVerifier: !codeVerifierResult?.value,
|
||||
projectId: !projectIdResult?.value,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
!(
|
||||
storedStateResult?.valid &&
|
||||
codeVerifierResult?.valid &&
|
||||
projectIdResult?.valid
|
||||
)
|
||||
) {
|
||||
throw new LogError('Invalid GSC OAuth cookies', {
|
||||
storedState: !storedStateResult?.value,
|
||||
codeVerifier: !codeVerifierResult?.value,
|
||||
projectId: !projectIdResult?.value,
|
||||
});
|
||||
}
|
||||
|
||||
const stateStr = storedStateResult?.value;
|
||||
const codeVerifierStr = codeVerifierResult?.value;
|
||||
const projectIdStr = projectIdResult?.value;
|
||||
|
||||
if (state !== stateStr) {
|
||||
throw new LogError('GSC OAuth state mismatch', {
|
||||
hasState: true,
|
||||
hasStoredState: true,
|
||||
stateMismatch: true,
|
||||
});
|
||||
}
|
||||
|
||||
const tokens = await googleGsc.validateAuthorizationCode(
|
||||
code,
|
||||
codeVerifierStr
|
||||
);
|
||||
|
||||
const accessToken = tokens.accessToken();
|
||||
const refreshToken = tokens.hasRefreshToken()
|
||||
? tokens.refreshToken()
|
||||
: null;
|
||||
const accessTokenExpiresAt = tokens.accessTokenExpiresAt();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new LogError('No refresh token returned from Google GSC OAuth');
|
||||
}
|
||||
|
||||
const project = await db.project.findUnique({
|
||||
where: { id: projectIdStr },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new LogError('Project not found for GSC connection', {
|
||||
projectId: projectIdStr,
|
||||
});
|
||||
}
|
||||
|
||||
await db.gscConnection.upsert({
|
||||
where: { projectId: projectIdStr },
|
||||
create: {
|
||||
projectId: projectIdStr,
|
||||
accessToken: encrypt(accessToken),
|
||||
refreshToken: encrypt(refreshToken),
|
||||
accessTokenExpiresAt,
|
||||
siteUrl: '',
|
||||
},
|
||||
update: {
|
||||
accessToken: encrypt(accessToken),
|
||||
refreshToken: encrypt(refreshToken),
|
||||
accessTokenExpiresAt,
|
||||
lastSyncStatus: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
});
|
||||
|
||||
reply.clearCookie('gsc_oauth_state');
|
||||
reply.clearCookie('gsc_code_verifier');
|
||||
reply.clearCookie('gsc_project_id');
|
||||
|
||||
const dashboardUrl =
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!;
|
||||
const redirectUrl = `${dashboardUrl}/${project.organizationId}/${projectIdStr}/settings/gsc`;
|
||||
return reply.redirect(redirectUrl);
|
||||
} catch (error) {
|
||||
req.log.error(error);
|
||||
reply.clearCookie('gsc_oauth_state');
|
||||
reply.clearCookie('gsc_code_verifier');
|
||||
reply.clearCookie('gsc_project_id');
|
||||
return redirectWithError(reply, error);
|
||||
}
|
||||
}
|
||||
|
||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||
const url = new URL(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
url.pathname = '/login';
|
||||
if (error instanceof LogError) {
|
||||
url.searchParams.set('error', error.message);
|
||||
} else {
|
||||
url.searchParams.set('error', 'Failed to connect Google Search Console');
|
||||
}
|
||||
url.searchParams.set('correlationId', reply.request.id);
|
||||
return reply.redirect(url.toString());
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { isShuttingDown } from '@/utils/graceful-shutdown';
|
||||
import { chQuery, db } from '@openpanel/db';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isShuttingDown } from '@/utils/graceful-shutdown';
|
||||
|
||||
// For docker compose healthcheck
|
||||
export async function healthcheck(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const redisRes = await getRedisCache().ping();
|
||||
@@ -21,6 +21,7 @@ export async function healthcheck(
|
||||
ch: chRes && chRes.length > 0,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.warn('healthcheck failed', { error });
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
@@ -41,18 +42,22 @@ export async function readiness(request: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
// Perform lightweight dependency checks for readiness
|
||||
const redisRes = await getRedisCache().ping();
|
||||
const dbRes = await db.project.findFirst();
|
||||
const dbRes = await db.$executeRaw`SELECT 1`;
|
||||
const chRes = await chQuery('SELECT 1');
|
||||
|
||||
const isReady = redisRes && dbRes && chRes;
|
||||
const isReady = redisRes;
|
||||
|
||||
if (!isReady) {
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
const res = {
|
||||
redis: redisRes === 'PONG',
|
||||
db: !!dbRes,
|
||||
ch: chRes && chRes.length > 0,
|
||||
};
|
||||
request.log.warn('dependencies not ready', res);
|
||||
return reply.status(503).send({
|
||||
ready: false,
|
||||
reason: 'dependencies not ready',
|
||||
...res,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
import { toDots } from '@openpanel/common';
|
||||
import type { IClickhouseEvent } from '@openpanel/db';
|
||||
import { TABLE_NAMES, ch, formatClickhouseDate } from '@openpanel/db';
|
||||
import { ch, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
export async function importEvents(
|
||||
request: FastifyRequest<{
|
||||
Body: IClickhouseEvent[];
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
import { getDefaultIntervalByDates } from '@openpanel/constants';
|
||||
import {
|
||||
eventBuffer,
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
import { zChartEventFilter, zRange } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { parseQueryString } from '@/utils/parse-zod-query-string';
|
||||
|
||||
const zGetMetricsQuery = z.object({
|
||||
startDate: z.string().nullish(),
|
||||
@@ -22,7 +22,7 @@ export async function getMetrics(
|
||||
Params: { projectId: string };
|
||||
Querystring: z.infer<typeof zGetMetricsQuery>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||
const parsed = zGetMetricsQuery.safeParse(parseQueryString(request.query));
|
||||
@@ -41,11 +41,11 @@ export async function getMetrics(
|
||||
await overviewService.getMetrics({
|
||||
projectId: request.params.projectId,
|
||||
filters: parsed.data.filters,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
interval: getDefaultIntervalByDates(startDate, endDate) ?? 'day',
|
||||
timezone,
|
||||
}),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function getLiveVisitors(
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
reply.send({
|
||||
visitors: await eventBuffer.getActiveVisitorCount(request.params.projectId),
|
||||
@@ -76,7 +76,7 @@ export async function getPages(
|
||||
Params: { projectId: string };
|
||||
Querystring: z.infer<typeof zGetTopPagesQuery>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(request.query, timezone);
|
||||
@@ -93,11 +93,9 @@ export async function getPages(
|
||||
return overviewService.getTopPages({
|
||||
projectId: request.params.projectId,
|
||||
filters: parsed.data.filters,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,19 +132,19 @@ const zGetOverviewGenericQuery = z.object({
|
||||
});
|
||||
|
||||
export function getOverviewGeneric(
|
||||
column: z.infer<typeof zGetOverviewGenericQuery>['column'],
|
||||
column: z.infer<typeof zGetOverviewGenericQuery>['column']
|
||||
) {
|
||||
return async (
|
||||
request: FastifyRequest<{
|
||||
Params: { projectId: string; key: string };
|
||||
Querystring: z.infer<typeof zGetOverviewGenericQuery>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) => {
|
||||
const { timezone } = await getSettingsForProject(request.params.projectId);
|
||||
const { startDate, endDate } = getChartStartEndDate(
|
||||
request.query,
|
||||
timezone,
|
||||
timezone
|
||||
);
|
||||
const parsed = zGetOverviewGenericQuery.safeParse({
|
||||
...parseQueryString(request.query),
|
||||
@@ -167,12 +165,10 @@ export function getOverviewGeneric(
|
||||
column,
|
||||
projectId: request.params.projectId,
|
||||
filters: parsed.data.filters,
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
startDate,
|
||||
endDate,
|
||||
timezone,
|
||||
cursor: parsed.data.cursor,
|
||||
limit: Math.min(parsed.data.limit, 50),
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,23 +1,10 @@
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import superjson from 'superjson';
|
||||
|
||||
import type { WebSocket } from '@fastify/websocket';
|
||||
import {
|
||||
eventBuffer,
|
||||
getProfileById,
|
||||
transformMinimalEvent,
|
||||
} from '@openpanel/db';
|
||||
import { eventBuffer } from '@openpanel/db';
|
||||
import { setSuperJson } from '@openpanel/json';
|
||||
import {
|
||||
psubscribeToPublishedEvent,
|
||||
subscribeToPublishedEvent,
|
||||
} from '@openpanel/redis';
|
||||
import { subscribeToPublishedEvent } from '@openpanel/redis';
|
||||
import { getProjectAccess } from '@openpanel/trpc';
|
||||
import { getOrganizationAccess } from '@openpanel/trpc/src/access';
|
||||
|
||||
export function getLiveEventInfo(key: string) {
|
||||
return key.split(':').slice(2) as [string, string];
|
||||
}
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export function wsVisitors(
|
||||
socket: WebSocket,
|
||||
@@ -25,32 +12,32 @@ export function wsVisitors(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const unsubscribe = subscribeToPublishedEvent('events', 'saved', (event) => {
|
||||
if (event?.projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
const sendCount = () => {
|
||||
eventBuffer
|
||||
.getActiveVisitorCount(params.projectId)
|
||||
.then((count) => {
|
||||
socket.send(String(count));
|
||||
})
|
||||
.catch(() => {
|
||||
socket.send('0');
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const punsubscribe = psubscribeToPublishedEvent(
|
||||
'__keyevent@0__:expired',
|
||||
(key) => {
|
||||
const [projectId] = getLiveEventInfo(key);
|
||||
if (projectId && projectId === params.projectId) {
|
||||
eventBuffer.getActiveVisitorCount(params.projectId).then((count) => {
|
||||
socket.send(String(count));
|
||||
});
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
'batch',
|
||||
({ projectId }) => {
|
||||
if (projectId === params.projectId) {
|
||||
sendCount();
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => {
|
||||
unsubscribe();
|
||||
punsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,18 +49,10 @@ export async function wsProjectEvents(
|
||||
};
|
||||
Querystring: {
|
||||
token?: string;
|
||||
type?: 'saved' | 'received';
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params, query } = req;
|
||||
const type = query.type || 'saved';
|
||||
|
||||
if (!['saved', 'received'].includes(type)) {
|
||||
socket.send('Invalid type');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
const { params } = req;
|
||||
|
||||
const userId = req.session?.userId;
|
||||
if (!userId) {
|
||||
@@ -87,24 +66,20 @@ export async function wsProjectEvents(
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
if (!access) {
|
||||
socket.send('No access');
|
||||
socket.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const unsubscribe = subscribeToPublishedEvent(
|
||||
'events',
|
||||
type,
|
||||
async (event) => {
|
||||
if (event.projectId === params.projectId) {
|
||||
const profile = await getProfileById(event.profileId, event.projectId);
|
||||
socket.send(
|
||||
superjson.stringify(
|
||||
access
|
||||
? {
|
||||
...event,
|
||||
profile,
|
||||
}
|
||||
: transformMinimalEvent(event),
|
||||
),
|
||||
);
|
||||
'batch',
|
||||
({ projectId, count }) => {
|
||||
if (projectId === params.projectId) {
|
||||
socket.send(setSuperJson({ count }));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -116,7 +91,7 @@ export async function wsProjectNotifications(
|
||||
Params: {
|
||||
projectId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -143,9 +118,9 @@ export async function wsProjectNotifications(
|
||||
'created',
|
||||
(notification) => {
|
||||
if (notification.projectId === params.projectId) {
|
||||
socket.send(superjson.stringify(notification));
|
||||
socket.send(setSuperJson(notification));
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
@@ -157,7 +132,7 @@ export async function wsOrganizationEvents(
|
||||
Params: {
|
||||
organizationId: string;
|
||||
};
|
||||
}>,
|
||||
}>
|
||||
) {
|
||||
const { params } = req;
|
||||
const userId = req.session?.userId;
|
||||
@@ -184,7 +159,7 @@ export async function wsOrganizationEvents(
|
||||
'subscription_updated',
|
||||
(message) => {
|
||||
socket.send(setSuperJson(message));
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
|
||||
68
apps/api/src/controllers/logs.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getSalts } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import { type LogsQueuePayload, logsQueue } from '@openpanel/queue';
|
||||
import { type ILogBatchPayload, zLogBatchPayload } from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getDeviceId } from '@/utils/ids';
|
||||
import { getStringHeaders } from './track.controller';
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{ Body: ILogBatchPayload }>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
return reply.status(400).send({ status: 400, error: 'Missing projectId' });
|
||||
}
|
||||
|
||||
const validationResult = zLogBatchPayload.safeParse(request.body);
|
||||
if (!validationResult.success) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Validation failed',
|
||||
errors: validationResult.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { logs } = validationResult.data;
|
||||
|
||||
const ip = request.clientIp;
|
||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||
const headers = getStringHeaders(request.headers);
|
||||
const receivedAt = new Date().toISOString();
|
||||
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
const { deviceId, sessionId } = await getDeviceId({ projectId, ip, ua, salts });
|
||||
const uaInfo = parseUserAgent(ua, undefined);
|
||||
|
||||
const jobs: LogsQueuePayload[] = logs.map((log) => ({
|
||||
type: 'incomingLog' as const,
|
||||
payload: {
|
||||
projectId,
|
||||
log: {
|
||||
...log,
|
||||
timestamp: log.timestamp ?? receivedAt,
|
||||
},
|
||||
uaInfo,
|
||||
geo: {
|
||||
country: geo.country,
|
||||
city: geo.city,
|
||||
region: geo.region,
|
||||
},
|
||||
headers,
|
||||
deviceId,
|
||||
sessionId,
|
||||
},
|
||||
}));
|
||||
|
||||
await logsQueue.addBulk(
|
||||
jobs.map((job) => ({
|
||||
name: 'incomingLog',
|
||||
data: job,
|
||||
})),
|
||||
);
|
||||
|
||||
return reply.status(200).send({ ok: true, count: logs.length });
|
||||
}
|
||||
643
apps/api/src/controllers/manage.controller.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { stripTrailingSlash } from '@openpanel/common';
|
||||
import { hashPassword } from '@openpanel/common/server';
|
||||
import {
|
||||
db,
|
||||
getClientByIdCached,
|
||||
getId,
|
||||
getProjectByIdCached,
|
||||
} from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
|
||||
// Validation schemas
|
||||
const zCreateProject = z.object({
|
||||
name: z.string().min(1),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).default([]),
|
||||
crossDomain: z.boolean().optional().default(false),
|
||||
types: z
|
||||
.array(z.enum(['website', 'app', 'backend']))
|
||||
.optional()
|
||||
.default([]),
|
||||
});
|
||||
|
||||
const zUpdateProject = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
domain: z.string().url().or(z.literal('')).or(z.null()).optional(),
|
||||
cors: z.array(z.string()).optional(),
|
||||
crossDomain: z.boolean().optional(),
|
||||
allowUnsafeRevenueTracking: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const zCreateClient = z.object({
|
||||
name: z.string().min(1),
|
||||
projectId: z.string().optional(),
|
||||
type: z.enum(['read', 'write', 'root']).optional().default('write'),
|
||||
});
|
||||
|
||||
const zUpdateClient = z.object({
|
||||
name: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const zCreateReference = z.object({
|
||||
projectId: z.string(),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string(),
|
||||
});
|
||||
|
||||
const zUpdateReference = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
datetime: z.string().optional(),
|
||||
});
|
||||
|
||||
// Projects CRUD
|
||||
export async function listProjects(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
deleteAt: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: projects });
|
||||
}
|
||||
|
||||
export async function getProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function createProject(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateProject> }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, domain, cors, crossDomain, types } = parsed.data;
|
||||
|
||||
// Generate a default client secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
const clientData = {
|
||||
organizationId: request.client!.organizationId,
|
||||
name: 'First client',
|
||||
type: 'write' as const,
|
||||
secret: await hashPassword(secret),
|
||||
};
|
||||
|
||||
const project = await db.project.create({
|
||||
data: {
|
||||
id: await getId('project', name),
|
||||
organizationId: request.client!.organizationId,
|
||||
name,
|
||||
domain: domain ? stripTrailingSlash(domain) : null,
|
||||
cors: cors.map((c) => stripTrailingSlash(c)),
|
||||
crossDomain: crossDomain ?? false,
|
||||
allowUnsafeRevenueTracking: false,
|
||||
filters: [],
|
||||
types,
|
||||
clients: {
|
||||
create: clientData,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
...project.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
]);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...project,
|
||||
client: project.clients[0]
|
||||
? {
|
||||
id: project.clients[0].id,
|
||||
secret,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateProject(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateProject>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateProject.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify project exists and belongs to organization
|
||||
const existing = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
include: {
|
||||
clients: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
if (parsed.data.domain !== undefined) {
|
||||
updateData.domain = parsed.data.domain
|
||||
? stripTrailingSlash(parsed.data.domain)
|
||||
: null;
|
||||
}
|
||||
if (parsed.data.cors !== undefined) {
|
||||
updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c));
|
||||
}
|
||||
if (parsed.data.crossDomain !== undefined) {
|
||||
updateData.crossDomain = parsed.data.crossDomain;
|
||||
}
|
||||
if (parsed.data.allowUnsafeRevenueTracking !== undefined) {
|
||||
updateData.allowUnsafeRevenueTracking =
|
||||
parsed.data.allowUnsafeRevenueTracking;
|
||||
}
|
||||
|
||||
const project = await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
getProjectByIdCached.clear(project.id),
|
||||
...existing.clients.map((client) => getClientByIdCached.clear(client.id)),
|
||||
]);
|
||||
|
||||
reply.send({ data: project });
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.project.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: {
|
||||
deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
await getProjectByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// Clients CRUD
|
||||
export async function listClients(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const where: any = {
|
||||
organizationId: request.client!.organizationId,
|
||||
};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
}
|
||||
|
||||
const clients = await db.client.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: clients });
|
||||
}
|
||||
|
||||
export async function getClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function createClient(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateClient> }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { name, projectId, type } = parsed.data;
|
||||
|
||||
// If projectId is provided, verify it belongs to organization
|
||||
if (projectId) {
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Generate secret
|
||||
const secret = `sec_${crypto.randomBytes(10).toString('hex')}`;
|
||||
|
||||
const client = await db.client.create({
|
||||
data: {
|
||||
organizationId: request.client!.organizationId,
|
||||
projectId: projectId || null,
|
||||
name,
|
||||
type: type || 'write',
|
||||
secret: await hashPassword(secret),
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({
|
||||
data: {
|
||||
...client,
|
||||
secret, // Return plain secret only once
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateClient(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateClient>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateClient.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify client exists and belongs to organization
|
||||
const existing = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.name !== undefined) {
|
||||
updateData.name = parsed.data.name;
|
||||
}
|
||||
|
||||
const client = await db.client.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(client.id);
|
||||
|
||||
reply.send({ data: client });
|
||||
}
|
||||
|
||||
export async function deleteClient(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const client = await db.client.findFirst({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!client) {
|
||||
throw new HttpError('Client not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.client.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
await getClientByIdCached.clear(request.params.id);
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
|
||||
// References CRUD
|
||||
export async function listReferences(
|
||||
request: FastifyRequest<{ Querystring: { projectId?: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const where: any = {};
|
||||
|
||||
if (request.query.projectId) {
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: request.query.projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
where.projectId = request.query.projectId;
|
||||
} else {
|
||||
// If no projectId, get all projects in org and filter references
|
||||
const projects = await db.project.findMany({
|
||||
where: {
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
where.projectId = {
|
||||
in: projects.map((p) => p.id),
|
||||
};
|
||||
}
|
||||
|
||||
const references = await db.reference.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: references });
|
||||
}
|
||||
|
||||
export async function getReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function createReference(
|
||||
request: FastifyRequest<{ Body: z.infer<typeof zCreateReference> }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zCreateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const { projectId, title, description, datetime } = parsed.data;
|
||||
|
||||
// Verify project belongs to organization
|
||||
const project = await db.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
organizationId: request.client!.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new HttpError('Project not found', { status: 404 });
|
||||
}
|
||||
|
||||
const reference = await db.reference.create({
|
||||
data: {
|
||||
projectId,
|
||||
title,
|
||||
description: description || null,
|
||||
date: new Date(datetime),
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function updateReference(
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: z.infer<typeof zUpdateReference>;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsed = zUpdateReference.safeParse(request.body);
|
||||
|
||||
if (parsed.success === false) {
|
||||
return reply.status(400).send({
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid request body',
|
||||
details: parsed.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
// Verify reference exists and belongs to organization
|
||||
const existing = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!existing) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (existing.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
const updateData: any = {};
|
||||
if (parsed.data.title !== undefined) {
|
||||
updateData.title = parsed.data.title;
|
||||
}
|
||||
if (parsed.data.description !== undefined) {
|
||||
updateData.description = parsed.data.description ?? null;
|
||||
}
|
||||
if (parsed.data.datetime !== undefined) {
|
||||
updateData.date = new Date(parsed.data.datetime);
|
||||
}
|
||||
|
||||
const reference = await db.reference.update({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
reply.send({ data: reference });
|
||||
}
|
||||
|
||||
export async function deleteReference(
|
||||
request: FastifyRequest<{ Params: { id: string } }>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const reference = await db.reference.findUnique({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
select: {
|
||||
organizationId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!reference) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
if (reference.project.organizationId !== request.client!.organizationId) {
|
||||
throw new HttpError('Reference not found', { status: 404 });
|
||||
}
|
||||
|
||||
await db.reference.delete({
|
||||
where: {
|
||||
id: request.params.id,
|
||||
},
|
||||
});
|
||||
|
||||
reply.send({ success: true });
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import sharp from 'sharp';
|
||||
|
||||
import {
|
||||
DEFAULT_IP_HEADER_ORDER,
|
||||
getClientIpFromHeaders,
|
||||
} from '@openpanel/common/server/get-client-ip';
|
||||
import { TABLE_NAMES, ch, chQuery, formatClickhouseDate } from '@openpanel/db';
|
||||
import { ch, chQuery, formatClickhouseDate, TABLE_NAMES } from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getCache, getRedisCache } from '@openpanel/redis';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import sharp from 'sharp';
|
||||
import { logger } from '@/utils/logger';
|
||||
import { parseUrlMeta } from '@/utils/parseUrlMeta';
|
||||
|
||||
interface GetFaviconParams {
|
||||
url: string;
|
||||
@@ -29,7 +28,9 @@ function createCacheKey(url: string, prefix = 'favicon'): string {
|
||||
|
||||
function validateUrl(raw?: string): URL | null {
|
||||
try {
|
||||
if (!raw) throw new Error('Missing ?url');
|
||||
if (!raw) {
|
||||
throw new Error('Missing ?url');
|
||||
}
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error('Only http/https URLs are allowed');
|
||||
@@ -42,7 +43,7 @@ function validateUrl(raw?: string): URL | null {
|
||||
|
||||
// Binary cache functions (more efficient than base64)
|
||||
async function getFromCacheBinary(
|
||||
key: string,
|
||||
key: string
|
||||
): Promise<{ buffer: Buffer; contentType: string } | null> {
|
||||
const redis = getRedisCache();
|
||||
const [bufferBase64, contentType] = await Promise.all([
|
||||
@@ -50,14 +51,16 @@ async function getFromCacheBinary(
|
||||
redis.get(`${key}:ctype`),
|
||||
]);
|
||||
|
||||
if (!bufferBase64 || !contentType) return null;
|
||||
if (!(bufferBase64 && contentType)) {
|
||||
return null;
|
||||
}
|
||||
return { buffer: Buffer.from(bufferBase64, 'base64'), contentType };
|
||||
}
|
||||
|
||||
async function setToCacheBinary(
|
||||
key: string,
|
||||
buffer: Buffer,
|
||||
contentType: string,
|
||||
contentType: string
|
||||
): Promise<void> {
|
||||
const redis = getRedisCache();
|
||||
await Promise.all([
|
||||
@@ -68,10 +71,10 @@ async function setToCacheBinary(
|
||||
|
||||
// Fetch image with timeout and size limits
|
||||
async function fetchImage(
|
||||
url: URL,
|
||||
url: URL
|
||||
): Promise<{ buffer: Buffer; contentType: string; status: number }> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000); // 10s timeout
|
||||
const timeout = setTimeout(() => controller.abort(), 1000); // 10s timeout
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
@@ -118,7 +121,11 @@ async function fetchImage(
|
||||
|
||||
// Check if URL is an ICO file
|
||||
function isIcoFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.ico') || contentType === 'image/x-icon';
|
||||
return (
|
||||
url.toLowerCase().endsWith('.ico') ||
|
||||
contentType === 'image/x-icon' ||
|
||||
contentType === 'image/vnd.microsoft.icon'
|
||||
);
|
||||
}
|
||||
function isSvgFile(url: string, contentType?: string): boolean {
|
||||
return url.toLowerCase().endsWith('.svg') || contentType === 'image/svg+xml';
|
||||
@@ -128,7 +135,7 @@ function isSvgFile(url: string, contentType?: string): boolean {
|
||||
async function processImage(
|
||||
buffer: Buffer,
|
||||
originalUrl?: string,
|
||||
contentType?: string,
|
||||
contentType?: string
|
||||
): Promise<Buffer> {
|
||||
// If it's an ICO file, just return it as-is (no conversion needed)
|
||||
if (originalUrl && isIcoFile(originalUrl, contentType)) {
|
||||
@@ -171,28 +178,18 @@ async function processImage(
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
// If Sharp fails, try to create a simple fallback image
|
||||
return createFallbackImage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create a simple transparent fallback image when Sharp can't process the original
|
||||
function createFallbackImage(): Buffer {
|
||||
// 1x1 transparent PNG
|
||||
return Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
|
||||
'base64',
|
||||
);
|
||||
}
|
||||
|
||||
// Process OG image with Sharp (resize to 300px width)
|
||||
async function processOgImage(
|
||||
buffer: Buffer,
|
||||
originalUrl?: string,
|
||||
contentType?: string,
|
||||
contentType?: string
|
||||
): Promise<Buffer> {
|
||||
// If buffer is small enough, return it as-is
|
||||
if (buffer.length < 10000) {
|
||||
if (buffer.length < 10_000) {
|
||||
logger.debug('Serving OG image directly without processing', {
|
||||
originalUrl,
|
||||
bufferSize: buffer.length,
|
||||
@@ -216,8 +213,7 @@ async function processOgImage(
|
||||
bufferSize: buffer.length,
|
||||
});
|
||||
|
||||
// If Sharp fails, try to create a simple fallback image
|
||||
return createFallbackImage();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,12 +230,18 @@ export async function getFavicon(
|
||||
request: FastifyRequest<{
|
||||
Querystring: GetFaviconParams;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
logger.info('getFavicon', {
|
||||
url: request.query.url,
|
||||
});
|
||||
const url = validateUrl(request.query.url);
|
||||
if (!url) {
|
||||
return createFallbackImage();
|
||||
return reply
|
||||
.status(404)
|
||||
.header('Content-Type', 'text/plain')
|
||||
.send('Not found');
|
||||
}
|
||||
|
||||
const cacheKey = createCacheKey(url.toString());
|
||||
@@ -253,41 +255,110 @@ export async function getFavicon(
|
||||
}
|
||||
|
||||
let imageUrl: URL;
|
||||
|
||||
// If it's a direct image URL, use it directly
|
||||
if (isDirectImage(url)) {
|
||||
imageUrl = url;
|
||||
} else {
|
||||
logger.info('before parseUrlMeta', {
|
||||
url: url.toString(),
|
||||
});
|
||||
// For website URLs, extract favicon from HTML
|
||||
const meta = await parseUrlMeta(url.toString());
|
||||
logger.info('parseUrlMeta result', {
|
||||
url: url.toString(),
|
||||
favicon: meta?.favicon,
|
||||
});
|
||||
if (meta?.favicon) {
|
||||
imageUrl = new URL(meta.favicon);
|
||||
} else {
|
||||
// Fallback to Google's favicon service
|
||||
const { hostname } = url;
|
||||
imageUrl = new URL(
|
||||
`https://www.google.com/s2/favicons?domain=${hostname}&sz=256`,
|
||||
);
|
||||
// Try standard favicon location first
|
||||
const { origin } = url;
|
||||
imageUrl = new URL(`${origin}/favicon.ico`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the image
|
||||
const { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
logger.info('Fetching favicon', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
});
|
||||
|
||||
if (status !== 200 || buffer.length === 0) {
|
||||
return reply.send(createFallbackImage());
|
||||
// Fetch the image
|
||||
let { buffer, contentType, status } = await fetchImage(imageUrl);
|
||||
|
||||
logger.info('Favicon fetch result', {
|
||||
originalUrl: url.toString(),
|
||||
imageUrl: imageUrl.toString(),
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
|
||||
// If the direct favicon fetch failed and it's not from DuckDuckGo's service,
|
||||
// try DuckDuckGo's favicon service as a fallback
|
||||
if (buffer.length === 0 && !imageUrl.hostname.includes('duckduckgo.com')) {
|
||||
const { hostname } = url;
|
||||
const duckduckgoUrl = new URL(
|
||||
`https://icons.duckduckgo.com/ip3/${hostname}.ico`
|
||||
);
|
||||
|
||||
logger.info('Trying DuckDuckGo favicon service', {
|
||||
originalUrl: url.toString(),
|
||||
duckduckgoUrl: duckduckgoUrl.toString(),
|
||||
});
|
||||
|
||||
const duckduckgoResult = await fetchImage(duckduckgoUrl);
|
||||
buffer = duckduckgoResult.buffer;
|
||||
contentType = duckduckgoResult.contentType;
|
||||
status = duckduckgoResult.status;
|
||||
imageUrl = duckduckgoUrl;
|
||||
|
||||
logger.info('DuckDuckGo favicon result', {
|
||||
status,
|
||||
bufferLength: buffer.length,
|
||||
contentType,
|
||||
});
|
||||
}
|
||||
|
||||
// Accept any response as long as we have valid image data
|
||||
if (buffer.length === 0) {
|
||||
return reply
|
||||
.status(404)
|
||||
.header('Content-Type', 'text/plain')
|
||||
.send('Not found');
|
||||
}
|
||||
|
||||
// Process the image (resize to 30x30 PNG, or serve ICO as-is)
|
||||
const processedBuffer = await processImage(
|
||||
buffer,
|
||||
imageUrl.toString(),
|
||||
contentType,
|
||||
contentType
|
||||
);
|
||||
|
||||
logger.info('Favicon processing result', {
|
||||
originalUrl: url.toString(),
|
||||
originalBufferLength: buffer.length,
|
||||
processedBufferLength: processedBuffer.length,
|
||||
});
|
||||
|
||||
// Determine the correct content type for caching and response
|
||||
const isIco = isIcoFile(imageUrl.toString(), contentType);
|
||||
const responseContentType = isIco ? 'image/x-icon' : contentType;
|
||||
const isSvg = isSvgFile(imageUrl.toString(), contentType);
|
||||
let responseContentType = contentType;
|
||||
|
||||
if (isIco) {
|
||||
responseContentType = 'image/x-icon';
|
||||
} else if (isSvg) {
|
||||
responseContentType = 'image/svg+xml';
|
||||
} else if (
|
||||
processedBuffer.length < 5000 &&
|
||||
buffer.length === processedBuffer.length
|
||||
) {
|
||||
// Image was returned as-is, keep original content type
|
||||
responseContentType = contentType;
|
||||
} else {
|
||||
// Image was processed by Sharp, it's now a PNG
|
||||
responseContentType = 'image/png';
|
||||
}
|
||||
|
||||
// Cache the result with correct content type
|
||||
await setToCacheBinary(cacheKey, processedBuffer, responseContentType);
|
||||
@@ -312,7 +383,7 @@ export async function getFavicon(
|
||||
|
||||
export async function clearFavicons(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const redis = getRedisCache();
|
||||
const keys = await redis.keys('favicon:*');
|
||||
@@ -328,7 +399,7 @@ export async function clearFavicons(
|
||||
|
||||
export async function clearOgImages(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const redis = getRedisCache();
|
||||
const keys = await redis.keys('og:*');
|
||||
@@ -349,7 +420,7 @@ export async function ping(
|
||||
count: number;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
await ch.insert({
|
||||
@@ -381,10 +452,10 @@ export async function ping(
|
||||
export async function stats(request: FastifyRequest, reply: FastifyReply) {
|
||||
const res = await getCache('api:stats', 60 * 60, async () => {
|
||||
const projects = await chQuery<{ project_id: string; count: number }>(
|
||||
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`,
|
||||
`SELECT project_id, count(*) as count from ${TABLE_NAMES.events} GROUP by project_id order by count()`
|
||||
);
|
||||
const last24h = await chQuery<{ count: number }>(
|
||||
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`,
|
||||
`SELECT count(*) as count from ${TABLE_NAMES.events} WHERE created_at > now() - interval '24 hours'`
|
||||
);
|
||||
return { projects, last24hCount: last24h[0]?.count || 0 };
|
||||
});
|
||||
@@ -406,7 +477,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
ip,
|
||||
geo: await getGeoLocation(ip),
|
||||
};
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
if (!ip) {
|
||||
@@ -424,7 +495,7 @@ export async function getGeo(request: FastifyRequest, reply: FastifyReply) {
|
||||
acc[other.header] = other;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>,
|
||||
{} as Record<string, { ip: string; header: string; geo: GeoLocation }>
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -435,7 +506,7 @@ export async function getOgImage(
|
||||
url: string;
|
||||
};
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const url = validateUrl(request.query.url);
|
||||
@@ -479,7 +550,7 @@ export async function getOgImage(
|
||||
const processedBuffer = await processOgImage(
|
||||
buffer,
|
||||
imageUrl.toString(),
|
||||
contentType,
|
||||
contentType
|
||||
);
|
||||
|
||||
// Cache the result
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import { LogError } from '@/utils/errors';
|
||||
import {
|
||||
Arctic,
|
||||
type OAuth2Tokens,
|
||||
createSession,
|
||||
generateSessionToken,
|
||||
github,
|
||||
google,
|
||||
type OAuth2Tokens,
|
||||
setLastAuthProviderCookie,
|
||||
setSessionTokenCookie,
|
||||
} from '@openpanel/auth';
|
||||
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { LogError } from '@/utils/errors';
|
||||
|
||||
async function getGithubEmail(githubAccessToken: string) {
|
||||
const emailListRequest = new Request('https://api.github.com/user/emails');
|
||||
@@ -74,10 +75,14 @@ async function handleExistingUser({
|
||||
setSessionTokenCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
sessionToken,
|
||||
session.expiresAt,
|
||||
session.expiresAt
|
||||
);
|
||||
setLastAuthProviderCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
providerName
|
||||
);
|
||||
return reply.redirect(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
}
|
||||
|
||||
@@ -103,7 +108,7 @@ async function handleNewUser({
|
||||
existingUser,
|
||||
oauthUser,
|
||||
providerName,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,10 +143,14 @@ async function handleNewUser({
|
||||
setSessionTokenCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
sessionToken,
|
||||
session.expiresAt,
|
||||
session.expiresAt
|
||||
);
|
||||
setLastAuthProviderCookie(
|
||||
(...args) => reply.setCookie(...args),
|
||||
providerName
|
||||
);
|
||||
return reply.redirect(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,7 +228,7 @@ interface ValidatedOAuthQuery {
|
||||
|
||||
async function validateOAuthCallback(
|
||||
req: FastifyRequest,
|
||||
provider: Provider,
|
||||
provider: Provider
|
||||
): Promise<ValidatedOAuthQuery> {
|
||||
const schema = z.object({
|
||||
code: z.string(),
|
||||
@@ -353,7 +362,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
|
||||
|
||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||
const url = new URL(
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||
);
|
||||
url.pathname = '/login';
|
||||
if (error instanceof LogError) {
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr } from 'ramda';
|
||||
|
||||
import { parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, upsertProfile } from '@openpanel/db';
|
||||
import { getGeoLocation } from '@openpanel/geo';
|
||||
import type {
|
||||
IncrementProfilePayload,
|
||||
UpdateProfilePayload,
|
||||
} from '@openpanel/sdk';
|
||||
DeprecatedIncrementProfilePayload,
|
||||
DeprecatedUpdateProfilePayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr } from 'ramda';
|
||||
|
||||
export async function updateProfile(
|
||||
request: FastifyRequest<{
|
||||
Body: UpdateProfilePayload;
|
||||
Body: DeprecatedUpdateProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const payload = request.body;
|
||||
const projectId = request.client!.projectId;
|
||||
@@ -52,9 +51,9 @@ export async function updateProfile(
|
||||
|
||||
export async function incrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { profileId, property, value } = request.body;
|
||||
const projectId = request.client!.projectId;
|
||||
@@ -69,7 +68,7 @@ export async function incrementProfileProperty(
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
@@ -79,7 +78,7 @@ export async function incrementProfileProperty(
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed + value,
|
||||
profile.properties,
|
||||
profile.properties
|
||||
);
|
||||
|
||||
await upsertProfile({
|
||||
@@ -94,9 +93,9 @@ export async function incrementProfileProperty(
|
||||
|
||||
export async function decrementProfileProperty(
|
||||
request: FastifyRequest<{
|
||||
Body: IncrementProfilePayload;
|
||||
Body: DeprecatedIncrementProfilePayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const { profileId, property, value } = request.body;
|
||||
const projectId = request.client?.projectId;
|
||||
@@ -111,7 +110,7 @@ export async function decrementProfileProperty(
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
@@ -121,7 +120,7 @@ export async function decrementProfileProperty(
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed - value,
|
||||
profile.properties,
|
||||
profile.properties
|
||||
);
|
||||
|
||||
await upsertProfile({
|
||||
|
||||
@@ -1,19 +1,33 @@
|
||||
import { generateId, slug } from '@openpanel/common';
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import {
|
||||
getProfileById,
|
||||
getSalts,
|
||||
groupBuffer,
|
||||
replayBuffer,
|
||||
upsertProfile,
|
||||
} from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import {
|
||||
type EventsQueuePayloadIncomingEvent,
|
||||
getEventsGroupQueueShard,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import {
|
||||
type IAssignGroupPayload,
|
||||
type IDecrementPayload,
|
||||
type IGroupPayload,
|
||||
type IIdentifyPayload,
|
||||
type IIncrementPayload,
|
||||
type IReplayPayload,
|
||||
type ITrackHandlerPayload,
|
||||
type ITrackPayload,
|
||||
zTrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { assocPath, pathOr, pick } from 'ramda';
|
||||
|
||||
import { generateId } from '@openpanel/common';
|
||||
import { generateDeviceId, parseUserAgent } from '@openpanel/common/server';
|
||||
import { getProfileById, getSalts, upsertProfile } from '@openpanel/db';
|
||||
import { type GeoLocation, getGeoLocation } from '@openpanel/geo';
|
||||
import { getEventsGroupQueueShard } from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import type {
|
||||
DecrementPayload,
|
||||
IdentifyPayload,
|
||||
IncrementPayload,
|
||||
TrackHandlerPayload,
|
||||
TrackPayload,
|
||||
} from '@openpanel/sdk';
|
||||
import { HttpError } from '@/utils/errors';
|
||||
import { getDeviceId } from '@/utils/ids';
|
||||
|
||||
export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
return Object.entries(
|
||||
@@ -25,36 +39,40 @@ export function getStringHeaders(headers: FastifyRequest['headers']) {
|
||||
'openpanel-client-id',
|
||||
'request-id',
|
||||
],
|
||||
headers,
|
||||
),
|
||||
headers
|
||||
)
|
||||
).reduce(
|
||||
(acc, [key, value]) => ({
|
||||
...acc,
|
||||
[key]: value ? String(value) : undefined,
|
||||
}),
|
||||
{},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
function getIdentity(body: TrackHandlerPayload): IdentifyPayload | undefined {
|
||||
const identity =
|
||||
'properties' in body.payload
|
||||
? (body.payload?.properties?.__identify as IdentifyPayload | undefined)
|
||||
: undefined;
|
||||
function getIdentity(body: ITrackHandlerPayload): IIdentifyPayload | undefined {
|
||||
if (body.type === 'track') {
|
||||
const identity = body.payload.properties?.__identify as
|
||||
| IIdentifyPayload
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
identity ||
|
||||
(body?.payload?.profileId
|
||||
if (identity) {
|
||||
return identity;
|
||||
}
|
||||
|
||||
return body.payload.profileId
|
||||
? {
|
||||
profileId: body.payload.profileId,
|
||||
profileId: String(body.payload.profileId),
|
||||
}
|
||||
: undefined)
|
||||
);
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getTimestamp(
|
||||
timestamp: FastifyRequest['timestamp'],
|
||||
payload: TrackHandlerPayload['payload'],
|
||||
payload: ITrackHandlerPayload['payload']
|
||||
) {
|
||||
const safeTimestamp = timestamp || Date.now();
|
||||
const userDefinedTimestamp =
|
||||
@@ -81,7 +99,7 @@ export function getTimestamp(
|
||||
return { timestamp: safeTimestamp, isTimestampFromThePast: false };
|
||||
}
|
||||
|
||||
// isTimestampFromThePast is true only if timestamp is older than 1 hour
|
||||
// isTimestampFromThePast is true only if timestamp is older than 15 minutes
|
||||
const isTimestampFromThePast =
|
||||
clientTimestampNumber < safeTimestamp - FIFTEEN_MINUTES_MS;
|
||||
|
||||
@@ -91,203 +109,141 @@ export function getTimestamp(
|
||||
};
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: TrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) {
|
||||
const timestamp = getTimestamp(request.timestamp, request.body.payload);
|
||||
const ip =
|
||||
'properties' in request.body.payload &&
|
||||
request.body.payload.properties?.__ip
|
||||
? (request.body.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'];
|
||||
const projectId = request.client?.projectId;
|
||||
|
||||
if (!projectId) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Missing projectId',
|
||||
});
|
||||
}
|
||||
|
||||
const identity = getIdentity(request.body);
|
||||
const profileId = identity?.profileId;
|
||||
const overrideDeviceId = (() => {
|
||||
const deviceId =
|
||||
'properties' in request.body.payload
|
||||
? request.body.payload.properties?.__deviceId
|
||||
: undefined;
|
||||
if (typeof deviceId === 'string') {
|
||||
return deviceId;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
|
||||
// We might get a profileId from the alias table
|
||||
// If we do, we should use that instead of the one from the payload
|
||||
if (profileId) {
|
||||
request.body.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
switch (request.body.type) {
|
||||
case 'track': {
|
||||
const [salts, geo] = await Promise.all([getSalts(), getGeoLocation(ip)]);
|
||||
const currentDeviceId =
|
||||
overrideDeviceId ||
|
||||
(ua
|
||||
? generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '');
|
||||
const previousDeviceId = ua
|
||||
? generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
})
|
||||
: '';
|
||||
|
||||
const promises = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (identity && Object.keys(identity).length > 1) {
|
||||
promises.push(
|
||||
identify({
|
||||
payload: identity,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
promises.push(
|
||||
track({
|
||||
payload: request.body.payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers: getStringHeaders(request.headers),
|
||||
timestamp: timestamp.timestamp,
|
||||
isTimestampFromThePast: timestamp.isTimestampFromThePast,
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
break;
|
||||
}
|
||||
case 'identify': {
|
||||
const geo = await getGeoLocation(ip);
|
||||
await identify({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'alias': {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
case 'increment': {
|
||||
await increment({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'decrement': {
|
||||
await decrement({
|
||||
payload: request.body.payload,
|
||||
projectId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reply.status(200).send();
|
||||
interface TrackContext {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua?: string;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: { value: number; isFromPast: boolean };
|
||||
identity?: IIdentifyPayload;
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
geo: GeoLocation;
|
||||
}
|
||||
|
||||
async function track({
|
||||
payload,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
projectId,
|
||||
geo,
|
||||
headers,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
}: {
|
||||
payload: TrackPayload;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
headers: Record<string, string | undefined>;
|
||||
timestamp: number;
|
||||
isTimestampFromThePast: boolean;
|
||||
}) {
|
||||
async function buildContext(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
validatedBody: ITrackHandlerPayload
|
||||
): Promise<TrackContext> {
|
||||
const projectId = request.client?.projectId;
|
||||
if (!projectId) {
|
||||
throw new HttpError('Missing projectId', { status: 400 });
|
||||
}
|
||||
|
||||
const timestamp = getTimestamp(request.timestamp, validatedBody.payload);
|
||||
const ip =
|
||||
validatedBody.type === 'track' && validatedBody.payload.properties?.__ip
|
||||
? (validatedBody.payload.properties.__ip as string)
|
||||
: request.clientIp;
|
||||
const ua = request.headers['user-agent'] ?? 'unknown/1.0';
|
||||
|
||||
const headers = getStringHeaders(request.headers);
|
||||
const identity = getIdentity(validatedBody);
|
||||
const profileId = identity?.profileId;
|
||||
|
||||
if (profileId && validatedBody.type === 'track') {
|
||||
validatedBody.payload.profileId = profileId;
|
||||
}
|
||||
|
||||
const overrideDeviceId =
|
||||
validatedBody.type === 'track' &&
|
||||
typeof validatedBody.payload?.properties?.__deviceId === 'string'
|
||||
? validatedBody.payload?.properties.__deviceId
|
||||
: undefined;
|
||||
|
||||
// Get geo location (needed for track and identify)
|
||||
const [geo, salts] = await Promise.all([getGeoLocation(ip), getSalts()]);
|
||||
|
||||
const deviceIdResult = await getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId,
|
||||
});
|
||||
|
||||
return {
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
headers,
|
||||
timestamp: {
|
||||
value: timestamp.timestamp,
|
||||
isFromPast: timestamp.isTimestampFromThePast,
|
||||
},
|
||||
identity,
|
||||
deviceId: deviceIdResult.deviceId,
|
||||
sessionId: deviceIdResult.sessionId,
|
||||
session: deviceIdResult.session,
|
||||
geo,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleTrack(
|
||||
payload: ITrackPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { projectId, deviceId, geo, headers, timestamp, sessionId, session } =
|
||||
context;
|
||||
|
||||
const uaInfo = parseUserAgent(headers['user-agent'], payload.properties);
|
||||
const groupId = uaInfo.isServer
|
||||
? payload.profileId
|
||||
? `${projectId}:${payload.profileId}`
|
||||
: `${projectId}:${generateId()}`
|
||||
: currentDeviceId;
|
||||
const jobId = [payload.name, timestamp, projectId, currentDeviceId, groupId]
|
||||
: undefined
|
||||
: deviceId;
|
||||
const jobId = [
|
||||
slug(payload.name),
|
||||
timestamp.value,
|
||||
projectId,
|
||||
deviceId,
|
||||
groupId,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('-');
|
||||
await getEventsGroupQueueShard(groupId).add({
|
||||
orderMs: timestamp,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
timestamp,
|
||||
isTimestampFromThePast,
|
||||
|
||||
const promises: Promise<unknown>[] = [];
|
||||
|
||||
// If we have more than one property in the identity object, we should identify the user
|
||||
// Otherwise its only a profileId and we should not identify the user
|
||||
if (context.identity && Object.keys(context.identity).length > 1) {
|
||||
promises.push(handleIdentify(context.identity, context));
|
||||
}
|
||||
|
||||
promises.push(
|
||||
getEventsGroupQueueShard(groupId || generateId()).add({
|
||||
orderMs: timestamp.value,
|
||||
data: {
|
||||
projectId,
|
||||
headers,
|
||||
event: {
|
||||
...payload,
|
||||
groups: payload.groups ?? [],
|
||||
timestamp: timestamp.value,
|
||||
isTimestampFromThePast: timestamp.isFromPast,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
deviceId,
|
||||
sessionId,
|
||||
session,
|
||||
},
|
||||
uaInfo,
|
||||
geo,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
},
|
||||
groupId,
|
||||
jobId,
|
||||
});
|
||||
groupId,
|
||||
jobId,
|
||||
})
|
||||
);
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
async function identify({
|
||||
payload,
|
||||
projectId,
|
||||
geo,
|
||||
ua,
|
||||
}: {
|
||||
payload: IdentifyPayload;
|
||||
projectId: string;
|
||||
geo: GeoLocation;
|
||||
ua?: string;
|
||||
}) {
|
||||
async function handleIdentify(
|
||||
payload: IIdentifyPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { projectId, geo, ua } = context;
|
||||
const uaInfo = parseUserAgent(ua, payload.properties);
|
||||
await upsertProfile({
|
||||
...payload,
|
||||
@@ -312,32 +268,30 @@ async function identify({
|
||||
});
|
||||
}
|
||||
|
||||
async function increment({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: IncrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
async function adjustProfileProperty(
|
||||
payload: IIncrementPayload | IDecrementPayload,
|
||||
projectId: string,
|
||||
direction: 1 | -1
|
||||
): Promise<void> {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
throw new HttpError('Profile not found', { status: 404 });
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
10
|
||||
);
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
throw new HttpError('Property value is not a number', { status: 400 });
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed + (value || 1),
|
||||
profile.properties,
|
||||
parsed + direction * (value || 1),
|
||||
profile.properties
|
||||
);
|
||||
|
||||
await upsertProfile({
|
||||
@@ -348,45 +302,142 @@ async function increment({
|
||||
});
|
||||
}
|
||||
|
||||
async function decrement({
|
||||
payload,
|
||||
projectId,
|
||||
}: {
|
||||
payload: DecrementPayload;
|
||||
projectId: string;
|
||||
}) {
|
||||
const { profileId, property, value } = payload;
|
||||
const profile = await getProfileById(profileId, projectId);
|
||||
if (!profile) {
|
||||
throw new Error('Not found');
|
||||
async function handleIncrement(
|
||||
payload: IIncrementPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, 1);
|
||||
}
|
||||
|
||||
async function handleDecrement(
|
||||
payload: IDecrementPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
await adjustProfileProperty(payload, context.projectId, -1);
|
||||
}
|
||||
|
||||
async function handleReplay(
|
||||
payload: IReplayPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
if (!context.sessionId) {
|
||||
throw new HttpError('Session ID is required for replay', { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(
|
||||
pathOr<string>('0', property.split('.'), profile.properties),
|
||||
10,
|
||||
);
|
||||
const row = {
|
||||
project_id: context.projectId,
|
||||
session_id: context.sessionId,
|
||||
chunk_index: payload.chunk_index,
|
||||
started_at: payload.started_at,
|
||||
ended_at: payload.ended_at,
|
||||
events_count: payload.events_count,
|
||||
is_full_snapshot: payload.is_full_snapshot,
|
||||
payload: payload.payload,
|
||||
};
|
||||
await replayBuffer.add(row);
|
||||
}
|
||||
|
||||
if (Number.isNaN(parsed)) {
|
||||
throw new Error('Not number');
|
||||
async function handleGroup(
|
||||
payload: IGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const { id, type, name, properties = {} } = payload;
|
||||
await groupBuffer.add({
|
||||
id,
|
||||
projectId: context.projectId,
|
||||
type,
|
||||
name,
|
||||
properties,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAssignGroup(
|
||||
payload: IAssignGroupPayload,
|
||||
context: TrackContext
|
||||
): Promise<void> {
|
||||
const profileId = payload.profileId ?? context.deviceId;
|
||||
if (!profileId) {
|
||||
return;
|
||||
}
|
||||
|
||||
profile.properties = assocPath(
|
||||
property.split('.'),
|
||||
parsed - (value || 1),
|
||||
profile.properties,
|
||||
);
|
||||
|
||||
await upsertProfile({
|
||||
id: profile.id,
|
||||
projectId,
|
||||
properties: profile.properties,
|
||||
isExternal: true,
|
||||
id: String(profileId),
|
||||
projectId: context.projectId,
|
||||
isExternal: !!payload.profileId,
|
||||
groups: payload.groupIds,
|
||||
});
|
||||
}
|
||||
|
||||
export async function handler(
|
||||
request: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}>,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
// Validate request body with Zod
|
||||
const validationResult = zTrackHandlerPayload.safeParse(request.body);
|
||||
if (!validationResult.success) {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Validation failed',
|
||||
errors: validationResult.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
const validatedBody = validationResult.data;
|
||||
|
||||
// Handle alias (not supported)
|
||||
if (validatedBody.type === 'alias') {
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Alias is not supported',
|
||||
});
|
||||
}
|
||||
|
||||
// Build request context
|
||||
const context = await buildContext(request, validatedBody);
|
||||
|
||||
// Dispatch to appropriate handler
|
||||
switch (validatedBody.type) {
|
||||
case 'track':
|
||||
await handleTrack(validatedBody.payload, context);
|
||||
break;
|
||||
case 'identify':
|
||||
await handleIdentify(validatedBody.payload, context);
|
||||
break;
|
||||
case 'increment':
|
||||
await handleIncrement(validatedBody.payload, context);
|
||||
break;
|
||||
case 'decrement':
|
||||
await handleDecrement(validatedBody.payload, context);
|
||||
break;
|
||||
case 'replay':
|
||||
await handleReplay(validatedBody.payload, context);
|
||||
break;
|
||||
case 'group':
|
||||
await handleGroup(validatedBody.payload, context);
|
||||
break;
|
||||
case 'assign_group':
|
||||
await handleAssignGroup(validatedBody.payload, context);
|
||||
break;
|
||||
default:
|
||||
return reply.status(400).send({
|
||||
status: 400,
|
||||
error: 'Bad Request',
|
||||
message: 'Invalid type',
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send({
|
||||
deviceId: context.deviceId,
|
||||
sessionId: context.sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchDeviceId(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const salts = await getSalts();
|
||||
const projectId = request.client?.projectId;
|
||||
@@ -419,20 +470,31 @@ export async function fetchDeviceId(
|
||||
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`);
|
||||
multi.exists(`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`);
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
const res = await multi.exec();
|
||||
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = JSON.parse(res?.[0]?.[1] as string);
|
||||
const sessionId = data.payload.sessionId;
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
sessionId,
|
||||
message: 'current session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = JSON.parse(res?.[1]?.[1] as string);
|
||||
const sessionId = data.payload.sessionId;
|
||||
return reply.status(200).send({
|
||||
deviceId: previousDeviceId,
|
||||
sessionId,
|
||||
message: 'previous session exists for this device id',
|
||||
});
|
||||
}
|
||||
@@ -442,6 +504,7 @@ export async function fetchDeviceId(
|
||||
|
||||
return reply.status(200).send({
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: '',
|
||||
message: 'No session exists for this device id',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { dirname } from 'node:path';
|
||||
import path, { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
import { db, getOrganizationByProjectIdCached } from '@openpanel/db';
|
||||
import {
|
||||
sendSlackNotification,
|
||||
slackInstaller,
|
||||
} from '@openpanel/integrations/src/slack';
|
||||
import {
|
||||
PolarWebhookVerificationError,
|
||||
getProduct,
|
||||
PolarWebhookVerificationError,
|
||||
validatePolarEvent,
|
||||
} from '@openpanel/payments';
|
||||
import { publishEvent } from '@openpanel/redis';
|
||||
@@ -34,7 +34,7 @@ export async function slackWebhook(
|
||||
request: FastifyRequest<{
|
||||
Querystring: unknown;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const parsedParams = paramsSchema.safeParse(request.query);
|
||||
|
||||
@@ -45,10 +45,10 @@ export async function slackWebhook(
|
||||
|
||||
const veryfiedState = await slackInstaller.stateStore?.verifyStateParam(
|
||||
new Date(),
|
||||
parsedParams.data.state,
|
||||
parsedParams.data.state
|
||||
);
|
||||
const parsedMetadata = metadataSchema.safeParse(
|
||||
JSON.parse(veryfiedState?.metadata ?? '{}'),
|
||||
JSON.parse(veryfiedState?.metadata ?? '{}')
|
||||
);
|
||||
|
||||
if (!parsedMetadata.success) {
|
||||
@@ -75,7 +75,7 @@ export async function slackWebhook(
|
||||
zod: parsedJson,
|
||||
json,
|
||||
},
|
||||
'Failed to parse slack auth response',
|
||||
'Failed to parse slack auth response'
|
||||
);
|
||||
const html = fs.readFileSync(path.join(__dirname, 'error.html'), 'utf8');
|
||||
return reply.status(500).header('Content-Type', 'text/html').send(html);
|
||||
@@ -104,7 +104,7 @@ export async function slackWebhook(
|
||||
});
|
||||
|
||||
return reply.redirect(
|
||||
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`,
|
||||
`${process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL}/${organizationId}/integrations/installed`
|
||||
);
|
||||
} catch (err) {
|
||||
request.log.error(err);
|
||||
@@ -128,13 +128,13 @@ export async function polarWebhook(
|
||||
request: FastifyRequest<{
|
||||
Querystring: unknown;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const event = validatePolarEvent(
|
||||
request.rawBody!,
|
||||
request.headers as Record<string, string>,
|
||||
process.env.POLAR_WEBHOOK_SECRET ?? '',
|
||||
process.env.POLAR_WEBHOOK_SECRET ?? ''
|
||||
);
|
||||
|
||||
switch (event.type) {
|
||||
@@ -169,6 +169,11 @@ export async function polarWebhook(
|
||||
.parse(event.data.metadata);
|
||||
|
||||
const product = await getProduct(event.data.productId);
|
||||
const organization = await db.organization.findUniqueOrThrow({
|
||||
where: {
|
||||
id: metadata.organizationId,
|
||||
},
|
||||
});
|
||||
const eventsLimit = product.metadata?.eventsLimit;
|
||||
const subscriptionPeriodEventsLimit =
|
||||
typeof eventsLimit === 'number' ? eventsLimit : undefined;
|
||||
@@ -186,7 +191,9 @@ export async function polarWebhook(
|
||||
where: {
|
||||
subscriptionCustomerId: event.data.customer.id,
|
||||
subscriptionId: event.data.id,
|
||||
subscriptionStatus: 'active',
|
||||
subscriptionStatus: {
|
||||
in: ['active', 'past_due', 'unpaid'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,6 +223,13 @@ export async function polarWebhook(
|
||||
subscriptionCreatedByUserId: metadata.userId,
|
||||
subscriptionInterval: event.data.recurringInterval,
|
||||
subscriptionPeriodEventsLimit,
|
||||
subscriptionPeriodEventsCountExceededAt:
|
||||
subscriptionPeriodEventsLimit &&
|
||||
organization.subscriptionPeriodEventsCountExceededAt &&
|
||||
organization.subscriptionPeriodEventsLimit <
|
||||
subscriptionPeriodEventsLimit
|
||||
? null
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { SdkAuthError, validateSdkRequest } from '@/utils/auth';
|
||||
|
||||
export async function clientHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
try {
|
||||
const client = await validateSdkRequest(req);
|
||||
|
||||
@@ -1,23 +1,28 @@
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { isDuplicatedEvent } from '@/utils/deduplicate';
|
||||
|
||||
export async function duplicateHook(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const ip = req.clientIp;
|
||||
const origin = req.headers.origin;
|
||||
const clientId = req.headers['openpanel-client-id'];
|
||||
const shouldCheck = ip && origin && clientId;
|
||||
|
||||
const body = req?.body;
|
||||
const isTrackPayload = getIsTrackPayload(req);
|
||||
const isReplay = isTrackPayload && req.body.type === 'replay';
|
||||
const shouldCheck = ip && origin && clientId && !isReplay;
|
||||
const isDuplicate = shouldCheck
|
||||
? await isDuplicatedEvent({
|
||||
ip,
|
||||
origin,
|
||||
payload: req.body,
|
||||
payload: body,
|
||||
projectId: clientId as string,
|
||||
})
|
||||
: false;
|
||||
@@ -26,3 +31,25 @@ export async function duplicateHook(
|
||||
return reply.status(200).send('Duplicate event');
|
||||
}
|
||||
}
|
||||
|
||||
function getIsTrackPayload(
|
||||
req: FastifyRequest<{
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>
|
||||
): req is FastifyRequest<{
|
||||
Body: ITrackHandlerPayload;
|
||||
}> {
|
||||
if (req.method !== 'POST') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!req.body) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof req.body !== 'object' || Array.isArray(req.body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return 'type' in req.body;
|
||||
}
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
import { isBot } from '@/bots';
|
||||
import { createBotEvent } from '@openpanel/db';
|
||||
import type { TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
type DeprecatedEventPayload = {
|
||||
name: string;
|
||||
properties: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
};
|
||||
import { isBot } from '@/bots';
|
||||
|
||||
export async function isBotHook(
|
||||
req: FastifyRequest<{
|
||||
Body: TrackHandlerPayload | DeprecatedEventPayload;
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
const bot = req.headers['user-agent']
|
||||
? isBot(req.headers['user-agent'])
|
||||
? await isBot(req.headers['user-agent'])
|
||||
: null;
|
||||
|
||||
if (bot && req.client?.projectId) {
|
||||
@@ -46,6 +43,6 @@ export async function isBotHook(
|
||||
}
|
||||
}
|
||||
|
||||
return reply.status(202).send('OK');
|
||||
return reply.status(202).send({ bot });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type {
|
||||
FastifyReply,
|
||||
FastifyRequest,
|
||||
HookHandlerDoneFunction,
|
||||
} from 'fastify';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
|
||||
export async function requestIdHook(request: FastifyRequest) {
|
||||
if (!request.headers['request-id']) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { DEFAULT_IP_HEADER_ORDER } from '@openpanel/common';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { path, pick } from 'ramda';
|
||||
|
||||
@@ -6,7 +5,7 @@ const ignoreLog = ['/healthcheck', '/healthz', '/metrics', '/misc'];
|
||||
const ignoreMethods = ['OPTIONS'];
|
||||
|
||||
const getTrpcInput = (
|
||||
request: FastifyRequest,
|
||||
request: FastifyRequest
|
||||
): Record<string, unknown> | undefined => {
|
||||
const input = path<any>(['query', 'input'], request);
|
||||
try {
|
||||
@@ -18,7 +17,7 @@ const getTrpcInput = (
|
||||
|
||||
export async function requestLoggingHook(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
reply: FastifyReply
|
||||
) {
|
||||
if (ignoreMethods.includes(request.method)) {
|
||||
return;
|
||||
@@ -38,19 +37,10 @@ export async function requestLoggingHook(
|
||||
url: request.url,
|
||||
method: request.method,
|
||||
elapsed: reply.elapsedTime,
|
||||
clientIp: request.clientIp,
|
||||
clientIpHeader: request.clientIpHeader,
|
||||
headers: pick(
|
||||
[
|
||||
'openpanel-client-id',
|
||||
'openpanel-sdk-name',
|
||||
'openpanel-sdk-version',
|
||||
'user-agent',
|
||||
...DEFAULT_IP_HEADER_ORDER,
|
||||
],
|
||||
request.headers,
|
||||
['openpanel-client-id', 'openpanel-sdk-name', 'openpanel-sdk-version'],
|
||||
request.headers
|
||||
),
|
||||
body: request.body,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
/** biome-ignore-all lint/suspicious/useAwait: fastify need async or done callbacks */
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
import compress from '@fastify/compress';
|
||||
import cookie from '@fastify/cookie';
|
||||
import cors, { type FastifyCorsOptions } from '@fastify/cors';
|
||||
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||
import Fastify from 'fastify';
|
||||
import metricsPlugin from 'fastify-metrics';
|
||||
|
||||
import {
|
||||
decodeSessionToken,
|
||||
EMPTY_SESSION,
|
||||
type SessionValidationResult,
|
||||
validateSessionToken,
|
||||
} from '@openpanel/auth';
|
||||
import { generateId } from '@openpanel/common';
|
||||
import {
|
||||
type IServiceClientWithProject,
|
||||
runWithAlsSession,
|
||||
} from '@openpanel/db';
|
||||
import { getCache, getRedisPub } from '@openpanel/redis';
|
||||
import { getRedisPub } from '@openpanel/redis';
|
||||
import type { AppRouter } from '@openpanel/trpc';
|
||||
import { appRouter, createContext } from '@openpanel/trpc';
|
||||
|
||||
import {
|
||||
EMPTY_SESSION,
|
||||
type SessionValidationResult,
|
||||
decodeSessionToken,
|
||||
validateSessionToken,
|
||||
} from '@openpanel/auth';
|
||||
import type { FastifyTRPCPluginOptions } from '@trpc/server/adapters/fastify';
|
||||
import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify';
|
||||
import type { FastifyBaseLogger, FastifyRequest } from 'fastify';
|
||||
import Fastify from 'fastify';
|
||||
import metricsPlugin from 'fastify-metrics';
|
||||
import sourceMapSupport from 'source-map-support';
|
||||
import {
|
||||
healthcheck,
|
||||
@@ -35,9 +36,12 @@ import { timestampHook } from './hooks/timestamp.hook';
|
||||
import aiRouter from './routes/ai.router';
|
||||
import eventRouter from './routes/event.router';
|
||||
import exportRouter from './routes/export.router';
|
||||
import gscCallbackRouter from './routes/gsc-callback.router';
|
||||
import importRouter from './routes/import.router';
|
||||
import insightsRouter from './routes/insights.router';
|
||||
import liveRouter from './routes/live.router';
|
||||
import logsRouter from './routes/logs.router';
|
||||
import manageRouter from './routes/manage.router';
|
||||
import miscRouter from './routes/misc.router';
|
||||
import oauthRouter from './routes/oauth-callback.router';
|
||||
import profileRouter from './routes/profile.router';
|
||||
@@ -49,8 +53,6 @@ import { logger } from './utils/logger';
|
||||
|
||||
sourceMapSupport.install();
|
||||
|
||||
process.env.TZ = 'UTC';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
client: IServiceClientWithProject | null;
|
||||
@@ -62,13 +64,16 @@ declare module 'fastify' {
|
||||
}
|
||||
|
||||
const port = Number.parseInt(process.env.API_PORT || '3000', 10);
|
||||
const host =
|
||||
process.env.API_HOST ||
|
||||
(process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost');
|
||||
|
||||
const startServer = async () => {
|
||||
logger.info('Starting server');
|
||||
try {
|
||||
const fastify = Fastify({
|
||||
maxParamLength: 15_000,
|
||||
bodyLimit: 1048576 * 500, // 500MB
|
||||
bodyLimit: 1_048_576 * 500, // 500MB
|
||||
loggerInstance: logger as unknown as FastifyBaseLogger,
|
||||
disableRequestLogging: true,
|
||||
genReqId: (req) =>
|
||||
@@ -80,7 +85,7 @@ const startServer = async () => {
|
||||
fastify.register(cors, () => {
|
||||
return (
|
||||
req: FastifyRequest,
|
||||
callback: (error: Error | null, options: FastifyCorsOptions) => void,
|
||||
callback: (error: Error | null, options: FastifyCorsOptions) => void
|
||||
) => {
|
||||
// TODO: set prefix on dashboard routes
|
||||
const corsPaths = [
|
||||
@@ -93,7 +98,7 @@ const startServer = async () => {
|
||||
];
|
||||
|
||||
const isPrivatePath = corsPaths.some((path) =>
|
||||
req.url.startsWith(path),
|
||||
req.url.startsWith(path)
|
||||
);
|
||||
|
||||
if (isPrivatePath) {
|
||||
@@ -114,6 +119,7 @@ const startServer = async () => {
|
||||
|
||||
return callback(null, {
|
||||
origin: '*',
|
||||
maxAge: 86_400 * 7, // cache preflight for 7 days
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -143,12 +149,21 @@ const startServer = async () => {
|
||||
instance.addHook('onRequest', async (req) => {
|
||||
if (req.cookies?.session) {
|
||||
try {
|
||||
const sessionId = decodeSessionToken(req.cookies.session);
|
||||
const sessionId = decodeSessionToken(req.cookies?.session);
|
||||
const session = await runWithAlsSession(sessionId, () =>
|
||||
validateSessionToken(req.cookies.session),
|
||||
validateSessionToken(req.cookies.session)
|
||||
);
|
||||
req.session = session;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else if (process.env.DEMO_USER_ID) {
|
||||
try {
|
||||
const session = await runWithAlsSession('1', () =>
|
||||
validateSessionToken(null)
|
||||
);
|
||||
req.session = session;
|
||||
} catch {
|
||||
req.session = EMPTY_SESSION;
|
||||
}
|
||||
} else {
|
||||
@@ -160,7 +175,7 @@ const startServer = async () => {
|
||||
prefix: '/trpc',
|
||||
trpcOptions: {
|
||||
router: appRouter,
|
||||
createContext: createContext,
|
||||
createContext,
|
||||
onError(ctx) {
|
||||
if (
|
||||
ctx.error.code === 'UNAUTHORIZED' &&
|
||||
@@ -181,8 +196,10 @@ const startServer = async () => {
|
||||
instance.register(liveRouter, { prefix: '/live' });
|
||||
instance.register(webhookRouter, { prefix: '/webhook' });
|
||||
instance.register(oauthRouter, { prefix: '/oauth' });
|
||||
instance.register(gscCallbackRouter, { prefix: '/gsc' });
|
||||
instance.register(miscRouter, { prefix: '/misc' });
|
||||
instance.register(aiRouter, { prefix: '/ai' });
|
||||
instance.register(logsRouter, { prefix: '/logs' });
|
||||
});
|
||||
|
||||
// Public API
|
||||
@@ -194,6 +211,7 @@ const startServer = async () => {
|
||||
instance.register(importRouter, { prefix: '/import' });
|
||||
instance.register(insightsRouter, { prefix: '/insights' });
|
||||
instance.register(trackRouter, { prefix: '/track' });
|
||||
instance.register(manageRouter, { prefix: '/manage' });
|
||||
// Keep existing endpoints for backward compatibility
|
||||
instance.get('/healthcheck', healthcheck);
|
||||
// New Kubernetes-style health endpoints
|
||||
@@ -203,39 +221,50 @@ const startServer = async () => {
|
||||
reply.send({
|
||||
status: 'ok',
|
||||
message: 'Successfully running OpenPanel.dev API',
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
const SKIP_LOG_ERRORS = ['UNAUTHORIZED', 'FST_ERR_CTP_INVALID_MEDIA_TYPE'];
|
||||
fastify.setErrorHandler((error, request, reply) => {
|
||||
if (error instanceof HttpError) {
|
||||
request.log.error(`${error.message}`, error);
|
||||
if (process.env.NODE_ENV === 'production' && error.status === 500) {
|
||||
request.log.error('request error', { error });
|
||||
reply.status(500).send('Internal server error');
|
||||
} else {
|
||||
reply.status(error.status).send({
|
||||
status: error.status,
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
} else if (error.statusCode === 429) {
|
||||
reply.status(429).send({
|
||||
if (error.statusCode === 429) {
|
||||
return reply.status(429).send({
|
||||
status: 429,
|
||||
error: 'Too Many Requests',
|
||||
message: 'You have exceeded the rate limit for this endpoint.',
|
||||
});
|
||||
} else if (error.statusCode === 400) {
|
||||
reply.status(400).send({
|
||||
status: 400,
|
||||
error,
|
||||
message: 'The request was invalid.',
|
||||
});
|
||||
} else {
|
||||
request.log.error('request error', { error });
|
||||
reply.status(500).send('Internal server error');
|
||||
}
|
||||
|
||||
if (error instanceof HttpError) {
|
||||
if (!SKIP_LOG_ERRORS.includes(error.code)) {
|
||||
request.log.error('internal server error', { error });
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && error.status === 500) {
|
||||
return reply.status(500).send('Internal server error');
|
||||
}
|
||||
|
||||
return reply.status(error.status).send({
|
||||
status: error.status,
|
||||
error: error.error,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (!SKIP_LOG_ERRORS.includes(error.code)) {
|
||||
request.log.error('request error', { error });
|
||||
}
|
||||
|
||||
const status = error?.statusCode ?? 500;
|
||||
if (process.env.NODE_ENV === 'production' && status === 500) {
|
||||
return reply.status(500).send('Internal server error');
|
||||
}
|
||||
|
||||
return reply.status(status).send({
|
||||
status,
|
||||
error,
|
||||
message: error.message,
|
||||
});
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@@ -252,10 +281,7 @@ const startServer = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
await fastify.listen({
|
||||
host: process.env.NODE_ENV === 'production' ? '0.0.0.0' : 'localhost',
|
||||
port,
|
||||
});
|
||||
await fastify.listen({ host, port });
|
||||
|
||||
try {
|
||||
// Notify when keys expires
|
||||
@@ -263,7 +289,7 @@ const startServer = async () => {
|
||||
} catch (error) {
|
||||
logger.warn('Failed to set redis notify-keyspace-events', error);
|
||||
logger.warn(
|
||||
'If you use a managed Redis service, you may need to set this manually.',
|
||||
'If you use a managed Redis service, you may need to set this manually.'
|
||||
);
|
||||
logger.warn('Otherwise some functions may not work as expected.');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import * as controller from '@/controllers/ai.controller';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const aiRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter<
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as controller from '@/controllers/event.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import * as controller from '@/controllers/event.controller';
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import * as controller from '@/controllers/export.controller';
|
||||
import { validateExportRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const exportRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
|
||||
12
apps/api/src/routes/gsc-callback.router.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import { gscGoogleCallback } from '@/controllers/gsc-oauth-callback.controller';
|
||||
|
||||
const router: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/callback',
|
||||
handler: gscGoogleCallback,
|
||||
});
|
||||
};
|
||||
|
||||
export default router;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import * as controller from '@/controllers/import.controller';
|
||||
import { validateImportRequest } from '@/utils/auth';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
import { Prisma } from '@openpanel/db';
|
||||
|
||||
const importRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import * as controller from '@/controllers/insights.controller';
|
||||
import { validateExportRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
|
||||
const insightsRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as controller from '@/controllers/live.controller';
|
||||
import fastifyWS from '@fastify/websocket';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import * as controller from '@/controllers/live.controller';
|
||||
|
||||
const liveRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.register(fastifyWS);
|
||||
@@ -9,22 +9,22 @@ const liveRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.get(
|
||||
'/organization/:organizationId',
|
||||
{ websocket: true },
|
||||
controller.wsOrganizationEvents,
|
||||
controller.wsOrganizationEvents
|
||||
);
|
||||
fastify.get(
|
||||
'/visitors/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsVisitors,
|
||||
controller.wsVisitors
|
||||
);
|
||||
fastify.get(
|
||||
'/events/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsProjectEvents,
|
||||
controller.wsProjectEvents
|
||||
);
|
||||
fastify.get(
|
||||
'/notifications/:projectId',
|
||||
{ websocket: true },
|
||||
controller.wsProjectNotifications,
|
||||
controller.wsProjectNotifications
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
6
apps/api/src/routes/logs.router.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { handler } from '@/controllers/logs.controller';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export default async function (fastify: FastifyInstance) {
|
||||
fastify.post('/', handler);
|
||||
}
|
||||
132
apps/api/src/routes/manage.router.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { Prisma } from '@openpanel/db';
|
||||
import type { FastifyPluginCallback, FastifyRequest } from 'fastify';
|
||||
import * as controller from '@/controllers/manage.controller';
|
||||
import { validateManageRequest } from '@/utils/auth';
|
||||
import { activateRateLimiter } from '@/utils/rate-limiter';
|
||||
|
||||
const manageRouter: FastifyPluginCallback = async (fastify) => {
|
||||
await activateRateLimiter({
|
||||
fastify,
|
||||
max: 20,
|
||||
timeWindow: '10 seconds',
|
||||
});
|
||||
|
||||
fastify.addHook('preHandler', async (req: FastifyRequest, reply) => {
|
||||
try {
|
||||
const client = await validateManageRequest(req.headers);
|
||||
req.client = client;
|
||||
} catch (e) {
|
||||
if (e instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
return reply.status(401).send({
|
||||
error: 'Unauthorized',
|
||||
message: 'Client ID seems to be malformed',
|
||||
});
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: e.message });
|
||||
}
|
||||
|
||||
return reply
|
||||
.status(401)
|
||||
.send({ error: 'Unauthorized', message: 'Unexpected error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Projects routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects',
|
||||
handler: controller.listProjects,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/projects/:id',
|
||||
handler: controller.getProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/projects',
|
||||
handler: controller.createProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/projects/:id',
|
||||
handler: controller.updateProject,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/projects/:id',
|
||||
handler: controller.deleteProject,
|
||||
});
|
||||
|
||||
// Clients routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients',
|
||||
handler: controller.listClients,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/clients/:id',
|
||||
handler: controller.getClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/clients',
|
||||
handler: controller.createClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/clients/:id',
|
||||
handler: controller.updateClient,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/clients/:id',
|
||||
handler: controller.deleteClient,
|
||||
});
|
||||
|
||||
// References routes
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references',
|
||||
handler: controller.listReferences,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'GET',
|
||||
url: '/references/:id',
|
||||
handler: controller.getReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/references',
|
||||
handler: controller.createReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'PATCH',
|
||||
url: '/references/:id',
|
||||
handler: controller.updateReference,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
method: 'DELETE',
|
||||
url: '/references/:id',
|
||||
handler: controller.deleteReference,
|
||||
});
|
||||
};
|
||||
|
||||
export default manageRouter;
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as controller from '@/controllers/misc.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import * as controller from '@/controllers/misc.controller';
|
||||
|
||||
const miscRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as controller from '@/controllers/oauth-callback.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import * as controller from '@/controllers/oauth-callback.controller';
|
||||
|
||||
const router: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import * as controller from '@/controllers/profile.controller';
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
const eventRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.addHook('preHandler', clientHook);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
|
||||
import { fetchDeviceId, handler } from '@/controllers/track.controller';
|
||||
import { clientHook } from '@/hooks/client.hook';
|
||||
import { duplicateHook } from '@/hooks/duplicate.hook';
|
||||
import { isBotHook } from '@/hooks/is-bot.hook';
|
||||
@@ -13,23 +12,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
method: 'POST',
|
||||
url: '/',
|
||||
handler: handler,
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['type', 'payload'],
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['track', 'increment', 'decrement', 'alias', 'identify'],
|
||||
},
|
||||
payload: {
|
||||
type: 'object',
|
||||
additionalProperties: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler,
|
||||
});
|
||||
|
||||
fastify.route({
|
||||
@@ -42,6 +25,7 @@ const trackRouter: FastifyPluginCallback = async (fastify) => {
|
||||
type: 'object',
|
||||
properties: {
|
||||
deviceId: { type: 'string' },
|
||||
sessionId: { type: 'string' },
|
||||
message: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as controller from '@/controllers/webhook.controller';
|
||||
import type { FastifyPluginCallback } from 'fastify';
|
||||
import * as controller from '@/controllers/webhook.controller';
|
||||
|
||||
const webhookRouter: FastifyPluginCallback = async (fastify) => {
|
||||
fastify.route({
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
import { chartTypes } from '@openpanel/constants';
|
||||
import type { IClickhouseSession } from '@openpanel/db';
|
||||
import {
|
||||
ch,
|
||||
clix,
|
||||
type IClickhouseEvent,
|
||||
type IClickhouseProfile,
|
||||
TABLE_NAMES,
|
||||
ch,
|
||||
clix,
|
||||
} from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import { getChart } from '@openpanel/trpc/src/routers/chart.helpers';
|
||||
import { zChartInputAI } from '@openpanel/validation';
|
||||
import { zReportInput } from '@openpanel/validation';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export function getReport({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getReport({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description: `Generate a report (a chart) for
|
||||
- ${chartTypes.area}
|
||||
@@ -27,7 +22,10 @@ export function getReport({
|
||||
- ${chartTypes.metric}
|
||||
- ${chartTypes.bar}
|
||||
`,
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -64,15 +62,14 @@ export function getReport({
|
||||
},
|
||||
});
|
||||
}
|
||||
export function getConversionReport({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getConversionReport({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for conversions between two actions a unique user took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -86,15 +83,14 @@ export function getConversionReport({
|
||||
},
|
||||
});
|
||||
}
|
||||
export function getFunnelReport({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getFunnelReport({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description:
|
||||
'Generate a report (a chart) for funnel between two or more actions a unique user (session_id or profile_id) took.',
|
||||
parameters: zChartInputAI,
|
||||
parameters: zReportInput.extend({
|
||||
startDate: z.string().describe('The start date for the report'),
|
||||
endDate: z.string().describe('The end date for the report'),
|
||||
}),
|
||||
execute: async (report) => {
|
||||
return {
|
||||
type: 'report',
|
||||
@@ -109,11 +105,7 @@ export function getFunnelReport({
|
||||
});
|
||||
}
|
||||
|
||||
export function getProfiles({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getProfiles({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description: 'Get profiles',
|
||||
parameters: z.object({
|
||||
@@ -179,11 +171,7 @@ export function getProfiles({
|
||||
});
|
||||
}
|
||||
|
||||
export function getProfile({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getProfile({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description: 'Get a specific profile',
|
||||
parameters: z.object({
|
||||
@@ -267,11 +255,7 @@ export function getProfile({
|
||||
});
|
||||
}
|
||||
|
||||
export function getEvents({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getEvents({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description: 'Get events for a project or specific profile',
|
||||
parameters: z.object({
|
||||
@@ -360,11 +344,7 @@ export function getEvents({
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessions({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getSessions({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description: 'Get sessions for a project or specific profile',
|
||||
parameters: z.object({
|
||||
@@ -449,11 +429,7 @@ export function getSessions({
|
||||
});
|
||||
}
|
||||
|
||||
export function getAllEventNames({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
export function getAllEventNames({ projectId }: { projectId: string }) {
|
||||
return tool({
|
||||
description: 'Get the top 50 event names in a comma separated list',
|
||||
parameters: z.object({}),
|
||||
|
||||
@@ -14,11 +14,7 @@ export const getChatModel = () => {
|
||||
}
|
||||
};
|
||||
|
||||
export const getChatSystemPrompt = ({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) => {
|
||||
export const getChatSystemPrompt = ({ projectId }: { projectId: string }) => {
|
||||
return `You're an product and web analytics expert. Don't generate more than the user asks for. Follow all rules listed below!
|
||||
## General:
|
||||
- projectId: \`${projectId}\`
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||
|
||||
import { verifyPassword } from '@openpanel/common/server';
|
||||
import type { IServiceClientWithProject } from '@openpanel/db';
|
||||
import { ClientType, getClientByIdCached } from '@openpanel/db';
|
||||
import { getCache } from '@openpanel/redis';
|
||||
import type { PostEventPayload, TrackHandlerPayload } from '@openpanel/sdk';
|
||||
import type {
|
||||
DeprecatedPostEventPayload,
|
||||
IProjectFilterIp,
|
||||
IProjectFilterProfileId,
|
||||
ITrackHandlerPayload,
|
||||
} from '@openpanel/validation';
|
||||
import type { FastifyRequest, RawRequestDefaultExpression } from 'fastify';
|
||||
import { path } from 'ramda';
|
||||
|
||||
const cleanDomain = (domain: string) =>
|
||||
@@ -30,7 +30,7 @@ export class SdkAuthError extends Error {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
origin?: string;
|
||||
},
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'SdkAuthError';
|
||||
@@ -41,8 +41,8 @@ export class SdkAuthError extends Error {
|
||||
|
||||
export async function validateSdkRequest(
|
||||
req: FastifyRequest<{
|
||||
Body: PostEventPayload | TrackHandlerPayload;
|
||||
}>,
|
||||
Body: ITrackHandlerPayload | DeprecatedPostEventPayload;
|
||||
}>
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const { headers, clientIp } = req;
|
||||
const clientIdNew = headers['openpanel-client-id'] as string;
|
||||
@@ -69,10 +69,10 @@ export async function validateSdkRequest(
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId,
|
||||
clientId
|
||||
)
|
||||
) {
|
||||
throw createError('Ingestion: Clean ID must be a valid UUIDv4');
|
||||
throw createError('Ingestion: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
@@ -87,7 +87,7 @@ export async function validateSdkRequest(
|
||||
|
||||
// Filter out blocked IPs
|
||||
const ipFilter = client.project.filters.filter(
|
||||
(filter): filter is IProjectFilterIp => filter.type === 'ip',
|
||||
(filter): filter is IProjectFilterIp => filter.type === 'ip'
|
||||
);
|
||||
if (ipFilter.some((filter) => filter.ip === clientIp)) {
|
||||
throw createError('Ingestion: IP address is blocked by project filter');
|
||||
@@ -95,7 +95,7 @@ export async function validateSdkRequest(
|
||||
|
||||
// Filter out blocked profile ids
|
||||
const profileFilter = client.project.filters.filter(
|
||||
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id',
|
||||
(filter): filter is IProjectFilterProfileId => filter.type === 'profile_id'
|
||||
);
|
||||
const profileId =
|
||||
path<string | undefined>(['payload', 'profileId'], req.body) || // Track handler
|
||||
@@ -112,12 +112,11 @@ export async function validateSdkRequest(
|
||||
// Only allow revenue tracking if it was sent with a client secret
|
||||
// or if the project has allowUnsafeRevenueTracking enabled
|
||||
if (
|
||||
!client.project.allowUnsafeRevenueTracking &&
|
||||
!clientSecret &&
|
||||
!(client.project.allowUnsafeRevenueTracking || clientSecret) &&
|
||||
typeof revenue !== 'undefined'
|
||||
) {
|
||||
throw createError(
|
||||
'Ingestion: Revenue tracking is not allowed without a client secret',
|
||||
'Ingestion: Revenue tracking is not allowed without a client secret'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,7 +130,7 @@ export async function validateSdkRequest(
|
||||
// support wildcard domains `*.foo.com`
|
||||
if (cleanedDomain.includes('*')) {
|
||||
const regex = new RegExp(
|
||||
`${cleanedDomain.replaceAll('.', '\\.').replaceAll('*', '.+?')}`,
|
||||
`${cleanedDomain.replaceAll('.', '\\.').replaceAll('*', '.+?')}`
|
||||
);
|
||||
|
||||
return regex.test(origin || '');
|
||||
@@ -156,7 +155,7 @@ export async function validateSdkRequest(
|
||||
`client:auth:${clientId}:${Buffer.from(clientSecret).toString('base64')}`,
|
||||
60 * 5,
|
||||
async () => await verifyPassword(clientSecret, client.secret!),
|
||||
true,
|
||||
true
|
||||
);
|
||||
if (isVerified) {
|
||||
return client;
|
||||
@@ -167,14 +166,14 @@ export async function validateSdkRequest(
|
||||
}
|
||||
|
||||
export async function validateExportRequest(
|
||||
headers: RawRequestDefaultExpression['headers'],
|
||||
headers: RawRequestDefaultExpression['headers']
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const clientId = headers['openpanel-client-id'] as string;
|
||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId,
|
||||
clientId
|
||||
)
|
||||
) {
|
||||
throw new Error('Export: Client ID must be a valid UUIDv4');
|
||||
@@ -202,14 +201,14 @@ export async function validateExportRequest(
|
||||
}
|
||||
|
||||
export async function validateImportRequest(
|
||||
headers: RawRequestDefaultExpression['headers'],
|
||||
headers: RawRequestDefaultExpression['headers']
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const clientId = headers['openpanel-client-id'] as string;
|
||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId,
|
||||
clientId
|
||||
)
|
||||
) {
|
||||
throw new Error('Import: Client ID must be a valid UUIDv4');
|
||||
@@ -235,3 +234,40 @@ export async function validateImportRequest(
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export async function validateManageRequest(
|
||||
headers: RawRequestDefaultExpression['headers']
|
||||
): Promise<IServiceClientWithProject> {
|
||||
const clientId = headers['openpanel-client-id'] as string;
|
||||
const clientSecret = (headers['openpanel-client-secret'] as string) || '';
|
||||
|
||||
if (
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(
|
||||
clientId
|
||||
)
|
||||
) {
|
||||
throw new Error('Manage: Client ID must be a valid UUIDv4');
|
||||
}
|
||||
|
||||
const client = await getClientByIdCached(clientId);
|
||||
|
||||
if (!client) {
|
||||
throw new Error('Manage: Invalid client id');
|
||||
}
|
||||
|
||||
if (!client.secret) {
|
||||
throw new Error('Manage: Client has no secret');
|
||||
}
|
||||
|
||||
if (client.type !== ClientType.root) {
|
||||
throw new Error(
|
||||
'Manage: Only root clients are allowed to manage resources'
|
||||
);
|
||||
}
|
||||
|
||||
if (!(await verifyPassword(clientSecret, client.secret))) {
|
||||
throw new Error('Manage: Invalid client secret');
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ export async function isDuplicatedEvent({
|
||||
origin,
|
||||
projectId,
|
||||
},
|
||||
'md5',
|
||||
'md5'
|
||||
)}`,
|
||||
'1',
|
||||
100,
|
||||
100
|
||||
);
|
||||
|
||||
if (locked) {
|
||||
|
||||
@@ -4,7 +4,7 @@ export class LogError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
payload?: Record<string, unknown>,
|
||||
options?: ErrorOptions,
|
||||
options?: ErrorOptions
|
||||
) {
|
||||
super(message, options);
|
||||
this.name = 'LogError';
|
||||
@@ -26,7 +26,7 @@ export class HttpError extends Error {
|
||||
fingerprint?: string;
|
||||
extra?: Record<string, unknown>;
|
||||
error?: Error | unknown;
|
||||
},
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'HttpError';
|
||||
|
||||
@@ -29,7 +29,7 @@ export function isShuttingDown() {
|
||||
export async function shutdown(
|
||||
fastify: FastifyInstance,
|
||||
signal: string,
|
||||
exitCode = 0,
|
||||
exitCode = 0
|
||||
) {
|
||||
if (isShuttingDown()) {
|
||||
logger.warn('Shutdown already in progress, ignoring signal', { signal });
|
||||
@@ -96,7 +96,7 @@ export async function shutdown(
|
||||
if (redis.status === 'ready') {
|
||||
await redis.quit();
|
||||
}
|
||||
}),
|
||||
})
|
||||
);
|
||||
logger.info('Redis connections closed');
|
||||
} catch (error) {
|
||||
|
||||
181
apps/api/src/utils/ids.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { generateDeviceId } from '@openpanel/common/server';
|
||||
import { getSafeJson } from '@openpanel/json';
|
||||
import type {
|
||||
EventsQueuePayloadCreateSessionEnd,
|
||||
EventsQueuePayloadIncomingEvent,
|
||||
} from '@openpanel/queue';
|
||||
import { getRedisCache } from '@openpanel/redis';
|
||||
import { pick } from 'ramda';
|
||||
|
||||
export async function getDeviceId({
|
||||
projectId,
|
||||
ip,
|
||||
ua,
|
||||
salts,
|
||||
overrideDeviceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
ip: string;
|
||||
ua: string | undefined;
|
||||
salts: { current: string; previous: string };
|
||||
overrideDeviceId?: string;
|
||||
}) {
|
||||
if (overrideDeviceId) {
|
||||
return { deviceId: overrideDeviceId, sessionId: '' };
|
||||
}
|
||||
|
||||
if (!ua) {
|
||||
return { deviceId: '', sessionId: '' };
|
||||
}
|
||||
|
||||
const currentDeviceId = generateDeviceId({
|
||||
salt: salts.current,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
const previousDeviceId = generateDeviceId({
|
||||
salt: salts.previous,
|
||||
origin: projectId,
|
||||
ip,
|
||||
ua,
|
||||
});
|
||||
|
||||
return await getInfoFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
});
|
||||
}
|
||||
|
||||
interface DeviceIdResult {
|
||||
deviceId: string;
|
||||
sessionId: string;
|
||||
session?: EventsQueuePayloadIncomingEvent['payload']['session'];
|
||||
}
|
||||
|
||||
async function getInfoFromSession({
|
||||
projectId,
|
||||
currentDeviceId,
|
||||
previousDeviceId,
|
||||
}: {
|
||||
projectId: string;
|
||||
currentDeviceId: string;
|
||||
previousDeviceId: string;
|
||||
}): Promise<DeviceIdResult> {
|
||||
try {
|
||||
const multi = getRedisCache().multi();
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${currentDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
multi.hget(
|
||||
`bull:sessions:sessionEnd:${projectId}:${previousDeviceId}`,
|
||||
'data'
|
||||
);
|
||||
const res = await multi.exec();
|
||||
if (res?.[0]?.[1]) {
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
(res?.[0]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
return {
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
if (res?.[1]?.[1]) {
|
||||
const data = getSafeJson<EventsQueuePayloadCreateSessionEnd>(
|
||||
(res?.[1]?.[1] as string) ?? ''
|
||||
);
|
||||
if (data) {
|
||||
return {
|
||||
deviceId: previousDeviceId,
|
||||
sessionId: data.payload.sessionId,
|
||||
session: pick(
|
||||
['referrer', 'referrerName', 'referrerType'],
|
||||
data.payload
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error getting session end GET /track/device-id', error);
|
||||
}
|
||||
|
||||
return {
|
||||
deviceId: currentDeviceId,
|
||||
sessionId: getSessionId({
|
||||
projectId,
|
||||
deviceId: currentDeviceId,
|
||||
graceMs: 5 * 1000,
|
||||
windowMs: 1000 * 60 * 30,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic session id for (projectId, deviceId) within a time window,
|
||||
* with a grace period at the *start* of each window to avoid boundary splits.
|
||||
*
|
||||
* - windowMs: 30 minutes by default
|
||||
* - graceMs: 1 minute by default (events in first minute of a bucket map to previous bucket)
|
||||
* - Output: base64url, 128-bit (16 bytes) truncated from SHA-256
|
||||
*/
|
||||
function getSessionId(params: {
|
||||
projectId: string;
|
||||
deviceId: string;
|
||||
eventMs?: number; // use event timestamp; defaults to Date.now()
|
||||
windowMs?: number; // default 5 min
|
||||
graceMs?: number; // default 1 min
|
||||
bytes?: number; // default 16 (128-bit). You can set 24 or 32 for longer ids.
|
||||
}): string {
|
||||
const {
|
||||
projectId,
|
||||
deviceId,
|
||||
eventMs = Date.now(),
|
||||
windowMs = 5 * 60 * 1000,
|
||||
graceMs = 60 * 1000,
|
||||
bytes = 16,
|
||||
} = params;
|
||||
|
||||
if (!projectId) {
|
||||
throw new Error('projectId is required');
|
||||
}
|
||||
if (!deviceId) {
|
||||
throw new Error('deviceId is required');
|
||||
}
|
||||
if (windowMs <= 0) {
|
||||
throw new Error('windowMs must be > 0');
|
||||
}
|
||||
if (graceMs < 0 || graceMs >= windowMs) {
|
||||
throw new Error('graceMs must be >= 0 and < windowMs');
|
||||
}
|
||||
if (bytes < 8 || bytes > 32) {
|
||||
throw new Error('bytes must be between 8 and 32');
|
||||
}
|
||||
|
||||
const bucket = Math.floor(eventMs / windowMs);
|
||||
const offset = eventMs - bucket * windowMs;
|
||||
|
||||
// Grace at the start of the bucket: stick to the previous bucket.
|
||||
const chosenBucket = offset < graceMs ? bucket - 1 : bucket;
|
||||
|
||||
const input = `sess:v1:${projectId}:${deviceId}:${chosenBucket}`;
|
||||
|
||||
const digest = crypto.createHash('sha256').update(input).digest();
|
||||
const truncated = digest.subarray(0, bytes);
|
||||
|
||||
// base64url
|
||||
return truncated
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/g, '');
|
||||
}
|
||||
@@ -3,14 +3,21 @@ import { getSafeJson } from '@openpanel/json';
|
||||
export const parseQueryString = (obj: Record<string, any>): any => {
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).map(([k, v]) => {
|
||||
if (typeof v === 'object') return [k, parseQueryString(v)];
|
||||
if (typeof v === 'object') {
|
||||
return [k, parseQueryString(v)];
|
||||
}
|
||||
if (
|
||||
/^-?[0-9]+(\.[0-9]+)?$/i.test(v) &&
|
||||
!Number.isNaN(Number.parseFloat(v))
|
||||
)
|
||||
) {
|
||||
return [k, Number.parseFloat(v)];
|
||||
if (v === 'true') return [k, true];
|
||||
if (v === 'false') return [k, false];
|
||||
}
|
||||
if (v === 'true') {
|
||||
return [k, true];
|
||||
}
|
||||
if (v === 'false') {
|
||||
return [k, false];
|
||||
}
|
||||
if (typeof v === 'string') {
|
||||
if (getSafeJson(v) !== null) {
|
||||
return [k, getSafeJson(v)];
|
||||
@@ -18,6 +25,6 @@ export const parseQueryString = (obj: Record<string, any>): any => {
|
||||
return [k, v];
|
||||
}
|
||||
return [k, null];
|
||||
}),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import urlMetadata from 'url-metadata';
|
||||
|
||||
function fallbackFavicon(url: string) {
|
||||
return `https://www.google.com/s2/favicons?domain=${url}&sz=256`;
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
return `https://icons.duckduckgo.com/ip3/${hostname}.ico`;
|
||||
} catch {
|
||||
// If URL parsing fails, use the original string
|
||||
return `https://icons.duckduckgo.com/ip3/${url}.ico`;
|
||||
}
|
||||
}
|
||||
|
||||
function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||
@@ -13,7 +19,7 @@ function findBestFavicon(favicons: UrlMetaData['favicons']) {
|
||||
(favicon) =>
|
||||
favicon.rel === 'shortcut icon' ||
|
||||
favicon.rel === 'icon' ||
|
||||
favicon.rel === 'apple-touch-icon',
|
||||
favicon.rel === 'apple-touch-icon'
|
||||
);
|
||||
|
||||
if (match) {
|
||||
@@ -66,7 +72,9 @@ interface UrlMetaData {
|
||||
|
||||
export async function parseUrlMeta(url: string) {
|
||||
try {
|
||||
const metadata = (await urlMetadata(url)) as UrlMetaData;
|
||||
const metadata = (await urlMetadata(url, {
|
||||
timeout: 500,
|
||||
})) as UrlMetaData;
|
||||
const data = transform(metadata, url);
|
||||
return data;
|
||||
} catch (err) {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"strictNullChecks": true
|
||||
},
|
||||
"include": ["."],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig } from 'tsdown';
|
||||
import type { Options } from 'tsdown';
|
||||
import { defineConfig } from 'tsdown';
|
||||
|
||||
const options: Options = {
|
||||
clean: true,
|
||||
|
||||
BIN
apps/justfuckinguseopenpanel/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
767
apps/justfuckinguseopenpanel/index.html
Normal file
@@ -0,0 +1,767 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>Just Fucking Use OpenPanel - Stop Overpaying for Analytics</title>
|
||||
<meta
|
||||
name="title"
|
||||
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
|
||||
>
|
||||
<meta
|
||||
name="description"
|
||||
content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted."
|
||||
>
|
||||
<meta
|
||||
name="keywords"
|
||||
content="openpanel, analytics, mixpanel alternative, posthog alternative, product analytics, web analytics, open source analytics, self-hosted analytics"
|
||||
>
|
||||
<meta name="author" content="OpenPanel">
|
||||
<meta name="robots" content="index, follow">
|
||||
<link rel="canonical" href="https://justfuckinguseopenpanel.dev/">
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta
|
||||
property="og:title"
|
||||
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
|
||||
>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted."
|
||||
>
|
||||
<meta property="og:image" content="/ogimage.png">
|
||||
<meta property="og:image:width" content="1200">
|
||||
<meta property="og:image:height" content="630">
|
||||
<meta property="og:site_name" content="Just Fucking Use OpenPanel">
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:url" content="https://justfuckinguseopenpanel.dev/">
|
||||
<meta
|
||||
name="twitter:title"
|
||||
content="Just Fucking Use OpenPanel - Stop Overpaying for Analytics"
|
||||
>
|
||||
<meta
|
||||
name="twitter:description"
|
||||
content="Mixpanel costs $2,300/month at scale. PostHog costs $1,982/month. Web-only tools tell you nothing about user behavior. OpenPanel gives you full product analytics for a fraction of the cost, or FREE self-hosted."
|
||||
>
|
||||
<meta name="twitter:image" content="/ogimage.png">
|
||||
|
||||
<!-- Additional Meta Tags -->
|
||||
<meta name="theme-color" content="#0a0a0a">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
|
||||
Arial, sans-serif;
|
||||
font-size: 18px;
|
||||
line-height: 1.75;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.3;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #3b82f6;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-left: 1.5rem;
|
||||
margin-bottom: 1.25em;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
padding-left: 1.5rem;
|
||||
margin: 1.5rem 0;
|
||||
font-style: italic;
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #374151;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
tr:hover {
|
||||
background: #131313;
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
margin: 0 -4rem 4rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.screenshot {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot-inner {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.window-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.window-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.window-dot.red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.window-dot.yellow {
|
||||
background: #eab308;
|
||||
}
|
||||
|
||||
.window-dot.green {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.screenshot-image-wrapper {
|
||||
width: 100%;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
}
|
||||
|
||||
.screenshot img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.cta {
|
||||
background: #131313;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 2rem;
|
||||
margin: 3rem 0;
|
||||
text-align: center;
|
||||
margin: 0 -4rem 4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.cta {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cta h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.cta a {
|
||||
display: inline-block;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
margin: 0.5rem;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta a:hover {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 4rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #374151;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
text-align: left;
|
||||
margin-top: 4rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.hero p {
|
||||
font-size: 1.25rem;
|
||||
color: #8f8f8f;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin-top: 1rem;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="hero">
|
||||
<h1>Just Fucking Use OpenPanel</h1>
|
||||
<p>
|
||||
Stop settling for basic metrics. Get real insights that actually help
|
||||
you build a better product.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img
|
||||
src="screenshots/realtime-dark.webp"
|
||||
alt="OpenPanel Real-time Analytics"
|
||||
width="1400"
|
||||
height="800"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
Real-time analytics - see events as they happen. No waiting, no
|
||||
delays.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The PostHog/Mixpanel Problem (Volume Pricing Hell)</h2>
|
||||
|
||||
<p>
|
||||
Let's talk about what happens when you have a
|
||||
<strong>real product</strong> with <strong>real users</strong>.
|
||||
</p>
|
||||
|
||||
<p><strong>Real pricing at scale (20M+ events/month):</strong></p>
|
||||
<ul>
|
||||
<li><strong>Mixpanel</strong>: $2,300/month (and more with add-ons)</li>
|
||||
<li><strong>PostHog</strong>: $1,982/month (and more with add-ons)</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
"1 million free events!" they scream. Cute. Until you have an actual
|
||||
product with actual users doing actual things. Then suddenly you need to
|
||||
"talk to sales" and your wallet starts bleeding.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Add-ons, add-ons everywhere. Session replay? +$X. Feature flags? +$X.
|
||||
HIPAA compliance? +$250/month. A/B testing? That'll be extra. You're
|
||||
hemorrhaging money just to understand what your users are doing, you
|
||||
magnificent fool.
|
||||
</p>
|
||||
|
||||
<h2>The Web-Only Analytics Trap</h2>
|
||||
|
||||
<p>
|
||||
You built a great fucking product. You have real traffic. Thousands,
|
||||
tens of thousands of visitors. But you're flying blind.
|
||||
</p>
|
||||
|
||||
<blockquote>
|
||||
"Congrats, 50,000 visitors from France this month. Why didn't a single
|
||||
one buy your baguette?"
|
||||
</blockquote>
|
||||
|
||||
<p>
|
||||
You see the traffic. You see the bounce rate. You see the referrers. You
|
||||
see where they're from. You have <strong>NO FUCKING IDEA</strong> what
|
||||
users actually do.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Where do they drop off? Do they come back? What features do they use?
|
||||
Why didn't they convert? Who the fuck knows! You're using a glorified
|
||||
hit counter with a pretty dashboard that tells you everything about
|
||||
geography and nothing about behavior.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Plausible. Umami. Fathom. Simple Analytics. GoatCounter. Cabin. Pirsch.
|
||||
They're all the same story: simple analytics with some goals you can
|
||||
define. Page views, visitors, countries, basic funnels. That's it. No
|
||||
retention analysis. No user profiles. No event tracking. No cohorts. No
|
||||
revenue tracking. Just... basic web analytics.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
And when you finally need to understand your users—when you need to see
|
||||
where they drop off in your signup flow, or which features drive
|
||||
retention, or why your conversion rate is shit—you end up paying for a
|
||||
<strong>SECOND tool</strong> on top. Now you're paying for two
|
||||
subscriptions, managing two dashboards, and your users' data is split
|
||||
across two platforms like a bad divorce.
|
||||
</p>
|
||||
|
||||
<h2>Counter One Dollar Stats</h2>
|
||||
|
||||
<p>"$1/month for page views. Adorable."</p>
|
||||
|
||||
<p>
|
||||
Look, I get it. A dollar is cheap. But you're getting exactly what you
|
||||
pay for: page views. That's it. No funnels. No retention. No user
|
||||
profiles. No event tracking. Just... page views.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Here's the thing: if you want to make <strong>good decisions</strong>
|
||||
about your product, you need to understand
|
||||
<strong>what your users are actually doing</strong>, not just where the
|
||||
fuck they're from.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
OpenPanel gives you the full product analytics suite. Or self-host for
|
||||
<strong>FREE</strong> with <strong>UNLIMITED events</strong>.
|
||||
</p>
|
||||
|
||||
<p>You get:</p>
|
||||
<ul>
|
||||
<li>Funnels to see where users drop off</li>
|
||||
<li>Retention analysis to see who comes back</li>
|
||||
<li>Cohorts to segment your users</li>
|
||||
<li>User profiles to understand individual behavior</li>
|
||||
<li>Custom dashboards to see what matters to YOU</li>
|
||||
<li>Revenue tracking to see what actually makes money</li>
|
||||
<li>
|
||||
All the web analytics (page views, visitors, referrers) that the other
|
||||
tools give you
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
One Dollar Stats tells you 50,000 people visited from France. OpenPanel
|
||||
tells you why they didn't buy your baguette. That's the difference
|
||||
between vanity metrics and actual insights.
|
||||
</p>
|
||||
|
||||
<h2>Why OpenPanel is the Answer</h2>
|
||||
|
||||
<p>
|
||||
You want analytics that actually help you build a better product. Not
|
||||
vanity metrics. Not enterprise pricing. Not two separate tools.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To make good decisions, you need to understand
|
||||
<strong>what your users are doing</strong>, not just where they're from.
|
||||
You need to see where they drop off. You need to know which features
|
||||
they use. You need to understand why they convert or why they don't.
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Open Source & Self-Hostable</strong>: AGPL-3.0 - fork it,
|
||||
audit it, own it. Self-host for FREE with unlimited events, or use our
|
||||
cloud
|
||||
</li>
|
||||
<li>
|
||||
<strong>Price</strong>: Affordable pricing that scales, or FREE
|
||||
self-hosted (unlimited events, forever)
|
||||
</li>
|
||||
<li>
|
||||
<strong>SDK Size</strong>: 2.3KB (PostHog is 52KB+ - that's 22x
|
||||
bigger, you performance-obsessed maniac)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Privacy</strong>: Cookie-free by default, EU-only hosting (or
|
||||
your own servers if you self-host)
|
||||
</li>
|
||||
<li>
|
||||
<strong>Full Suite</strong>: Web analytics + product analytics in one
|
||||
tool. No need for two subscriptions.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img
|
||||
src="screenshots/overview-dark.webp"
|
||||
alt="OpenPanel Overview Dashboard"
|
||||
width="1400"
|
||||
height="800"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
OpenPanel overview showing web analytics and product analytics in one
|
||||
clean interface
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>Open Source & Self-Hosting: The Ultimate Fuck You to Pricing Hell</h2>
|
||||
|
||||
<p>
|
||||
Tired of watching your analytics bill grow every month? Tired of "talk
|
||||
to sales" when you hit their arbitrary limits? Tired of paying
|
||||
$2,000+/month just to understand your users?
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>OpenPanel is open source.</strong> AGPL-3.0 licensed. You can
|
||||
fork it. You can audit it. You can own it. And you can
|
||||
<strong>self-host it for FREE with UNLIMITED events</strong>.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
That's right. Zero dollars. Unlimited events. All the features. Your
|
||||
data on your servers. No vendor lock-in. No surprise bills. No
|
||||
"enterprise sales" calls.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Mixpanel at 20M events? $2,300/month. PostHog? $1,982/month. OpenPanel
|
||||
self-hosted? <strong>$0/month</strong>. Forever.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Don't want to manage infrastructure? That's fine. Use our cloud. But if
|
||||
you want to escape the pricing hell entirely, self-hosting is a Docker
|
||||
command away. Your data, your rules, your wallet.
|
||||
</p>
|
||||
|
||||
<h2>The Comparison Table (The Brutal Truth)</h2>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Tool</th>
|
||||
<th>Price at 20M events</th>
|
||||
<th>What You Get</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Mixpanel</strong></td>
|
||||
<td>$2,300+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>PostHog</strong></td>
|
||||
<td>$1,982+/month</td>
|
||||
<td>Not all feautres... since addons are extra</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plausible</strong></td>
|
||||
<td>Various pricing</td>
|
||||
<td>
|
||||
Simple analytics with basic goals. Page views and visitors. That's
|
||||
it.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>One Dollar Stats</strong></td>
|
||||
<td>$1/month</td>
|
||||
<td>Page views (but cheaper!)</td>
|
||||
</tr>
|
||||
<tr style="background: #131313; border: 2px solid #3b82f6;">
|
||||
<td><strong>OpenPanel</strong></td>
|
||||
<td><strong>~$530/mo or FREE (self-hosted)</strong></td>
|
||||
<td>
|
||||
<strong
|
||||
>Web + Product analytics. The full package. Open source. Your
|
||||
data.</strong
|
||||
>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img
|
||||
src="screenshots/profile-dark.webp"
|
||||
alt="OpenPanel User Profiles"
|
||||
width="1400"
|
||||
height="800"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
User profiles - see individual user journeys and behavior. Something
|
||||
web-only tools can't give you.
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img
|
||||
src="screenshots/report-dark.webp"
|
||||
alt="OpenPanel Reports and Funnels"
|
||||
width="1400"
|
||||
height="800"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
Funnels, retention, and custom reports - the features you CAN'T get
|
||||
with web-only tools
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<h2>The Bottom Fucking Line</h2>
|
||||
|
||||
<p>
|
||||
If you want to make good decisions about your product, you need to
|
||||
understand what your users are actually doing. Not just where they're
|
||||
from. Not just how many page views you got. You need to see the full
|
||||
picture: funnels, retention, user behavior, conversion paths.
|
||||
</p>
|
||||
|
||||
<p>You have three choices:</p>
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
Keep using Google Analytics like a data-harvesting accomplice, adding
|
||||
cookie banners, annoying your users, and contributing to the dystopian
|
||||
surveillance economy
|
||||
</li>
|
||||
<li>
|
||||
Pay $2,000+/month for Mixpanel or PostHog when you scale, or use
|
||||
simple web-only analytics that tell you nothing about user
|
||||
behavior—just where they're from
|
||||
</li>
|
||||
<li>
|
||||
Use OpenPanel (affordable pricing or FREE self-hosted) and get the
|
||||
full analytics suite: web analytics AND product analytics in one tool,
|
||||
so you can actually understand what your users do
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<p>
|
||||
If you picked option 1 or 2, I can't help you. You're beyond saving. Go
|
||||
enjoy your complicated, privacy-violating, overpriced analytics life
|
||||
where you know everything about where your users are from but nothing
|
||||
about what they actually do.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
But if you have even one functioning brain cell, you'll realize that
|
||||
OpenPanel gives you everything you need—web analytics AND product
|
||||
analytics—for a fraction of what the enterprise tools cost. You'll
|
||||
finally understand what your users are doing, not just where the fuck
|
||||
they're from.
|
||||
</p>
|
||||
|
||||
<div class="cta">
|
||||
<h2>Ready to understand what your users actually do?</h2>
|
||||
<p>
|
||||
Stop settling for vanity metrics. Get the full analytics suite—web
|
||||
analytics AND product analytics—so you can make better decisions. Or
|
||||
self-host for free.
|
||||
</p>
|
||||
<a href="https://openpanel.dev" target="_blank"
|
||||
>Get Started with OpenPanel</a
|
||||
>
|
||||
<a
|
||||
href="https://openpanel.dev/docs/self-hosting/self-hosting"
|
||||
target="_blank"
|
||||
>Self-Host Guide</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<figure class="screenshot">
|
||||
<div class="screenshot-inner">
|
||||
<div class="window-controls">
|
||||
<div class="window-dot red"></div>
|
||||
<div class="window-dot yellow"></div>
|
||||
<div class="window-dot green"></div>
|
||||
</div>
|
||||
<div class="screenshot-image-wrapper">
|
||||
<img
|
||||
src="screenshots/dashboard-dark.webp"
|
||||
alt="OpenPanel Custom Dashboards"
|
||||
width="1400"
|
||||
height="800"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<figcaption>
|
||||
Custom dashboards - build exactly what you need to understand your
|
||||
product
|
||||
</figcaption>
|
||||
</figure>
|
||||
|
||||
<footer>
|
||||
<p><strong>Just Fucking Use OpenPanel</strong></p>
|
||||
<p>
|
||||
Inspired by
|
||||
<a
|
||||
href="https://justfuckingusereact.com"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>justfuckingusereact.com</a
|
||||
>, <a
|
||||
href="https://justfuckingusehtml.com"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>justfuckingusehtml.com</a
|
||||
>, and
|
||||
<a
|
||||
href="https://justfuckinguseonedollarstats.com"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>justfuckinguseonedollarstats.com</a
|
||||
>
|
||||
and all other just fucking use sites.
|
||||
</p>
|
||||
<p style="margin-top: 1rem;">
|
||||
This is affiliated with
|
||||
<a href="https://openpanel.dev" target="_blank" rel="nofollow"
|
||||
>OpenPanel</a
|
||||
>. We still love all products mentioned in this website, and we're
|
||||
grateful for them and what they do 🫶
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
window.op =
|
||||
window.op ||
|
||||
(() => {
|
||||
var n = [];
|
||||
return new Proxy(
|
||||
function () {
|
||||
arguments.length && n.push([].slice.call(arguments));
|
||||
},
|
||||
{
|
||||
get(t, r) {
|
||||
returnr === 'q'
|
||||
? n
|
||||
: function () {
|
||||
n.push([r].concat([].slice.call(arguments)));
|
||||
};
|
||||
},
|
||||
has(t, r) {
|
||||
returnr === 'q';
|
||||
},
|
||||
}
|
||||
);
|
||||
})();
|
||||
window.op('init', {
|
||||
clientId: '59d97757-9449-44cf-a8c1-8f213843b4f0',
|
||||
trackScreenViews: true,
|
||||
trackOutgoingLinks: true,
|
||||
trackAttributes: true,
|
||||
});
|
||||
</script>
|
||||
<script src="https://openpanel.dev/op1.js" defer async></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
apps/justfuckinguseopenpanel/ogimage.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/dashboard-dark.webp
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/overview-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/profile-dark.webp
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/realtime-dark.webp
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/justfuckinguseopenpanel/screenshots/report-dark.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
7
apps/justfuckinguseopenpanel/wrangler.jsonc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"name": "justfuckinguseopenpanel",
|
||||
"compatibility_date": "2025-12-19",
|
||||
"assets": {
|
||||
"directory": "."
|
||||
}
|
||||
}
|
||||
2
apps/public/.gitignore
vendored
@@ -2,8 +2,6 @@
|
||||
/node_modules
|
||||
|
||||
# generated content
|
||||
.contentlayer
|
||||
.content-collections
|
||||
.source
|
||||
|
||||
# test & build
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
ARG NODE_VERSION=20.15.1
|
||||
|
||||
FROM --platform=linux/amd64 node:${NODE_VERSION}-slim AS base
|
||||
|
||||
ARG DATABASE_URL
|
||||
ENV DATABASE_URL=$DATABASE_URL
|
||||
|
||||
ARG REDIS_URL
|
||||
ENV REDIS_URL=$REDIS_URL
|
||||
|
||||
ARG CLICKHOUSE_URL
|
||||
ENV CLICKHOUSE_URL=$CLICKHOUSE_URL
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
RUN apt update \
|
||||
&& apt install -y curl \
|
||||
&& curl -L https://raw.githubusercontent.com/tj/n/master/bin/n -o n \
|
||||
&& bash n $NODE_VERSION \
|
||||
&& rm n \
|
||||
&& npm install -g n
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package.json
|
||||
COPY pnpm-lock.yaml pnpm-lock.yaml
|
||||
COPY pnpm-workspace.yaml pnpm-workspace.yaml
|
||||
COPY apps/public/package.json apps/public/package.json
|
||||
COPY packages/db/package.json packages/db/package.json
|
||||
COPY packages/redis/package.json packages/redis/package.json
|
||||
COPY packages/queue/package.json packages/queue/package.json
|
||||
COPY packages/common/package.json packages/common/package.json
|
||||
COPY packages/constants/package.json packages/constants/package.json
|
||||
COPY packages/validation/package.json packages/validation/package.json
|
||||
COPY packages/sdks/sdk/package.json packages/sdks/sdk/package.json
|
||||
COPY packages/sdks/_info/package.json packages/sdks/_info/package.json
|
||||
|
||||
# BUILD
|
||||
FROM base AS build
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app
|
||||
COPY apps/public apps/public
|
||||
COPY packages packages
|
||||
COPY tooling tooling
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm run build
|
||||
|
||||
# PROD
|
||||
FROM base AS prod
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
|
||||
|
||||
# FINAL
|
||||
FROM base AS runner
|
||||
|
||||
COPY --from=build /app/package.json /app/package.json
|
||||
COPY --from=prod /app/node_modules /app/node_modules
|
||||
# Apps
|
||||
COPY --from=build /app/apps/public /app/apps/public
|
||||
# Apps node_modules
|
||||
COPY --from=prod /app/apps/public/node_modules /app/apps/public/node_modules
|
||||
# Packages
|
||||
COPY --from=build /app/packages/db /app/packages/db
|
||||
COPY --from=build /app/packages/redis /app/packages/redis
|
||||
COPY --from=build /app/packages/common /app/packages/common
|
||||
COPY --from=build /app/packages/queue /app/packages/queue
|
||||
COPY --from=build /app/packages/constants /app/packages/constants
|
||||
COPY --from=build /app/packages/validation /app/packages/validation
|
||||
COPY --from=build /app/packages/sdks/sdk /app/packages/sdks/sdk
|
||||
COPY --from=build /app/packages/sdks/_info /app/packages/sdks/_info
|
||||
# Packages node_modules
|
||||
COPY --from=prod /app/packages/db/node_modules /app/packages/db/node_modules
|
||||
COPY --from=prod /app/packages/redis/node_modules /app/packages/redis/node_modules
|
||||
COPY --from=prod /app/packages/common/node_modules /app/packages/common/node_modules
|
||||
COPY --from=prod /app/packages/queue/node_modules /app/packages/queue/node_modules
|
||||
COPY --from=prod /app/packages/validation/node_modules /app/packages/validation/node_modules
|
||||
|
||||
RUN pnpm db:codegen
|
||||
|
||||
WORKDIR /app/apps/public
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
||||
@@ -15,6 +15,25 @@ yarn dev
|
||||
|
||||
Open http://localhost:3000 with your browser to see the result.
|
||||
|
||||
## Explore
|
||||
|
||||
In the project, you can see:
|
||||
|
||||
- `lib/source.ts`: Code for content source adapter, [`loader()`](https://fumadocs.dev/docs/headless/source-api) provides the interface to access your content.
|
||||
- `lib/layout.shared.tsx`: Shared options for layouts, optional but preferred to keep.
|
||||
|
||||
| Route | Description |
|
||||
| ------------------------- | ------------------------------------------------------ |
|
||||
| `app/(home)` | The route group for your landing page and other pages. |
|
||||
| `app/docs` | The documentation layout and pages. |
|
||||
| `app/api/search/route.ts` | The Route Handler for search. |
|
||||
|
||||
### Fumadocs MDX
|
||||
|
||||
A `source.config.ts` config file has been included, you can customise different options like frontmatter schema.
|
||||
|
||||
Read the [Introduction](https://fumadocs.dev/docs/mdx) for further details.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js and Fumadocs, take a look at the following
|
||||
@@ -23,4 +42,4 @@ 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.
|
||||
- [Fumadocs](https://fumadocs.vercel.app) - learn about Fumadocs
|
||||
- [Fumadocs](https://fumadocs.dev) - learn about Fumadocs
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { url, getAuthor } from '@/app/layout.config';
|
||||
import { SingleSwirl } from '@/components/Swirls';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { Logo } from '@/components/logo';
|
||||
import { SectionHeader } from '@/components/section';
|
||||
import { Toc } from '@/components/toc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import { ArrowLeftIcon } from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import Script from 'next/script';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const author = getAuthor(article?.data.team);
|
||||
|
||||
if (!article) {
|
||||
return {
|
||||
title: 'Article Not Found',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
authors: [{ name: author.name }],
|
||||
alternates: {
|
||||
canonical: url(article.url),
|
||||
},
|
||||
openGraph: {
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
type: 'article',
|
||||
publishedTime: article.data.date.toISOString(),
|
||||
authors: author.name,
|
||||
images: url(article.data.cover),
|
||||
url: url(article.url),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: article.data.title,
|
||||
description: article.data.description,
|
||||
images: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function Page({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ articleSlug: string }>;
|
||||
}) {
|
||||
const { articleSlug } = await params;
|
||||
const article = await articleSource.getPage([articleSlug]);
|
||||
const Body = article?.data.body;
|
||||
const author = getAuthor(article?.data.team);
|
||||
const goBackUrl = '/articles';
|
||||
|
||||
const relatedArticles = (await articleSource.getPages())
|
||||
.filter(
|
||||
(item) =>
|
||||
item.data.tag === article?.data.tag && item.url !== article?.url,
|
||||
)
|
||||
.sort((a, b) => b.data.date.getTime() - a.data.date.getTime());
|
||||
|
||||
if (!Body) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Create the JSON-LD data
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: article?.data.title,
|
||||
datePublished: article?.data.date.toISOString(),
|
||||
author: {
|
||||
'@type': 'Person',
|
||||
name: author.name,
|
||||
},
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url(article.url),
|
||||
},
|
||||
image: {
|
||||
'@type': 'ImageObject',
|
||||
url: url(article.data.cover),
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
strategy="beforeInteractive"
|
||||
id="article-schema"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<article className="container max-w-5xl col">
|
||||
<div className="py-16">
|
||||
<Link
|
||||
href={goBackUrl}
|
||||
className="flex items-center gap-2 mb-4 text-muted-foreground"
|
||||
>
|
||||
<ArrowLeftIcon className="w-4 h-4" />
|
||||
<span>Back to all articles</span>
|
||||
</Link>
|
||||
<div className="flex-col-reverse col md:row gap-8">
|
||||
<div className="col flex-1">
|
||||
<h1 className="text-5xl font-bold leading-tight">
|
||||
{article?.data.title}
|
||||
</h1>
|
||||
|
||||
<div className="row gap-4 items-center mt-8">
|
||||
<div className="size-10 center-center bg-black rounded-full">
|
||||
{author.image ? (
|
||||
<Image
|
||||
className="size-10 object-cover rounded-full"
|
||||
src={author.image}
|
||||
alt={author.name}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
) : (
|
||||
<Logo className="w-6 h-6 fill-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="col">
|
||||
<p className="font-medium">{author.name}</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{article?.data.date.toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_300px] gap-0">
|
||||
<div className="min-w-0">
|
||||
<div className="prose [&_table]:w-auto [&_img]:max-w-full [&_img]:h-auto">
|
||||
<Body />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="pl-12 pb-12 gap-8 col">
|
||||
<Toc toc={article?.data.toc} />
|
||||
<section className="overflow-hidden relative bg-foreground dark:bg-background-dark text-background dark:text-foreground rounded-xl py-16">
|
||||
<SingleSwirl className="pointer-events-none absolute top-0 bottom-0 left-0 size-[300px]" />
|
||||
<SingleSwirl className="pointer-events-none rotate-180 absolute top-0 bottom-0 -right-0 opacity-50 size-[300px]" />
|
||||
<div className="container center-center col">
|
||||
<SectionHeader
|
||||
className="mb-8"
|
||||
title="Try it"
|
||||
description="Give it a spin for free. No credit card required."
|
||||
/>
|
||||
<Button size="lg" variant="secondary" asChild>
|
||||
<Link href="https://dashboard.openpanel.dev/onboarding">
|
||||
Get started today!
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{relatedArticles.length > 0 && (
|
||||
<div className="my-16">
|
||||
<h3 className="text-2xl font-bold mb-8">Related articles</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{relatedArticles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { ArticleCard } from '@/components/article-card';
|
||||
import { articleSource } from '@/lib/source';
|
||||
import type { Metadata } from 'next';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
const title = 'Articles';
|
||||
const description = 'Read our latest articles';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title,
|
||||
description,
|
||||
alternates: {
|
||||
canonical: url('/articles'),
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Page() {
|
||||
const articles = (await articleSource.getPages()).sort(
|
||||
(a, b) => b.data.date.getTime() - a.data.date.getTime(),
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<div className="container col">
|
||||
<div className="py-16">
|
||||
<h1 className="text-center text-7xl font-bold">Articles</h1>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-8">
|
||||
{articles.map((item) => (
|
||||
<ArticleCard
|
||||
key={item.url}
|
||||
url={item.url}
|
||||
title={item.data.title}
|
||||
tag={item.data.tag}
|
||||
cover={item.data.cover}
|
||||
team={item.data.team}
|
||||
date={item.data.date}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main className="overflow-hidden">
|
||||
<HeroContainer className="h-screen pointer-events-none" />
|
||||
<div className="absolute h-screen inset-0 radial-gradient-dot-pages select-none pointer-events-none" />
|
||||
<div className="-mt-[calc(100vh-100px)] relative min-h-[500px] pb-12 -mb-24">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,288 +0,0 @@
|
||||
import { url } from '@/app/layout.config';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import { Section, SectionHeader } from '@/components/section';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { SupporterPerks } from '@/components/sections/supporter-perks';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { Tag } from '@/components/tag';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
HeartHandshakeIcon,
|
||||
SparklesIcon,
|
||||
ZapIcon,
|
||||
} from 'lucide-react';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import Script from 'next/script';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
alternates: {
|
||||
canonical: url('/supporter'),
|
||||
},
|
||||
openGraph: {
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
type: 'website',
|
||||
url: url('/supporter'),
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'Become a Supporter',
|
||||
description:
|
||||
'Support OpenPanel and get exclusive perks like latest Docker images, prioritized support, and early access to new features.',
|
||||
},
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Article',
|
||||
headline: 'Become a Supporter',
|
||||
publisher: {
|
||||
'@type': 'Organization',
|
||||
name: 'OpenPanel',
|
||||
logo: {
|
||||
'@type': 'ImageObject',
|
||||
url: url('/logo.png'),
|
||||
},
|
||||
},
|
||||
mainEntityOfPage: {
|
||||
'@type': 'WebPage',
|
||||
'@id': url('/supporter'),
|
||||
},
|
||||
};
|
||||
|
||||
export default function SupporterPage() {
|
||||
return (
|
||||
<div>
|
||||
<Script
|
||||
id="supporter-schema"
|
||||
strategy="beforeInteractive"
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
|
||||
/>
|
||||
<HeroContainer>
|
||||
<div className="container relative z-10 col sm:py-44 max-sm:pt-32">
|
||||
<div className="col gap-8 text-center">
|
||||
<div className="col gap-4">
|
||||
<Tag className="self-center">
|
||||
<HeartHandshakeIcon className="size-4 text-rose-600" />
|
||||
Support Open-Source Analytics
|
||||
</Tag>
|
||||
<h1 className="text-4xl md:text-5xl font-extrabold leading-[1.1]">
|
||||
Help us build the future of{' '}
|
||||
<span className="text-primary">open analytics</span>
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
|
||||
Your support accelerates development, funds infrastructure, and
|
||||
helps us build features faster. Plus, you get exclusive perks
|
||||
and early access to everything we ship.
|
||||
</p>
|
||||
</div>
|
||||
<div className="col gap-4 justify-center items-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter
|
||||
<SparklesIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Starting at $20/month • Cancel anytime
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<div className="container max-w-7xl">
|
||||
{/* Main Content with Sidebar */}
|
||||
<div className="grid lg:grid-cols-[1fr_380px] gap-8 mb-16">
|
||||
{/* Main Content */}
|
||||
<div className="col gap-12">
|
||||
{/* Why Support Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">Why your support matters</h2>
|
||||
<div className="col gap-6 text-muted-foreground">
|
||||
<p className="text-lg">
|
||||
We're not a big corporation – just a small team passionate
|
||||
about building something useful for developers. OpenPanel
|
||||
started because we believed analytics tools shouldn't be
|
||||
complicated or locked behind expensive enterprise
|
||||
subscriptions.
|
||||
</p>
|
||||
<p>When you become a supporter, you're directly funding:</p>
|
||||
<ul className="col gap-3 list-none pl-0">
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Active Development
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
More time fixing bugs, adding features, and improving
|
||||
documentation
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">
|
||||
Infrastructure
|
||||
</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Keeping servers running, CI/CD pipelines, and
|
||||
development tools
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<ZapIcon className="size-5 text-primary mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<strong className="text-foreground">Independence</strong>
|
||||
<p className="text-sm mt-1">
|
||||
Staying focused on what matters: building a tool
|
||||
developers actually want
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
No corporate speak, no fancy promises – just honest work on
|
||||
making OpenPanel better for everyone. Every contribution, no
|
||||
matter the size, helps us stay independent and focused on what
|
||||
matters.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* What You Get Section */}
|
||||
<section className="col gap-6">
|
||||
<h2 className="text-3xl font-bold">
|
||||
What you get as a supporter
|
||||
</h2>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
🚀 Latest Docker Images
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get bleeding-edge builds on every commit. Access new
|
||||
features weeks before public release.
|
||||
</p>
|
||||
<Link
|
||||
href="/docs/self-hosting/supporter-access-latest-docker-images"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
Learn more →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
💬 Prioritized Support
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Get help faster with priority support in our Discord
|
||||
community. Your questions get answered first.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
✨ Feature Requests
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Your ideas and feature requests get prioritized in our
|
||||
roadmap. Shape the future of OpenPanel.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 rounded-lg border bg-card">
|
||||
<h3 className="font-semibold text-lg mb-2">
|
||||
⭐ Exclusive Discord Role
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
Special badge and recognition in our community. Show your
|
||||
support with pride.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Impact Section */}
|
||||
<section className="p-8 rounded-xl border bg-gradient-to-br from-primary/5 to-primary/10">
|
||||
<h2 className="text-2xl font-bold mb-4">Your impact</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Every dollar you contribute goes directly into development,
|
||||
infrastructure, and making OpenPanel better. Here's what your
|
||||
support enables:
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
100%
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Open Source
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">
|
||||
24/7
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Active Development
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-3xl font-bold text-primary mb-2">∞</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Self-Hostable
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:block hidden">
|
||||
<SupporterPerks />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Mobile Perks */}
|
||||
<div className="lg:hidden mb-16">
|
||||
<SupporterPerks />
|
||||
</div>
|
||||
|
||||
{/* CTA Section */}
|
||||
<Section className="container my-0 py-20">
|
||||
<SectionHeader
|
||||
tag={
|
||||
<Tag>
|
||||
<ArrowDownIcon className="size-4" strokeWidth={1.5} />
|
||||
Starting at $20/month
|
||||
</Tag>
|
||||
}
|
||||
title="Ready to support OpenPanel?"
|
||||
description="Join our community of supporters and help us build the best open-source alternative to Mixpanel. Every contribution helps accelerate development and make OpenPanel better for everyone."
|
||||
/>
|
||||
<div className="center-center">
|
||||
<Button size="lg" asChild>
|
||||
<Link href="https://buy.polar.sh/polar_cl_Az1CruNFzQB2bYdMOZmGHqTevW317knWqV44W1FqZmV">
|
||||
Become a Supporter Now
|
||||
<HeartHandshakeIcon className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
<div className="lg:-mx-20 xl:-mx-40 not-prose mt-16">
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import {
|
||||
createNextRouteHandler,
|
||||
createScriptHandler,
|
||||
} from '@openpanel/nextjs/server';
|
||||
|
||||
export const POST = createNextRouteHandler();
|
||||
export const GET = createScriptHandler();
|
||||
@@ -1,4 +0,0 @@
|
||||
import { source } from '@/lib/source';
|
||||
import { createFromSource } from 'fumadocs-core/search/server';
|
||||
|
||||
export const { GET } = createFromSource(source);
|
||||
@@ -1,67 +0,0 @@
|
||||
import { url, siteName } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import defaultMdxComponents from 'fumadocs-ui/mdx';
|
||||
import {
|
||||
DocsBody,
|
||||
DocsDescription,
|
||||
DocsPage,
|
||||
DocsTitle,
|
||||
} from 'fumadocs-ui/page';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
export default async function Page(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
const MDX = page.data.body;
|
||||
|
||||
return (
|
||||
<DocsPage toc={page.data.toc} full={page.data.full}>
|
||||
<DocsTitle>{page.data.title}</DocsTitle>
|
||||
<DocsDescription>{page.data.description}</DocsDescription>
|
||||
<DocsBody>
|
||||
<MDX components={{ ...defaultMdxComponents }} />
|
||||
</DocsBody>
|
||||
</DocsPage>
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return source.generateParams();
|
||||
}
|
||||
|
||||
export async function generateMetadata(props: {
|
||||
params: Promise<{ slug?: string[] }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const page = source.getPage(params.slug);
|
||||
if (!page) notFound();
|
||||
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
canonical: url(page.url),
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
url: url(page.url),
|
||||
type: 'article',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { source } from '@/lib/source';
|
||||
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<DocsLayout tree={source.pageTree} {...baseOptions}>
|
||||
{children}
|
||||
</DocsLayout>
|
||||
);
|
||||
}
|
||||
@@ -1,224 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--green: 156 71% 67%;
|
||||
--red: 351 89% 72%;
|
||||
--background: 0 0% 98%;
|
||||
--background-light: 0 0% 100%;
|
||||
--background-dark: 0 0% 96%;
|
||||
--foreground: 0 0% 9%;
|
||||
--foreground-dark: 0 0% 7.5%;
|
||||
--foreground-light: 0 0% 11%;
|
||||
--card: 0 0% 98%;
|
||||
--card-foreground: 0 0% 9%;
|
||||
--popover: 0 0% 98%;
|
||||
--popover-foreground: 0 0% 9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 0 0% 9%;
|
||||
--background-dark: 0 0% 7.5%;
|
||||
--background-light: 0 0% 11%;
|
||||
--foreground: 0 0% 98%;
|
||||
--foreground-light: 0 0% 100%;
|
||||
--foreground-dark: 0 0% 96%;
|
||||
--card: 0 0% 9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply !bg-[hsl(var(--background))] text-foreground font-sans text-base antialiased flex flex-col min-h-screen;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.container {
|
||||
@apply max-w-6xl mx-auto px-6 md:px-10 lg:px-14 w-full;
|
||||
}
|
||||
|
||||
.pulled {
|
||||
@apply -mx-2 md:-mx-6 lg:-mx-10 xl:-mx-20;
|
||||
}
|
||||
|
||||
.row {
|
||||
@apply flex flex-row;
|
||||
}
|
||||
|
||||
.col {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.center-center {
|
||||
@apply flex items-center justify-center text-center;
|
||||
}
|
||||
}
|
||||
|
||||
strong {
|
||||
@apply font-semibold;
|
||||
}
|
||||
|
||||
|
||||
.radial-gradient {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at bottom, hsl(var(--background-light)), hsl(var(--background)));
|
||||
}
|
||||
|
||||
.radial-gradient-dot-1 {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 20%, hsl(var(--background-light)), transparent);
|
||||
}
|
||||
|
||||
.radial-gradient-dot-pages {
|
||||
background: #BECCDF;
|
||||
background: radial-gradient(at 50% 50%, hsl(var(--background)), hsl(var(--background)/0.2));
|
||||
}
|
||||
|
||||
.animated-iframe-gradient {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.animated-iframe-gradient:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
background: linear-gradient(250deg, hsl(var(--foreground)/0.9), transparent);
|
||||
|
||||
animation: GradientRotate 8s linear infinite;
|
||||
}
|
||||
@keyframes GradientRotate {
|
||||
0% { transform: translate(-50%, -50%) rotate(0deg); }
|
||||
100% { transform: translate(-50%, -50%) rotate(360deg); }
|
||||
}
|
||||
|
||||
.line-before {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-before:before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
left: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
.line-after {
|
||||
position: relative;
|
||||
padding: 16px;
|
||||
}
|
||||
.line-after:after {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: calc(4px*-32);
|
||||
bottom: calc(4px*-32);
|
||||
right: 0;
|
||||
width: 1px;
|
||||
background: hsl(var(--foreground)/0.1);
|
||||
}
|
||||
|
||||
.animate-fade-up {
|
||||
animation: animateFadeUp 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeUp {
|
||||
0% { transform: translateY(0.5rem); scale: 0.95; }
|
||||
100% { transform: translateY(0); scale: 1; }
|
||||
}
|
||||
|
||||
.animate-fade-down {
|
||||
animation: animateFadeDown 0.5s ease-in-out;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes animateFadeDown {
|
||||
0% { transform: translateY(-1rem); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* Docs */
|
||||
|
||||
h1 code, h2 code, h3 code, h4 code, h5 code, h6 code {
|
||||
font-size: inherit !important;
|
||||
}
|
||||
|
||||
.prose pre {
|
||||
background: hsl(var(--background-dark));
|
||||
border: 1px solid hsl(var(--background-light));
|
||||
padding: 10px 15px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.prose pre code {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] {
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
div[data-radix-scroll-area-viewport] > div[data-radix-scroll-area-content] pre{
|
||||
max-height: none;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { BaseLayoutProps } from 'fumadocs-ui/layouts/shared';
|
||||
|
||||
/**
|
||||
* Shared layout configurations
|
||||
*
|
||||
* you can configure layouts individually from:
|
||||
* Home Layout: app/(home)/layout.tsx
|
||||
* Docs Layout: app/docs/layout.tsx
|
||||
*/
|
||||
|
||||
export const siteName = 'OpenPanel';
|
||||
export const baseUrl = 'https://openpanel.dev';
|
||||
export const url = (path: string) => `${baseUrl}${path}`;
|
||||
export const baseOptions: BaseLayoutProps = {
|
||||
nav: {
|
||||
title: siteName,
|
||||
},
|
||||
links: [
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Home',
|
||||
url: '/',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Pricing',
|
||||
url: '/pricing',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Supporter',
|
||||
url: '/supporter',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Documentation',
|
||||
url: '/docs',
|
||||
active: 'nested-url',
|
||||
},
|
||||
{
|
||||
type: 'main',
|
||||
text: 'Articles',
|
||||
url: '/articles',
|
||||
active: 'nested-url',
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export const authors = [
|
||||
{
|
||||
name: 'OpenPanel Team',
|
||||
url: 'https://openpanel.com',
|
||||
},
|
||||
{
|
||||
name: 'Carl-Gerhard Lindesvärd',
|
||||
url: 'https://openpanel.com',
|
||||
image: '/twitter-carl.jpg',
|
||||
},
|
||||
];
|
||||
|
||||
export const getAuthor = (author?: string) => {
|
||||
return authors.find((a) => a.name === author)!;
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
import { RootProvider } from 'fumadocs-ui/provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import './global.css';
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
import { OpenPanelComponent } from '@openpanel/nextjs';
|
||||
import { cn } from 'fumadocs-ui/components/api';
|
||||
import { GeistMono } from 'geist/font/mono';
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import Script from 'next/script';
|
||||
import { url, baseUrl, siteName } from './layout.config';
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
userScalable: true,
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#fafafa' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#171717' },
|
||||
],
|
||||
};
|
||||
|
||||
const description = `${siteName} is a simple, affordable open-source alternative to Mixpanel for web and product analytics. Get powerful insights without the complexity.`;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: siteName,
|
||||
template: `%s | ${siteName}`,
|
||||
},
|
||||
description,
|
||||
alternates: {
|
||||
canonical: baseUrl,
|
||||
},
|
||||
icons: {
|
||||
apple: '/apple-touch-icon.png',
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
manifest: '/site.webmanifest',
|
||||
openGraph: {
|
||||
title: siteName,
|
||||
description,
|
||||
siteName: siteName,
|
||||
url: baseUrl,
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: url('/ogimage.jpg'),
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: siteName,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={cn(GeistSans.variable, GeistMono.variable)}>
|
||||
<RootProvider>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
</RootProvider>
|
||||
<OpenPanelComponent
|
||||
apiUrl="/api/op"
|
||||
cdnUrl="/api/op/op1.js"
|
||||
clientId="301c6dc1-424c-4bc3-9886-a8beab09b615"
|
||||
trackAttributes
|
||||
trackScreenViews
|
||||
trackOutgoingLinks
|
||||
/>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
import { metadata } from './layout';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
name: metadata.title as string,
|
||||
short_name: 'Openpanel.dev',
|
||||
description: metadata.description!,
|
||||
start_url: '/',
|
||||
display: 'standalone',
|
||||
background_color: '#fff',
|
||||
theme_color: '#fff',
|
||||
icons: [
|
||||
{
|
||||
src: '/favicon.ico',
|
||||
sizes: 'any',
|
||||
type: 'image/x-icon',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { baseOptions } from '@/app/layout.config';
|
||||
import { Footer } from '@/components/footer';
|
||||
import { HeroContainer } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { HomeLayout } from 'fumadocs-ui/layouts/home';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export default function NotFound({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div>
|
||||
<Navbar />
|
||||
<HeroContainer className="h-screen center-center">
|
||||
<div className="relative z-10 col gap-2">
|
||||
<div className="text-[150px] font-mono font-bold opacity-5 -mb-4">
|
||||
404
|
||||
</div>
|
||||
<h1 className="text-6xl font-bold">Not Found</h1>
|
||||
<p className="text-xl text-muted-foreground">
|
||||
Awkward, we couldn't find what you were looking for.
|
||||
</p>
|
||||
</div>
|
||||
</HeroContainer>
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Footer } from '@/components/footer';
|
||||
import { Hero } from '@/components/hero';
|
||||
import Navbar from '@/components/navbar';
|
||||
import { Faq } from '@/components/sections/faq';
|
||||
import { Features } from '@/components/sections/features';
|
||||
import { Pricing } from '@/components/sections/pricing';
|
||||
import { Sdks } from '@/components/sections/sdks';
|
||||
import { Stats, StatsPure } from '@/components/sections/stats';
|
||||
import { Testimonials } from '@/components/sections/testimonials';
|
||||
import { WhyOpenPanel } from '@/components/why-openpanel';
|
||||
import type { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'OpenPanel | An open-source alternative to Mixpanel',
|
||||
};
|
||||
|
||||
// export const experimental_ppr = true;
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
<Navbar />
|
||||
<main>
|
||||
<Hero />
|
||||
<WhyOpenPanel />
|
||||
<Features />
|
||||
<Testimonials />
|
||||
<Faq />
|
||||
<Pricing />
|
||||
<Sdks />
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { articleSource, pageSource, source } from '@/lib/source';
|
||||
import type { MetadataRoute } from 'next';
|
||||
import { url } from './layout.config';
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const articles = await articleSource.getPages();
|
||||
const docs = await source.getPages();
|
||||
const pages = await pageSource.getPages();
|
||||
return [
|
||||
{
|
||||
url: url('/'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'yearly',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: url('/docs'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: url('/articles'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'weekly',
|
||||
priority: 0.5,
|
||||
},
|
||||
{
|
||||
url: url('/supporter'),
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'monthly',
|
||||
priority: 0.7,
|
||||
},
|
||||
...articles.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.lastModified,
|
||||
changeFrequency: 'yearly' as const,
|
||||
priority: 0.5,
|
||||
})),
|
||||
...docs.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.lastModified,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
...pages.map((item) => ({
|
||||
url: url(item.url),
|
||||
lastModified: item.data.lastModified,
|
||||
changeFrequency: 'monthly' as const,
|
||||
priority: 0.3,
|
||||
})),
|
||||
];
|
||||
}
|
||||