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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .env.selfhost.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
BETTER_AUTH_SECRET=""
POSTGRES_PASSWORD=""
CLICKHOUSE_PASSWORD=""
REDIS_PASSWORD=""
DATABASE_URL="postgres://databuddy:<POSTGRES_PASSWORD>@postgres:5432/databuddy"
REDIS_URL="redis://:<REDIS_PASSWORD>@redis:6379"
CLICKHOUSE_URL="http://default:<CLICKHOUSE_PASSWORD>@clickhouse:8123"
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
RESEND_API_KEY=""

POSTGRES_DB=databuddy
POSTGRES_USER=databuddy
POSTGRES_PORT=5432

CLICKHOUSE_DB=databuddy_analytics
CLICKHOUSE_USER=default
CLICKHOUSE_PORT=8123

REDIS_PORT=6379

BETTER_AUTH_URL=http://localhost:3100
DASHBOARD_URL=http://localhost:3100
NEXT_PUBLIC_API_URL=http://localhost:3001
NEXT_PUBLIC_APP_URL=http://localhost:3100
NEXT_PUBLIC_BASKET_URL=http://localhost:4000
NEXT_PUBLIC_SCRIPT_URL=http://localhost:3100/databuddy.js
NEXT_PUBLIC_BETTER_AUTH_URL=http://localhost:3100
AUTH_TRUSTED_ORIGINS=http://localhost:3100,http://localhost:3000,http://localhost:3001
CLIENT_APP_ALLOWED_ORIGINS=
AUTH_COOKIE_DOMAIN=
APP_URL=http://localhost:3100

DASHBOARD_PORT=3100
API_PORT=3001
BASKET_PORT=4000
LINKS_PORT=2500

AI_API_KEY=placeholder
LINKS_ROOT_REDIRECT_URL=http://localhost:3100
IMAGE_TAG=edge
NODE_ENV=production

NEXT_PUBLIC_DATABUDDY_CLIENT_ID=placeholder

PUBLIC_API_CORS_MODE=restricted
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ admin

# Local env files
.env
.env.prod
.mcp.json
.env.local
.env.development.local
Expand Down
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ Services started:

All ports are configurable via env vars (`API_PORT`, `BASKET_PORT`, etc.). See the compose file comments for the full env var reference.

### Troubleshooting

If the `init` job fails and Postgres logs show `password authentication failed for user "databuddy"`, the Postgres volume was likely initialized with an older password.

Changing `POSTGRES_PASSWORD` or `DATABASE_URL` in your environment does not update the password stored inside an existing Postgres data volume.

To keep the existing data, update the password inside the running Postgres container to match your current environment and redeploy:

```bash
docker exec -it databuddy-postgres psql -U databuddy -d databuddy
```

Then inside `psql`:

```sql
ALTER USER databuddy WITH PASSWORD 'your-current-postgres-password';
```

Make sure the password in `DATABASE_URL` matches the same value.

If you do not need to preserve the database, remove the existing volume and start fresh so Postgres initializes with the current environment values:

```bash
docker compose -f docker-compose.selfhost.yml down -v
docker compose -f docker-compose.selfhost.yml up -d
```

## 🤝 Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Expand Down Expand Up @@ -137,4 +164,4 @@ See [SECURITY.md](SECURITY.md) for reporting vulnerabilities.

This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0). See the [LICENSE](LICENSE) file for details.

