diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 00000000..cf899723 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,122 @@ +name: Build and Publish Docker Image + +on: + workflow_dispatch: + inputs: + version: + description: "Version tag (e.g., 1.0.2)" + required: true + type: string + +env: + IMAGE: sweetr/sweetr + +jobs: + validate: + runs-on: ubuntu-latest + permissions: + contents: read + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: test + POSTGRES_PASSWORD: test + POSTGRES_DB: sweetr_test + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + file: ./apps/api/Dockerfile + push: false + load: true + tags: ${{ env.IMAGE }}:test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Start container + run: | + docker run -d --name sweetr-api --network host \ + -e PORT=8000 \ + -e FRONTEND_URL=http://localhost:3000 \ + -e USE_SELF_SIGNED_SSL=false \ + -e JWT_SECRET=test-secret \ + -e DATABASE_URL=postgres://test:test@localhost:5432/sweetr_test \ + -e REDIS_CONNECTION_STRING=redis://localhost:6379 \ + -e GITHUB_CLIENT_SECRET=test \ + -e GITHUB_CLIENT_ID=test \ + -e GITHUB_APP_HANDLE=test \ + -e GITHUB_APP_ID=test \ + -e GITHUB_APP_PRIVATE_KEY=test \ + -e GITHUB_WEBHOOK_SECRET=test \ + ${{ env.IMAGE }}:test + + - name: Wait for API to be ready + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:8000/health > /dev/null 2>&1; then + echo "API is healthy" + exit 0 + fi + echo "Waiting for API... (attempt $i/30)" + sleep 2 + done + echo "API failed to start. Container logs:" + docker logs sweetr-api + exit 1 + + publish: + needs: validate + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: ./apps/api/Dockerfile + push: true + tags: | + ${{ env.IMAGE }}:${{ inputs.version }} + ${{ env.IMAGE }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/apps/api/src/app/billing/stripe.router.ts b/apps/api/src/app/billing/stripe.router.ts index 7e6afb7f..44866b13 100644 --- a/apps/api/src/app/billing/stripe.router.ts +++ b/apps/api/src/app/billing/stripe.router.ts @@ -10,12 +10,21 @@ import { captureException } from "../../lib/sentry"; import { InputValidationException } from "../errors/exceptions/input-validation.exception"; import { z } from "zod"; import { decodeId } from "../../lib/hash-id"; +import { IntegrationException } from "../errors/exceptions/integration.exception"; export const stripeRouter = Router(); stripeRouter.post( "/stripe/webhook", catchErrors(async (req, res) => { + if (!env.STRIPE_API_KEY) { + throw new IntegrationException("STRIPE_API_KEY is not set"); + } + + if (!env.STRIPE_WEBHOOK_SECRET) { + throw new IntegrationException("STRIPE_WEBHOOK_SECRET is not set"); + } + const signature = req.get("Stripe-Signature"); try { @@ -40,6 +49,10 @@ stripeRouter.post( "/stripe/checkout", urlencoded({ extended: true }), catchErrors(async (req, res) => { + if (!env.STRIPE_API_KEY) { + throw new IntegrationException("STRIPE_API_KEY is not set"); + } + const schema = z.object({ key: z.string(), quantity: z.string(), diff --git a/apps/api/src/app/health/health.router.ts b/apps/api/src/app/health/health.router.ts new file mode 100644 index 00000000..7eb17c29 --- /dev/null +++ b/apps/api/src/app/health/health.router.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +export const healthRouter = Router(); + +healthRouter.get("/health", (_req, res) => { + res.status(200).json({ status: "ok" }); +}); diff --git a/apps/api/src/app/integrations/slack/services/slack-integration.service.ts b/apps/api/src/app/integrations/slack/services/slack-integration.service.ts index b867d573..c207656a 100644 --- a/apps/api/src/app/integrations/slack/services/slack-integration.service.ts +++ b/apps/api/src/app/integrations/slack/services/slack-integration.service.ts @@ -116,6 +116,10 @@ export const getIntegration = async (workspaceId: number) => { }; export const getInstallUrl = (): string => { + if (!config.slack.clientId) { + throw new IntegrationException("SLACK_CLIENT_ID is not set"); + } + const url = new URL("https://slack.com/oauth/v2/authorize"); url.searchParams.append("client_id", config.slack.clientId); diff --git a/apps/api/src/bull-mq/bull-board.router.ts b/apps/api/src/bull-mq/bull-board.router.ts index 90b6b7e0..c04ade82 100644 --- a/apps/api/src/bull-mq/bull-board.router.ts +++ b/apps/api/src/bull-mq/bull-board.router.ts @@ -9,7 +9,7 @@ import { rateLimit } from "express-rate-limit"; export const bullBoardRouter = Router(); -if (env.BULLBOARD_PATH) { +if (env.BULLBOARD_PATH && env.BULLBOARD_USERNAME && env.BULLBOARD_PASSWORD) { const serverAdapter = new ExpressAdapter(); serverAdapter.setBasePath(env.BULLBOARD_PATH); @@ -22,7 +22,7 @@ if (env.BULLBOARD_PATH) { .use( env.BULLBOARD_PATH, rateLimit({ - windowMs: 60, // 15 minutes + windowMs: 15 * 60 * 1000, // 15 minutes max: 200, message: "Too many requests, please try again later.", }) diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index a5a5c476..a181c310 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -35,53 +35,47 @@ export const env = envsafe({ }), GITHUB_WEBHOOK_SECRET: str({ desc: "The secret string used to sign GitHub webhooks", - allowEmpty: true, - devDefault: "", }), GITHUB_APP_ID: str({ desc: "The application id" }), GITHUB_APP_PRIVATE_KEY: str({ desc: "The application private key" }), STRIPE_API_KEY: str({ desc: "The secret API Key for Stripe access", - allowEmpty: true, - devDefault: "", + default: "", }), STRIPE_WEBHOOK_SECRET: str({ desc: "The secret string used to sign Stripe webhooks", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_CLIENT_ID: str({ desc: "The public Slack Client ID", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_CLIENT_SECRET: str({ desc: "The secret API Key for Slack access", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_WEBHOOK_SECRET: str({ desc: "The secret string used to sign Slack webhooks", - allowEmpty: true, - devDefault: "", + default: "", }), SLACK_INSTALL_NOTIFICATION_WEBHOOK_URL: str({ desc: "The URL to the Slack install notification webhook", - allowEmpty: true, - devDefault: "", + default: "", }), JWT_SECRET: str({ desc: "The secret string used to sign JWT tokens" }), SENTRY_DSN: str({ desc: "The DSN to connect to Sentry project", - allowEmpty: true, + default: "", }), LOG_DRAIN: str({ - desc: "The secret string used to sign GitHub webhooks", + desc: "The stream to log to", choices: ["logtail", "console"], - default: "logtail", - devDefault: "console", + default: "console", + }), + LOGTAIL_TOKEN: str({ + desc: "The source token to forward logs to LogTail", + default: "", }), - LOGTAIL_TOKEN: str({ desc: "The source token to forward logs to LogTail" }), USE_SELF_SIGNED_SSL: bool({ desc: "Whether the server should use self-signed certificates generated by devcert (see npm run ssl:generate)", default: false, @@ -95,13 +89,19 @@ export const env = envsafe({ devDefault: "/bullboard", default: "", }), - BULLBOARD_USERNAME: str({ desc: "The username to login to BullBoard." }), - BULLBOARD_PASSWORD: str({ desc: "The password to login to BullBoard." }), + BULLBOARD_USERNAME: str({ + desc: "The username to login to BullBoard.", + default: "", + }), + BULLBOARD_PASSWORD: str({ + desc: "The password to login to BullBoard.", + default: "", + }), EMAIL_ENABLED: bool({ desc: "Whether transactional emails are enabled", default: false, }), - RESEND_API_KEY: str({ desc: "The API Key for Resend.", allowEmpty: true }), + RESEND_API_KEY: str({ desc: "The API Key for Resend.", default: "" }), APP_MODE: str({ desc: "Whether the application is being self-hosted", choices: ["self-hosted", "saas"], diff --git a/apps/api/src/express.ts b/apps/api/src/express.ts index 04d36f7b..dde9a6ea 100644 --- a/apps/api/src/express.ts +++ b/apps/api/src/express.ts @@ -9,6 +9,8 @@ import { slackRouter } from "./app/integrations/slack/slack.router"; import cors from "cors"; import { env } from "./env"; import { deploymentsRouter } from "./app/deployment/deployments.router"; +import { healthRouter } from "./app/health/health.router"; +import { isAppSelfHosted } from "./lib/self-host"; export const expressApp = express(); @@ -22,15 +24,25 @@ expressApp }, }) ) - .use(cors({ origin: env.FRONTEND_URL, credentials: true, methods: ["*"] })) + .use( + cors({ + origin: env.FRONTEND_URL, + credentials: true, + methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"], + }) + ) .set("trust proxy", 1); // Route handlers +expressApp.use(healthRouter); expressApp.use(githubRouter); -expressApp.use(stripeRouter); + +if (!isAppSelfHosted()) { + expressApp.use(stripeRouter); +} + expressApp.use(slackRouter); expressApp.use(bullBoardRouter); - // Customer-facing API expressApp.use(deploymentsRouter); expressApp.use(yoga); // Leave Yoga last diff --git a/apps/api/src/lib/logger.ts b/apps/api/src/lib/logger.ts index 61926d06..cb5a670e 100644 --- a/apps/api/src/lib/logger.ts +++ b/apps/api/src/lib/logger.ts @@ -3,13 +3,17 @@ import { env } from "../env"; import { addBreadcrumb } from "@sentry/node"; import { pick } from "radash"; -const logTailStream = pino.transport({ - target: "@logtail/pino", - options: { sourceToken: env.LOGTAIL_TOKEN }, -}); +const logTailStream = + env.LOG_DRAIN === "logtail" && env.LOGTAIL_TOKEN + ? pino.transport({ + target: "@logtail/pino", + options: { sourceToken: env.LOGTAIL_TOKEN }, + }) + : undefined; + const consoleStream = pino.destination(); -const stream = env.LOG_DRAIN === "logtail" ? logTailStream : consoleStream; +const stream = logTailStream || consoleStream; const pinoLogger = pino( { diff --git a/apps/api/src/lib/sentry.ts b/apps/api/src/lib/sentry.ts index 0655d5ea..310e5a95 100644 --- a/apps/api/src/lib/sentry.ts +++ b/apps/api/src/lib/sentry.ts @@ -4,13 +4,17 @@ import { nodeProfilingIntegration } from "@sentry/profiling-node"; import { BaseException } from "../app/errors/exceptions/base.exception"; import { env, isDev } from "../env"; import { logger } from "./logger"; -import { isAppSelfHosted } from "./self-host"; export const initSentry = () => { + if (!env.SENTRY_DSN) { + logger.info("Skipping Sentry initialization. No SENTRY_DSN provided."); + return; + } + Sentry.init({ dsn: env.SENTRY_DSN, environment: env.APP_ENV, - enabled: !isAppSelfHosted() && !isDev && !!env.SENTRY_DSN, + enabled: !isDev, beforeBreadcrumb(breadcrumb) { if (breadcrumb.category === "console") { return null; diff --git a/apps/api/src/lib/stripe.ts b/apps/api/src/lib/stripe.ts index c2f6c450..b3503c66 100644 --- a/apps/api/src/lib/stripe.ts +++ b/apps/api/src/lib/stripe.ts @@ -1,9 +1,14 @@ import Stripe from "stripe"; import { env } from "../env"; +import { IntegrationException } from "../app/errors/exceptions/integration.exception"; let stripeClient: Stripe | null; export const getStripeClient = (): Stripe => { + if (!env.STRIPE_API_KEY) { + throw new IntegrationException("STRIPE_API_KEY is not set"); + } + if (stripeClient) return stripeClient; stripeClient = new Stripe(env.STRIPE_API_KEY); diff --git a/apps/docs/get-started/self-host.mdx b/apps/docs/get-started/self-host.mdx index 47a10482..84b0f322 100644 --- a/apps/docs/get-started/self-host.mdx +++ b/apps/docs/get-started/self-host.mdx @@ -166,5 +166,6 @@ Update .env to enable: SENTRY_DSN= # LogTail +LOG_DRAIN=logtail LOGTAIL_TOKEN= ```