Copyright (c) 2025 Databuddy Analytics, Inc.
Copyright (c) 2025 Databuddy Analytics, Inc.
8 changes: 2 additions & 6 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { initLogger, log, parseError } from "evlog";
import { evlog, useLogger } from "evlog/elysia";
import { applyAuthWideEvent } from "@/lib/auth-wide-event";
import { AUTUMN_API_PREFIX, withAutumnApiPath } from "@/lib/autumn-mount";
import { getAllowedCorsOrigins } from "@/lib/cors-origins";
import {
apiLoggerDrain,
enrichApiWideEvent,
Expand Down Expand Up @@ -260,12 +261,7 @@ const app = new Elysia({ precompile: true })
.use(
cors({
credentials: true,
origin: [
/(?:^|\.)databuddy\.cc$/,
...(process.env.NODE_ENV === "development"
? ["http://localhost:3000"]
: []),
],
origin: getAllowedCorsOrigins(),
})
)
.use(publicApi)
Expand Down
45 changes: 45 additions & 0 deletions apps/api/src/lib/cors-origins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import {
getClientAppAllowedOrigins,
normalizeOrigin,
} from "@databuddy/shared/utils/origins";

const DATABUDDY_DOMAIN_REGEX = /(?:^|\.)databuddy\.cc$/;

export function getAllowedCorsOrigins(): Array<string | RegExp> {
const defaults = [
process.env.BETTER_AUTH_URL,
process.env.NEXT_PUBLIC_APP_URL,
process.env.NEXT_PUBLIC_API_URL,
process.env.DASHBOARD_URL,
Comment on lines +10 to +13
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 NEXT_PUBLIC_API_URL included as a CORS-allowed origin for the API itself

CORS allowed origins represent browsers/apps that may issue cross-origin requests to the API, not the API itself. Including NEXT_PUBLIC_API_URL (e.g. http://localhost:3001) means the API treats its own URL as a permitted front-end origin, which is a no-op in practice but adds unnecessary noise. Consider removing it from the defaults array.

process.env.APP_URL,
process.env.NODE_ENV === "development"
? "http://localhost:3000"
: undefined,
process.env.NODE_ENV === "development"
? "http://localhost:3100"
: undefined,
process.env.NODE_ENV === "development"
? "http://localhost:3001"
: undefined,
]
.filter((value): value is string => Boolean(value))
.map((value) => normalizeOrigin(value))
.filter((value): value is string => Boolean(value));

const extraOrigins = getClientAppAllowedOrigins();
const authTrustedOrigins = (process.env.AUTH_TRUSTED_ORIGINS ?? "")
.split(",")
.map((origin) => normalizeOrigin(origin))
.filter((origin): origin is string => Boolean(origin));

return [
DATABUDDY_DOMAIN_REGEX,
...new Set([...defaults, ...authTrustedOrigins, ...extraOrigins]),
];
}

export function getPublicCorsOrigins(): true | Array<string | RegExp> {
return process.env.PUBLIC_API_CORS_MODE === "restricted"
? getAllowedCorsOrigins()
: true;
}
3 changes: 2 additions & 1 deletion apps/api/src/routes/public/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import cors from "@elysiajs/cors";
import { serverTiming } from "@elysiajs/server-timing";
import { Elysia } from "elysia";
import { parseError } from "evlog";
import { getPublicCorsOrigins } from "@/lib/cors-origins";
import { captureError, mergeWideEvent } from "@/lib/tracing";
import { agentTelemetryRoute } from "./agent-telemetry";
import { flagsRoute } from "./flags";
Expand All @@ -22,7 +23,7 @@ export const publicApi = new Elysia({ prefix: "/public" })
.use(
cors({
credentials: false,
origin: true,
origin: getPublicCorsOrigins(),
})
)
.options("*", () => new Response(null, { status: 204 }))
Expand Down
20 changes: 19 additions & 1 deletion apps/api/src/routes/webhooks/autumn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,18 @@ import { Resend } from "resend";
import { Webhook } from "svix";
import { mergeWideEvent } from "../../lib/tracing";

const resend = new Resend(process.env.RESEND_API_KEY);
const SVIX_SECRET = process.env.AUTUMN_WEBHOOK_SECRET;
const SLACK_URL = process.env.SLACK_WEBHOOK_URL ?? "";
const COOLDOWN_DAYS = 7;

function getResendClient(): Resend | null {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) {
return null;
}
return new Resend(apiKey);
}

interface LimitReachedData {
customer_id: string;
entity_id?: string;
Expand Down Expand Up @@ -145,6 +152,17 @@ async function sendAlertEmailAction(opts: {
return { success: false, message: "No email found" };
}

const resend = getResendClient();
if (!resend) {
useLogger().warn(
"Skipping alert email - RESEND_API_KEY is not configured",
{
autumn: { customerId, cooldownKey },
}
);
return { success: true, message: "Email provider not configured" };
}

const result = await resend.emails.send({
from: "Databuddy <alerts@databuddy.cc>",
to: userData.email,
Expand Down
30 changes: 30 additions & 0 deletions apps/basket/src/lib/request-validation-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,9 +83,16 @@ vi.mock("@lib/tracing", () => ({
record: (_name: string, fn: Function) => Promise.resolve().then(() => fn()),
captureError: vi.fn(),
}));
mock.module("@databuddy/shared/utils/origins", () => ({
getClientAppAllowedOrigins: mock(() => []),
isOriginInList: mock(() => false),
}));

// Import after mocks
const { validateRequest, checkForBot } = await import("./request-validation");
const { getClientAppAllowedOrigins, isOriginInList } = await import(
"@databuddy/shared/utils/origins"
);

function makeReq(
url = "https://example.com?client_id=ws_1",
Expand All @@ -112,6 +119,8 @@ describe("validateRequest", () => {
mockIsValidIpFromSettings.mockReset();
mockLogBlockedTraffic.mockReset();
mockLoggerSet.mockReset();
(getClientAppAllowedOrigins as any).mockReset();
(isOriginInList as any).mockReset();

// Defaults: everything passes
mockGetWebsiteByIdV2.mockResolvedValue({
Expand All @@ -127,6 +136,8 @@ describe("validateRequest", () => {
mockIsValidOrigin.mockReturnValue(true);
mockIsValidOriginFromSettings.mockReturnValue(true);
mockIsValidIpFromSettings.mockReturnValue(true);
(getClientAppAllowedOrigins as any).mockReturnValue([]);
(isOriginInList as any).mockReturnValue(false);
});

test("happy path → returns ValidatedRequest", async () => {
Expand Down Expand Up @@ -270,6 +281,25 @@ describe("validateRequest", () => {
}
});

test("globally allowed client app origin bypasses website origin checks", async () => {
(getClientAppAllowedOrigins as any).mockReturnValue([
"https://starter1.emeruslabs.com",
]);
(isOriginInList as any).mockReturnValue(true);

const result = await validateRequest(
{},
{ client_id: "ws_1" },
makeReq("https://example.com", {
origin: "https://starter1.emeruslabs.com",
})
);

expect(result.clientId).toBe("ws_1");
expect(mockIsValidOrigin).not.toHaveBeenCalled();
expect(mockIsValidOriginFromSettings).not.toHaveBeenCalled();
});

test("IP not authorized → throws 403", async () => {
mockGetWebsiteByIdV2.mockResolvedValue({
id: "ws_1",
Expand Down
9 changes: 8 additions & 1 deletion apps/basket/src/lib/request-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import {
VALIDATION_LIMITS,
validatePayloadSize,
} from "@utils/validation";
import {
getClientAppAllowedOrigins,
isOriginInList,
} from "@databuddy/shared/utils/origins";
import { useLogger } from "evlog/elysia";

export interface ValidatedRequest {
Expand Down Expand Up @@ -141,12 +145,15 @@ export function validateRequest(

const origin = request.headers.get("origin");
const ip = extractIpFromRequest(request);
const clientAppAllowedOrigins = getClientAppAllowedOrigins();

const securitySettings = getWebsiteSecuritySettings(website.settings);
const allowedOrigins = securitySettings?.allowedOrigins;
const allowedIps = securitySettings?.allowedIps;

if (origin && allowedOrigins && allowedOrigins.length > 0) {
if (origin && isOriginInList(origin, clientAppAllowedOrigins)) {
log.set({ validation: { clientAppOriginAllowed: true, origin } });
} else if (origin && allowedOrigins && allowedOrigins.length > 0) {
if (
!(await record("isValidOriginFromSettings", () =>
isValidOriginFromSettings(origin, allowedOrigins)
Expand Down
3 changes: 2 additions & 1 deletion apps/dashboard/app/(dby)/dby/l/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { db } from "@databuddy/db";
import {
type CachedLink,
getCachedLink,
Expand All @@ -15,6 +14,8 @@ async function getLinkBySlug(slug: string): Promise<CachedLink | null> {
return cached;
}

const { db } = await import("@databuddy/db");

const dbLink = await db.query.links.findFirst({
where: (links, { and, eq, isNull }) =>
and(eq(links.slug, slug), isNull(links.deletedAt)),
Expand Down
10 changes: 5 additions & 5 deletions apps/dashboard/app/(main)/billing/utils/stripe-metadata.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { getTrackingIds } from "@databuddy/sdk";

const DATABUDDY_CLIENT_ID =
process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID ?? "OXmNQsViBT-FOS_wZCTHc";
const DATABUDDY_CLIENT_ID = process.env.NEXT_PUBLIC_DATABUDDY_CLIENT_ID;

export function getStripeMetadata(): Record<string, string> {
const { anonId, sessionId } = getTrackingIds();
const metadata: Record<string, string> = {
databuddy_client_id: DATABUDDY_CLIENT_ID,
};
const metadata: Record<string, string> = {};
if (DATABUDDY_CLIENT_ID) {
metadata.databuddy_client_id = DATABUDDY_CLIENT_ID;
}
if (sessionId) {
metadata.databuddy_session_id = sessionId;
}
Expand Down
6 changes: 4 additions & 2 deletions apps/dashboard/app/(main)/home/hooks/use-pulse-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useQuery } from "@tanstack/react-query";
import { useMemo } from "react";
import { useOrganizationsContext } from "@/components/providers/organizations-provider";
import { useFeatureAccess } from "@/hooks/use-feature-access";
import { orpc } from "@/lib/orpc";

Expand All @@ -23,12 +24,13 @@ export interface PulseStatus {
export function usePulseStatus() {
const { hasAccess, isLoading: isAccessLoading } =
useFeatureAccess("monitors");
const { organizationId } = useOrganizationsContext();

const query = useQuery({
...orpc.uptime.listSchedules.queryOptions({
input: {},
input: organizationId ? { organizationId } : {},
}),
enabled: hasAccess,
enabled: hasAccess && !!organizationId,
});

type ScheduleRow = PulseStatus["monitors"][number];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,8 @@ function sortInsights(items: Insight[], mode: SortMode): Insight[] {
}

export function CockpitSignals(): ReactElement {
const { activeOrganization, activeOrganizationId } =
useOrganizationsContext();
const orgId = activeOrganization?.id ?? activeOrganizationId ?? undefined;
const { organizationId } = useOrganizationsContext();
const orgId = organizationId ?? undefined;

const {
insights,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,8 @@ function FocusSitePicker({ websites, value, onChange }: FocusSitePickerProps) {

export function InsightsPageContent() {
const queryClient = useQueryClient();
const { activeOrganization, activeOrganizationId } =
useOrganizationsContext();
const orgId = activeOrganization?.id ?? activeOrganizationId ?? undefined;
const { organizationId } = useOrganizationsContext();
const orgId = organizationId ?? undefined;

const { insights, isLoading, isRefreshing, refetch } = useInsightsFeed();

Expand Down
Loading