diff --git a/.env.prod b/.env.prod deleted file mode 100644 index 5faab8f..0000000 --- a/.env.prod +++ /dev/null @@ -1,118 +0,0 @@ -# Canonical production environment template for: -# - self-hosted full stack via ./start-unified.sh prod -# - frontend deployment on Vercel (copy VITE_* and FRONTEND_URL/BACKEND_URL values) -# - backend deployment on Railway (copy backend/auth/database values) - -ENVIRONMENT=production - -# Public application URLs -FRONTEND_URL=https://containr-web.vercel.app -BACKEND_URL=https://containr-api.up.railway.app -VITE_API_URL=https://containr-api.up.railway.app -VITE_AUTH_URL=https://containr-api.up.railway.app/api/auth -BETTER_AUTH_URL=https://containr-api.up.railway.app -BETTER_AUTH_TRUSTED_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app -CORS_ORIGINS=https://containr-web.vercel.app,https://containr-api.up.railway.app -CORS_CREDENTIALS=true - -# Self-hosted domain settings -DOMAIN=containr.example.com -ACME_EMAIL=ops@example.com -TRAEFIK_API_INSECURE=false -TRAEFIK_AUTH=admin:$$apr1$$replace_me$$replace_me - -# Backend runtime -PORT=8080 -HOST=0.0.0.0 -AUTH_PORT=3001 -BETTER_AUTH_ENABLED=true -BETTER_AUTH_ENTRYPOINT=auth/src/server.js -BETTER_AUTH_NODE_BINARY=node -BETTER_AUTH_STARTUP_TIMEOUT=20s -BETTER_AUTH_PROXY_URL=http://127.0.0.1:3001 -BETTER_AUTH_INTERNAL_URL=http://127.0.0.1:3001/internal/session -MAX_REQUEST_BODY_BYTES=10485760 -READ_TIMEOUT=30s -WRITE_TIMEOUT=30s -IDLE_TIMEOUT=60s -SHUTDOWN_TIMEOUT=30s -AUTO_MIGRATE=true -MIGRATION_LOCK_TIMEOUT=5m -SEED_DATA_ON_START=false -TRUSTED_PROXY_CIDR=172.20.0.0/16 -LOG_LEVEL=info -LOG_FORMAT=json -LOG_OUTPUT=stdout -DEBUG=false - -# Secrets -JWT_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG -BETTER_AUTH_SECRET=CHANGE_ME_AT_LEAST_32_CHARACTERS_LONG -BETTER_AUTH_INTERNAL_TOKEN=CHANGE_ME_INTERNAL_AUTH_TOKEN -CONTAINR_AGENT_AUTH_TOKEN=CHANGE_ME_AGENT_AUTH_TOKEN -# Optional rotating token list -# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret - -# Cookies -COOKIE_SECURE=true -COOKIE_DOMAIN= -COOKIE_PATH=/ -COOKIE_SAME_SITE=lax - -# Self-hosted database defaults. -# Railway: replace DATABASE_URL and REDIS_URL with Railway-provided values. -POSTGRES_DB=containr -POSTGRES_USER=containr_user -POSTGRES_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD -DATABASE_URL=postgres://containr_user:CHANGE_ME_POSTGRES_PASSWORD@postgres:5432/containr?sslmode=disable -MAX_CONNECTIONS=50 -MAX_IDLE_CONNECTIONS=10 -CONN_MAX_LIFETIME=10m -CONN_MAX_IDLE_TIME=5m - -REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD -REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0 - -# Explicit DB settings for the embedded Better Auth runtime. -DB_HOST=postgres -DB_PORT=5432 -DB_NAME=containr -DB_USER=containr_user -DB_PASSWORD=CHANGE_ME_POSTGRES_PASSWORD - -# Optional OAuth providers -GITHUB_CLIENT_ID= -GITHUB_CLIENT_SECRET= -GITLAB_CLIENT_ID=PLACEHOLDER_GITLAB_CLIENT_ID -GITLAB_CLIENT_SECRET=PLACEHOLDER_GITLAB_CLIENT_SECRET -GITLAB_OAUTH_AUTHORIZE_URL=https://gitlab.com/oauth/authorize -GITLAB_OAUTH_TOKEN_URL=https://gitlab.com/oauth/token -GITLAB_OAUTH_USERINFO_URL=https://gitlab.com/api/v4/user -BITBUCKET_CLIENT_ID=PLACEHOLDER_BITBUCKET_CLIENT_ID -BITBUCKET_CLIENT_SECRET=PLACEHOLDER_BITBUCKET_CLIENT_SECRET -BITBUCKET_OAUTH_AUTHORIZE_URL=https://bitbucket.org/site/oauth2/authorize -BITBUCKET_OAUTH_TOKEN_URL=https://bitbucket.org/site/oauth2/access_token -BITBUCKET_OAUTH_USERINFO_URL=https://api.bitbucket.org/2.0/user -BITBUCKET_OAUTH_EMAILS_URL=https://api.bitbucket.org/2.0/user/emails -GITEA_CLIENT_ID=PLACEHOLDER_GITEA_CLIENT_ID -GITEA_CLIENT_SECRET=PLACEHOLDER_GITEA_CLIENT_SECRET -GITEA_OAUTH_AUTHORIZE_URL=https://gitea.example.com/login/oauth/authorize -GITEA_OAUTH_TOKEN_URL=https://gitea.example.com/login/oauth/access_token -GITEA_OAUTH_USERINFO_URL=https://gitea.example.com/api/v1/user - -# Repo sync / SCM integrations -GITHUB_APP_ID= -GITHUB_APP_SLUG= -GITHUB_APP_PRIVATE_KEY= -GITHUB_APP_BASE_URL=https://api.github.com -GITLAB_API_URL=https://gitlab.com/api/v4 -GITLAB_BASE_URL=https://gitlab.com -BITBUCKET_API_URL=https://api.bitbucket.org/2.0 -BITBUCKET_BASE_URL=https://bitbucket.org -GITEA_BASE_URL=https://gitea.example.com - -# Optional analytics / observability -UMAMI_BASE_URL= -UMAMI_API_KEY= -UMAMI_WEBSITE_ID= -CLOUDFLARED_TOKEN= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b793af2..8f0f260 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,72 @@ jobs: - name: Test run: npm test + + smoke: + name: Compose boot smoke + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Boot stack + run: | + export BETTER_AUTH_SECRET=$(openssl rand -hex 32) + export BETTER_AUTH_INTERNAL_TOKEN=$(openssl rand -hex 32) + docker compose -f infra/docker-compose.yml up -d --build postgres redis backend frontend traefik + + - name: Wait for backend health + run: | + for i in $(seq 1 60); do + if curl -sf http://localhost:8082/health; then exit 0; fi + sleep 5 + done + docker compose -f infra/docker-compose.yml logs --tail 50 backend + exit 1 + + - name: Check frontend responds + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:3000/ -o /dev/null; then exit 0; fi + sleep 2 + done + docker compose -f infra/docker-compose.yml logs --tail 50 frontend + exit 1 + + - name: Teardown + if: always() + run: docker compose -f infra/docker-compose.yml down -v + + images: + name: Publish images to GHCR + runs-on: ubuntu-latest + needs: [backend, frontend, smoke] + if: startsWith(github.ref, 'refs/tags/v') + permissions: + packages: write + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push backend + uses: docker/build-push-action@v6 + with: + context: app/backend + push: true + tags: ghcr.io/${{ github.repository }}-backend:${{ github.ref_name }},ghcr.io/${{ github.repository }}-backend:latest + + - name: Build and push frontend + uses: docker/build-push-action@v6 + with: + context: app/frontend + push: true + tags: ghcr.io/${{ github.repository }}-frontend:${{ github.ref_name }},ghcr.io/${{ github.repository }}-frontend:latest diff --git a/.gitignore b/.gitignore index 8bb1a2e..beabec8 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ build/ *.tsbuildinfo # Keep internal build system code -!internal/build/ +!**/internal/build/ # Environment variables .env @@ -21,6 +21,11 @@ build/ .env.development.local .env.test.local .env.production.local +.env.prod +.env.production +.env.*.local +!.env.example +!.env.production.example # Logs logs/ @@ -69,7 +74,7 @@ jspm_packages/ # Gatsby files .cache/ -public +/public/ # Storybook build outputs .out @@ -109,4 +114,10 @@ test-results.xml .opencode .claude .desloppify -bin \ No newline at end of file +bin +# Tool session dirs +.jj/ +.playwright-cli/ + +# Local reference checkouts (not project code) +ref/ diff --git a/.impeccable.md b/.impeccable.md deleted file mode 100644 index a2d4b8a..0000000 --- a/.impeccable.md +++ /dev/null @@ -1,47 +0,0 @@ -# Containr Design System - -## Design Context - -### Users -- **Primary**: DevOps engineers, system administrators, and developers managing container infrastructure -- **Secondary**: Small team leads and solo developers looking for self-hosted container management -- **Context**: Users access the dashboard to deploy, monitor, and manage containers. They need quick access to critical information, real-time metrics, and intuitive workflows. -- **Job to be done**: "I want to deploy and manage containers without the complexity of Kubernetes or expensive cloud platforms." - -### Brand Personality -- **Voice**: Professional yet approachable, technical but not intimidating -- **Three words**: Modern, reliable, empowering -- **Emotional goals**: Users should feel in control, confident, and efficient. The interface should reduce anxiety around infrastructure management. - -### Aesthetic Direction -- **Visual tone**: Dark mode SaaS dashboard with subtle gradients and refined details -- **References**: Railway (primary inspiration) - clean, modern, professional; Vercel - polished developer experience; Linear - refined typography and spacing -- **Anti-references**: Avoid cluttered interfaces, overly bright colors, generic Bootstrap aesthetics -- **Theme**: Dark mode with accent colors for status indicators (emerald for success, amber for warning, rose for errors) - -### Design Principles -1. **Clarity over density** - Every element serves a purpose, generous whitespace reduces cognitive load -2. **Progressive disclosure** - Surface critical information first, details available on interaction -3. **Consistent visual language** - Reusable patterns for cards, buttons, forms, and status indicators -4. **Performance perception** - Loading states and transitions that feel snappy and responsive -5. **Accessibility by default** - WCAG 2.1 AA compliance, readable contrast ratios, keyboard navigation - -### Typography -- **Headlines**: Geist (modern, technical, distinctive) -- **Body**: Inter (highly readable at all sizes) -- **Code/Mono**: JetBrains Mono (developer-friendly) - -### Color System -- **Background**: #0a0a0a (near-black with subtle warmth) -- **Surface**: #141414 (elevated cards and containers) -- **Border**: #1f1f1f (subtle separation) -- **Primary accent**: #6366f1 (indigo - modern tech feel) -- **Success**: #10b981 (emerald) -- **Warning**: #f59e0b (amber) -- **Danger**: #f43f5e (rose) - -### Spacing Scale -- Use 4px base unit -- Component padding: 16px-24px -- Section gaps: 32px-48px -- Page padding: 24px-48px (responsive) diff --git a/AUTOSCALING.md b/AUTOSCALING.md deleted file mode 100644 index c0a7aa7..0000000 --- a/AUTOSCALING.md +++ /dev/null @@ -1,288 +0,0 @@ -# Autoscaling with Cloudflare Tunnel - -## Overview - -This document explains how autoscaling works when using Cloudflare Tunnel with the Containr application. - -## Architecture - -``` -Internet → Cloudflare Edge → Cloudflare Tunnel → Traefik → Backend Services -``` - -## Autoscaling Considerations - -### 1. Cloudflare Tunnel Limitations - -**Cloudflare Tunnel itself does NOT provide autoscaling.** It's a secure tunneling service that: -- Creates a persistent connection between your infrastructure and Cloudflare's edge -- Routes traffic through Cloudflare's global network -- Provides DDoS protection and CDN features - -### 2. Where Autoscaling Happens - -Autoscaling must be implemented at different layers: - -#### A. Container Level (Docker Swarm/Kubernetes) -```yaml -# Example with Docker Swarm -backend: - image: containr-backend - deploy: - replicas: 3 - update_config: - parallelism: 1 - delay: 10s - restart_policy: - condition: on-failure -``` - -#### B. Application Level (Load Balancing) -Traefik automatically load balances between multiple backend instances: -```yaml -# Multiple backend containers -backend-1: - # ... backend config - labels: - - "traefik.http.services.backend.loadbalancer.server.port=8080" - -backend-2: - # ... backend config - labels: - - "traefik.http.services.backend.loadbalancer.server.port=8080" -``` - -#### C. Cloud Level (Cloudflare Load Balancer - Paid Feature) -For true autoscaling, you'd need: -- Multiple deployments in different regions -- Cloudflare Load Balancer ($$$/month) -- Health checks and failover - -## Implementation Options - -### Option 1: Docker Swarm (Recommended for Single Host) - -```bash -# Initialize Docker Swarm -docker swarm init - -# Deploy with autoscaling -docker stack deploy -c docker-compose.yml containr -``` - -### Option 2: Kubernetes - -```yaml -# deployment.yaml -apiVersion: apps/v1 -kind: Deployment -metadata: - name: backend -spec: - replicas: 3 - selector: - matchLabels: - app: backend - template: - metadata: - labels: - app: backend - spec: - containers: - - name: backend - image: containr-backend - ports: - - containerPort: 8080 ---- -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: backend-hpa -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: backend - minReplicas: 2 - maxReplicas: 10 - metrics: - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: 70 -``` - -### Option 3: Manual Scaling with Scripts - -```bash -#!/bin/bash -# scale-backend.sh - -scale_up() { - local current=$(docker ps --filter "name=containr-backend" --format "table {{.Names}}" | wc -l) - local target=$((current + 1)) - - echo "Scaling backend to $target instances..." - - for i in $(seq 1 $target); do - docker run -d \ - --name containr-backend-$i \ - --network containr_containr-network \ - -e DATABASE_URL="..." \ - -e REDIS_URL="..." \ - containr-backend - done -} - -scale_down() { - local current=$(docker ps --filter "name=containr-backend" --format "table {{.Names}}" | wc -l) - local target=$((current - 1)) - - if [ $target -lt 1 ]; then - echo "Cannot scale below 1 instance" - exit 1 - fi - - echo "Scaling backend to $target instances..." - docker stop containr-backend-$target - docker rm containr-backend-$target -} - -case "$1" in - up) scale_up ;; - down) scale_down ;; - *) echo "Usage: $0 [up|down]" ;; -esac -``` - -## Monitoring and Metrics - -### Health Checks -All services include health checks: -```yaml -healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8080/health"] - interval: 30s - timeout: 10s - retries: 3 -``` - -### Metrics Collection -Traefik provides Prometheus metrics: -```yaml -# In docker-compose.yml -command: - - "--metrics.prometheus=true" - - "--metrics.prometheus.addentrypointslabels=true" - - "--metrics.prometheus.addserviceslabels=true" -``` - -### Scaling Triggers -Monitor these metrics for scaling decisions: -- CPU usage (> 70%) -- Memory usage (> 80%) -- Response time (> 500ms) -- Error rate (> 5%) -- Queue depth (if using message queues) - -## Production Recommendations - -### 1. Use Docker Swarm or Kubernetes -- Better orchestration -- Built-in load balancing -- Health management -- Rolling updates - -### 2. Implement Horizontal Pod Autoscaler (HPA) -- Automatic scaling based on metrics -- Min/max replica limits -- Configurable thresholds - -### 3. Use Cloudflare Load Balancer (if budget allows) -- Geographic distribution -- Advanced health checks -- Traffic steering -- DDoS protection - -### 4. Monitoring and Alerting -- Prometheus + Grafana -- Alertmanager -- Log aggregation (ELK stack) - -## Example: Complete Autoscaling Setup - -```yaml -# docker-compose.autoscale.yml -version: '3.8' - -services: - traefik: - image: traefik:v3.2 - command: - - "--api.dashboard=true" - - "--providers.docker=true" - - "--providers.docker.swarmMode=true" - - "--metrics.prometheus=true" - deploy: - replicas: 1 - placement: - constraints: - - node.role == manager - - backend: - image: containr-backend - deploy: - replicas: 3 - update_config: - parallelism: 1 - delay: 10s - restart_policy: - condition: on-failure - labels: - - "traefik.http.services.backend.loadbalancer.server.port=8080" - - "traefik.http.routers.backend.rule=Host(`api.${DOMAIN}`)" - - "traefik.enable=true" - - prometheus: - image: prom/prometheus - deploy: - replicas: 1 - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml - - grafana: - image: grafana/grafana - deploy: - replicas: 1 - environment: - - GF_SECURITY_ADMIN_PASSWORD=admin -``` - -## Summary - -1. **Cloudflare Tunnel ≠ Autoscaling** - It's for secure connectivity -2. **Autoscaling happens at container/orchestration level** -3. **Traefik provides load balancing between instances** -4. **Use Docker Swarm or Kubernetes for production autoscaling** -5. **Monitor metrics and implement HPA for automatic scaling** -6. **Consider Cloudflare Load Balancer for multi-region setups** - -## Quick Start Commands - -```bash -# Start with autoscaling (Docker Swarm) -docker swarm init -docker stack deploy -c docker-compose.autoscale.yml containr - -# Scale manually -docker service scale containr_backend=5 - -# Check status -docker service ls -docker service ps containr_backend - -# View logs -docker service logs containr_backend -``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..cd9d1ae --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,69 @@ +# Contributing to Containr + +Thanks for your interest in contributing. Containr is a self-hosted deployment +platform — contributions of all sizes are welcome. + +## Project Layout + +``` +app/backend/ Go control-plane API (Gin, PostgreSQL, Redis, Docker SDK) +app/frontend/ React + Vite + TypeScript dashboard +infra/ Self-hosted compose stack (Traefik, Cloudflare Tunnel) +docs/ Guides, API spec, and design documents +templates/ Service template catalog notes +``` + +## Development Setup + +1. Copy `.env.example` to `.env` and fill in values (never commit `.env`). +2. Start Postgres and Redis: `docker compose up -d postgres redis` +3. Backend: `cd app/backend && go run ./cmd/server` +4. Frontend: `cd app/frontend && npm install && npm run dev` + +## Checks Before Submitting + +- Backend: `cd app/backend && go build ./... && go test ./... && go vet ./...` +- Frontend: `cd app/frontend && npm run build && npm test` +- Compose: `docker compose config -q` + +## Boot Smoke Test + +Verify the API actually starts and serves against real dependencies: + +```bash +# Requires Postgres + Redis reachable; migrations run automatically on boot. +cd app/backend +DATABASE_URL="postgres://user:pass@localhost:5432/containr?sslmode=disable" \ +REDIS_URL="redis://localhost:6379" \ +go run ./cmd/server & + +curl -fsS http://localhost:8080/live # -> 200 +curl -fsS http://localhost:8080/health # -> {"database":"ok","redis":"ok",...} +curl -s http://localhost:8080/api/v1/services/x # -> 401 (auth required, route exists) + +kill %1 +``` + +To verify migrations alone against a scratch database: + +```bash +cd app/backend +DATABASE_URL="postgres://user:pass@localhost:5432/scratch?sslmode=disable" \ + go run ./cmd/migrate up +DATABASE_URL=... go run ./cmd/migrate goose-status +``` + +## Conventions + +- Reuse existing utilities before adding dependencies. +- Keep handlers thin; business logic lives in `internal/` packages. +- Regenerate API types after schema changes: + `npm --prefix app/frontend run generate:api`. +- Never commit secrets, tokens, or environment files. +- Write tests for non-trivial logic. + +## Pull Requests + +- Keep PRs focused — one concern per PR. +- Describe the "why", not just the "what". +- Include a test plan for anything touching deployment, auth, or networking. diff --git a/Dockerfile.backend b/Dockerfile.backend deleted file mode 100644 index 333a8b7..0000000 --- a/Dockerfile.backend +++ /dev/null @@ -1,54 +0,0 @@ -# Build stage -FROM golang:1.24-alpine AS builder - -# Install build dependencies -RUN apk add --no-cache git ca-certificates tzdata - -# Set working directory -WORKDIR /app - -# Copy go mod files -COPY go.mod go.sum ./ - -# Download dependencies -RUN go mod download - -# Copy source code -COPY . . - -# Build the application -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main cmd/server/main.go - -# Final stage -FROM alpine:latest - -# Install ca-certificates for HTTPS requests -RUN apk --no-cache add ca-certificates tzdata - -# Create non-root user -RUN addgroup -g 1001 -S appgroup && \ - adduser -u 1001 -S appuser -G appgroup - -WORKDIR /app - -# Copy binary from builder stage -COPY --from=builder /app/main . - -# Copy migrations -COPY --from=builder /app/migrations ./migrations - -# Change ownership to non-root user -RUN chown -R appuser:appgroup /app - -# Switch to non-root user -USER appuser - -# Expose port -EXPOSE 8080 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 - -# Run the binary -CMD ["./main"] diff --git a/Dockerfile.frontend b/Dockerfile.frontend deleted file mode 100644 index 3de8cc6..0000000 --- a/Dockerfile.frontend +++ /dev/null @@ -1,53 +0,0 @@ -# Build stage -FROM node:20-alpine AS builder - -# Set working directory -WORKDIR /app - -# Copy package files -COPY package*.json ./ - -# Install dependencies -RUN npm ci && npm install --save-dev terser - -# Copy source code -COPY . . - -# Build the application -RUN npm run build - -# Production stage -FROM nginx:alpine - -# Copy custom nginx config -COPY nginx.conf /etc/nginx/nginx.conf - -# Copy built assets from builder stage -COPY --from=builder /app/dist /usr/share/nginx/html - -# Create non-root user -RUN addgroup -g 1001 -S appgroup && \ - adduser -u 1001 -S appuser -G appgroup - -# Change ownership of nginx directories -RUN chown -R appuser:appgroup /usr/share/nginx/html && \ - chown -R appuser:appgroup /var/cache/nginx && \ - chown -R appuser:appgroup /var/log/nginx && \ - chown -R appuser:appgroup /etc/nginx/conf.d - -# Create nginx PID directory -RUN touch /var/run/nginx.pid && \ - chown -R appuser:appgroup /var/run/nginx.pid - -# Switch to non-root user -USER appuser - -# Expose port -EXPOSE 80 - -# Health check -HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1 - -# Start nginx -CMD ["nginx", "-g", "daemon off;"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..10fdba5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Tomas Dvorak + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a7f73ac..c1f648b 100644 --- a/README.md +++ b/README.md @@ -1,354 +1,123 @@

- Containr Logo + Containr

-

- Containr - Modern Container Management Platform -

+

Containr

+

- 🚧 Work in Progress - Not yet fully tested -

-

- Deploy, manage, and scale containers with built-in Cloudflare tunnel support. + Self-hosted platform for deploying and managing containerized services. + Railway-style project canvases on your own hardware.

- Quick Start -   •   - Features -   •   - Tech Stack -   •   - Documentation -   •   - Contributing + Quick Start • + Features • + Architecture • + Guides • + Contributing

-

- Code Quality Scorecard -

+## What is Containr? -> **🚀 Inspired by Railway**: This project draws inspiration from [Railway](https://railway.app)'s seamless deployment experience and developer-friendly approach. The goal is to bring that same level of simplicity and power to self-hosted container management. +Containr is an open-source control plane for Docker. It gives you a +project-first deployment workflow — create a project, drop services onto a +visual canvas, wire them together with environment variables, and deploy — +without Kubernetes, without a cloud bill, and without giving up control of +your infrastructure. -## Introduction - -I built Containr because I was tired of complex container orchestration tools that required weeks of setup and deep expertise. You know how it is – Kubernetes clusters to configure, CI/CD pipelines to maintain, networking nightmares to debug, and that simple app deployment that somehow took three days to get running. - -So I decided to build my own solution. Containr is basically the platform I wish I had – one place to deploy, manage, and scale containers without the complexity. It's open-source and self-hosted because I believe your infrastructure should belong to you, not some cloud provider. - -It's like if Docker Compose, Traefik, and a modern dashboard had a baby. Built by one person (with AI's help), for anyone who's tired of container management being a mess. - -### My Design Inspiration - -I've always been a huge fan of **Portainer** – their clean, intuitive approach to container management just makes sense. Everything feels intentional and puts your containers first. - -So yeah, Containr borrows heavily from that design philosophy. But I wrote every line of code from scratch – this isn't some fork or copy-paste job. I took that beautiful simplicity and tried to expand it into more of a comprehensive container platform with modern features. - -### The AI-Assisted Journey - -This project was built with **Windsurf SWE 1.5**. - -Look, I'll be honest – I'm not the best DevOps engineer out there. But I really wanted to build something that solves real problems, and I think passion matters more than being perfect. Windsurf helped me figure out the hard stuff, learn new technologies, and actually build something I'm proud of. - -As a solo dev, having an AI pair programmer made this whole thing possible. Go backend, React frontend, Docker orchestration – stuff I probably couldn't have tackled alone. It's pretty cool what you can create when humans and AI work together, even when you're still learning. - -With Containr, your container deployments are centralized, scalable, and easy to manage, while remaining self-hosted so you maintain full control and privacy – just as it should be. - -## Project Status - -Containr is production-ready for small to medium deployments with comprehensive features for container management. The platform has been enhanced with enterprise-grade security, monitoring, and reliability features. - -**Production Readiness Score: 75/100** - -- ✅ Core functionality complete and tested -- ✅ Security hardening implemented -- ✅ Structured logging and error handling -- ✅ Rate limiting and request validation -- ✅ Comprehensive documentation -- ⚠️ Monitoring integration recommended -- ⚠️ Load testing recommended for high-traffic scenarios - -See [PRODUCTION_READINESS.md](./PRODUCTION_READINESS.md) for detailed assessment. +The UI is inspired by Railway's project canvas: services appear as nodes, +connections are inferred automatically, and everything — logs, metrics, +deployments, builds — is one click away. ## Features -### Core Functionality -- **Container Management**: Deploy, start, stop, and restart containers with ease -- **Service Orchestration**: Manage multi-container applications with Docker Compose integration -- **Automatic SSL**: Built-in Let's Encrypt certificate management with Traefik -- **Cloudflare Tunnel**: Zero-config deployment with automatic public URLs -- **Web Dashboard**: Modern React-based interface for container management -- **Real-time Logs**: View and search container logs in real-time +- **Project canvas** — visual service topology with groups, drag/drop layout, + and auto-inferred connections +- **Deployments** — Docker-based deploys with history, logs, and rollback +- **Git integration** — deploy from GitHub, GitLab, Bitbucket, or Gitea; + repository and branch pickers, webhooks, GitHub App support +- **Builds** — build pipeline with live status over WebSocket +- **Service metrics** — real CPU/memory/network telemetry from Docker stats +- **Templates** — one-click service catalog (databases, apps, tooling) +- **Databases** — managed database services with backup/restore actions +- **Cron jobs** — scheduled jobs with execution history +- **Preview environments** — per-project preview deploys +- **Variables & environments** — per-service env vars, production/preview/dev +- **Node monitoring** — host CPU, memory, disk, and network telemetry +- **Security** — audit logs, vulnerability scans, compliance reports +- **Auth** — Better Auth sessions (email + OAuth), per-user resource scoping +- **Self-hosted networking** — Traefik reverse proxy, optional Cloudflare + Tunnel for exposure without port forwarding -### Advanced Features -- **Auto-scaling**: Automatic horizontal scaling based on resource metrics -- **Health Monitoring**: Comprehensive health checks and monitoring for all services -- **Resource Metrics**: CPU, memory, and network usage tracking -- **Preview Environments**: Automatic preview deployments for branches -- **Git Integration**: Connect repositories for automatic deployments -- **Environment Variables**: Secure management of application secrets and configuration -- **Database Management**: Built-in PostgreSQL and Redis with automated backups -- **Security Scanning**: Container vulnerability scanning and compliance reporting +## Architecture -### Deployment & Infrastructure -- **Multi-environment Support**: Development, staging, and production configurations -- **Load Balancing**: Intelligent traffic distribution with Traefik -- **Service Discovery**: Automatic service registration and discovery -- **Network Management**: Isolated networks and secure inter-service communication -- **Volume Management**: Persistent storage with automated backups -- **CI/CD Integration**: GitHub Actions and GitLab CI integration +``` +app/frontend/ React 19 + Vite + TypeScript + Tailwind + React Flow +app/backend/ Go API (Gin) · PostgreSQL · Redis · Docker SDK · Better Auth sidecar +infra/ Self-hosted compose: Traefik + Cloudflare Tunnel +docs/ Guides, OpenAPI spec, design documents +``` -### Security & Compliance -- **Authentication**: JWT-based authentication with OAuth2 support -- **Role-based Access Control**: Granular permissions for users and teams -- **Audit Logging**: Comprehensive audit trails for all actions -- **Data Encryption**: Encrypted storage for sensitive data -- **Security Scanning**: Automated vulnerability assessment -- **Compliance Reporting**: GDPR and SOC2 compliance frameworks - -## Tech Stack - -### Frontend -- **React + TypeScript** – Modern, reactive UI framework with type safety -- **Vite** – Fast build tool and development server with HMR -- **TailwindCSS** – Utility-first CSS framework for rapid UI development -- **@tanstack/react-query** – Powerful data fetching and state management -- **React Router** – Client-side routing for SPAs -- **@xyflow/react** – Interactive diagrams and visualizations -- **Recharts** – Beautiful charts and data visualization -- **Zustand** – Lightweight state management - -### Backend Services -- **Main Backend (Go)** – Core API, container management, and business logic - - Gin web framework for HTTP routing - - GORM for database operations - - JWT authentication and authorization - - Docker SDK integration -- **Database Layer** – PostgreSQL for production data -- **Cache Layer** – Redis for session management and caching -- **Message Queue** – Background job processing - -### Infrastructure & DevOps -- **Docker & Docker Compose** – Containerized deployment -- **Traefik** – Reverse proxy with automatic SSL -- **Cloudflare Tunnel** – Zero-config public exposure -- **PostgreSQL** – Primary database -- **Redis** – Caching and session storage -- **Nginx** – Static file serving +| Component | Default port | +| ------------ | ------------ | +| Frontend | 3000 (prod) / 5173 (dev) | +| API | 8082 | +| Auth sidecar | 3001 | +| PostgreSQL | 5432 | +| Redis | 6379 | ## Quick Start -### Prerequisites -- Docker Engine with Compose v2 (`docker compose`) -- Git - -### Installation with Docker (Recommended) - -1. **Clone the repository** - ```bash - git clone https://github.com/your-username/containr.git - cd containr - ``` - -2. **Configure environment** - ```bash - cp .env.example .env - cp .env.production.example .env.prod - # Edit .env for local development and .env.prod for production - ``` - -3. **Start the full local stack** - ```bash - docker compose up -d --build - ``` - -4. **Access the application** - - **Local full stack**: http://localhost:3000 (Frontend), http://localhost:8082 (API) - - **Self-hosted production**: `./start-unified.sh prod` - - **Cloudflare profile**: `./start-unified.sh cloudflare` - -### Split Production Deployment +### Docker Compose (everything) ```bash -# Frontend -> Vercel -cd app/frontend -npm run remote - -# Backend only -> self-hosted Docker Compose -cd ../backend -docker compose up -d --build - -# Backend -> Railway -# Configure Railway service root directory: /app/backend -# Configure Railway config file path: /app/backend/railway.toml -railway up +cp .env.example .env # fill in secrets +docker compose up -d ``` -Use `.env.prod` in the repository root as the canonical production variable set. Copy the relevant values into Vercel and Railway project environments so the remote frontend and backend talk to each other over `FRONTEND_URL`, `BACKEND_URL`, `VITE_API_URL`, and `VITE_AUTH_URL`. +Frontend at `http://localhost:3000`, API at `http://localhost:8082`. -### Management Commands +### Local development ```bash -./start-unified.sh dev # Start local full stack -./start-unified.sh prod # Start self-hosted production stack from infra/ -./start-unified.sh cloudflare # Start self-hosted production stack with cloudflared -./start-unified.sh stop # Stop both compose stacks -./start-unified.sh logs # View local stack logs -./start-unified.sh status # Check service status -./start-unified.sh config # Validate compose configuration -./start-unified.sh clean # Clean up containers and volumes +docker compose up -d postgres redis # data services only +cd app/backend && go run ./cmd/server # API + embedded auth sidecar +cd app/frontend && npm install && npm run dev ``` -## Development +Frontend dev server at `http://localhost:5173`. -### Project Structure -``` -containr/ -├── app/ -│ ├── frontend/ # React + Vite frontend (deploy from here to Vercel) -│ └── backend/ # Go API + embedded Better Auth runtime -├── infra/ # Self-hosted production compose + Traefik/cloudflared -├── docs/ # API contract and design references -├── scripts/ # Utility scripts -├── .env.prod # Canonical production env template -└── README.md -``` +### Self-hosted with Traefik + Cloudflare Tunnel -### Local Development - -```bash -# Backend -cd app/backend -go mod download -go run cmd/migrate/main.go up # optional manual migration run (legacy + goose) -go run cmd/server/main.go - -# Frontend -cd ../frontend -npm install -npm run dev -``` - -## Documentation - -Comprehensive documentation is available in the project: - -- **[Cloudflare Setup](./CLOUDFLARE_SETUP.md)** – Cloudflare tunnel configuration -- **[Docker Setup](./DOCKER_SETUP.md)** – Docker deployment guide -- **[Autoscaling](./AUTOSCALING.md)** – Auto-scaling configuration -- **[API Documentation](./docs/api/openapi.yaml)** – REST API reference +See `infra/docker-compose.yml` and +[docs/guides/CLOUDFLARE_SETUP.md](docs/guides/CLOUDFLARE_SETUP.md). ## Configuration -### Environment Variables +All configuration is via environment variables — see `.env.example`. +Required for production: `JWT_SECRET`, `BETTER_AUTH_SECRET`, +`BETTER_AUTH_INTERNAL_TOKEN`, database and Redis credentials. -Key configuration options in `.env`: +**Never commit `.env` or `.env.prod`.** Both are gitignored; if a secret was +ever committed, rotate it. -```bash -# Domain Configuration (optional if using Cloudflare) -DOMAIN=yourdomain.com -ACME_EMAIL=admin@yourdomain.com +## Documentation -# Database Configuration -POSTGRES_DB=containr -POSTGRES_USER=containr_user -POSTGRES_PASSWORD=your_secure_postgres_password -MAX_CONNECTIONS=25 -MAX_IDLE_CONNECTIONS=5 -CONN_MAX_LIFETIME=5m -CONN_MAX_IDLE_TIME=5m - -# Redis Configuration -REDIS_PASSWORD=your_secure_redis_password - -# Application Configuration -JWT_SECRET=your_very_secure_jwt_secret_key_here # min 32 chars in production -CONTAINR_AGENT_AUTH_TOKEN=your_agent_shared_secret # required for node-agent auth -# Optional rotating token list (comma-separated) -# CONTAINR_AGENT_AUTH_TOKENS=current_secret,next_secret -CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.com -COOKIE_SECURE=true - -# Traefik Authentication (Basic Auth for dashboard) -TRAEFIK_AUTH=admin:$$apr1$$b8mh8c8v$$KkR8hQZQZQZQZQZQZQZQZ/ -# true for local dev dashboard on :8080, set false in production -TRAEFIK_API_INSECURE=true - -# Cloudflare Tunnel (alternative to domain) -CLOUDFLARED_TOKEN=your_cloudflare_tunnel_token_here - -# Optional: Custom Docker Registry -DOCKER_REGISTRY=your-registry.com -DOCKER_USERNAME=your_username -DOCKER_PASSWORD=your_password - -# Optional: External Services -SENTRY_DSN=https://your-sentry-dsn -SLACK_WEBHOOK_URL=https://hooks.slack.com/services/your/webhook/url - -# Development/Testing -ENVIRONMENT=development -DEBUG=true -TRUSTED_PROXY_CIDR=172.20.0.0/16 -``` +- [docs/guides/](docs/guides/) — setup, deployment, Cloudflare, autoscaling +- [docs/api/openapi.yaml](docs/api/openapi.yaml) — API contract +- [docs/developers/onboarding.md](docs/developers/onboarding.md) — contributor onboarding +- [docs/design/](docs/design/) — UI design references +- `make help` — common tasks ## Contributing -Building Containr as a solo developer has been an incredible journey, but it's always better when we build together! Whether you're fixing a typo, adding a feature, or just sharing ideas – your contribution matters. +See [CONTRIBUTING.md](CONTRIBUTING.md). Issues and PRs welcome. -**Feel free to contribute and enhance it to your liking. Help me make this project successful!** +## Security -Every contribution, no matter how small, helps turn this solo dream into a community success story. Whether you're a seasoned developer or just starting out, your perspective and skills can help make Containr better for everyone. - -Here's how you can join this adventure: - -1. **Fork the repository** and make it your own -2. **Create a feature branch** (`git checkout -b feature/amazing-feature`) -3. **Commit your changes** (`git commit -m 'Add some amazing feature'`) -4. **Push to the branch** (`git push origin feature/amazing-feature`) -5. **Open a Pull Request** – I'll be excited to review it! - -### Development Guidelines -- Follow Go coding standards for backend development -- Use TypeScript for frontend development -- Write tests for new features (helps future you!) -- Update documentation as needed -- Remember: this is a labor of love, so let's keep it fun and welcoming - -Don't hesitate to reach out if you're new to contributing – we all started somewhere, and I'm happy to help you get started! +See [SECURITY.md](SECURITY.md). Report vulnerabilities privately — do not +open public issues. ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Community & Support - -- **Issues**: Use GitHub Issues for bug reports and feature requests -- **Discussions**: Use GitHub Discussions for questions and community support -- **Documentation**: Check the project files for comprehensive guides - -## Acknowledgements - -### Inspiration -- **[Portainer](https://www.portainer.io/)** – Container management UI inspiration -- **[Docker Compose](https://docs.docker.com/compose/)** – Service orchestration concepts -- **[Traefik](https://traefik.io/)** – Reverse proxy and load balancing -- **[Cloudflare Tunnel](https://www.cloudflare.com/products/tunnel/)** – Zero-config deployment - -### Technology Stack -This project is built with amazing open-source technologies: -- **Frontend**: React, TypeScript, Vite, TailwindCSS -- **Backend**: Go, Gin, GORM, PostgreSQL -- **Infrastructure**: Docker, Traefik, Cloudflare, Redis - -## A Personal Note - -Thank you for taking the time to look at my project. Containr represents months of learning, building, and dreaming – all in the service of creating something that makes container management a little more accessible and a lot more powerful. - -In a world of complex orchestration tools and expensive cloud services, I believe there's beauty in owning your infrastructure. Containr is my attempt to build that bridge between simplicity and power. - -Whether you use it, contribute to it, or just find inspiration here – know that you're part of something special. A solo developer's dream, powered by AI assistance, and shared with the open-source community. - ---- - -**Containr** – Built with ❤️ by one human, one AI, and a passion for better container management. +[MIT](LICENSE) © 2026 Tomas Dvorak diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..1e5fb0a --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,492 @@ +# Containr — Project Roadmap + +Last verified: **2026-09-17** against `main` (`a1e4352`). + +Goal: bring Containr from "working core + large headless backend" to a +finished, installable, documented v1.0 self-hosted platform. + +--- + +## 1. Verified Current State + +Everything below was run, not assumed. + +| Check | Result | +|---|---| +| `go build ./...` | clean | +| `go vet ./...` | clean | +| `go test ./...` | all packages pass (api, build, cli, config, database, ha, middleware, networking, security) | +| `tsc -b` (frontend) | clean | +| `eslint .` | clean | +| `vite build` | clean | +| `vitest run` | 3/3 pass — one smoke test file only | +| `go run ./cmd/migrate up` vs empty Postgres | clean — 52 app tables + `goose_db_version`, version `20260917000000` | +| `go run ./cmd/migrate up` vs legacy schema | clean — APwhy parallel tables + `migrations` bookkeeping dropped, dead `users` columns removed | +| Backend boot vs migrated Postgres+Redis | starts, `/live` → 200, `/health` → `{"database":"ok","redis":"ok"}`, protected routes → 401 | +| Frontend demo mode (`?demo=1`) | Projects, workspace canvas, Templates render correctly in the Vertice theme | + +### Feature inventory — backend vs frontend + +The backend exposes **~95 route registrations**; the frontend consumes +**52** of them via `api-client.ts`. The gap below is the real roadmap. + +| Feature | Backend | Frontend | Docs | +|---|---|---|---| +| Auth (Better Auth: email, GitHub, Google; bootstrap gate; internal manual user creation) | done | done (`/auth/*`, People page) | done | +| Projects CRUD | done | done (`/projects`) | done | +| Project canvas (nodes, groups, auto-connections, drag layout) | via services API | done (React Flow, `ProjectCanvas`) | done | +| Services CRUD | done | done (canvas + `/services/:id` detail) | done | +| Deployments: create, history, rollback, logs | done | done (service detail) | done | +| Service env variables | done | done (service detail config section) | done | +| Service metrics (Docker stats) | done | done (metrics section + `/metrics-demo`) | done | +| Host monitoring (`/system/host`) | done | done (Usage + Projects telemetry cards) | done | +| Builds + live status over `/ws` | done | done (`/builds`, `useBuildUpdates`) | done | +| Templates — list/detail/deploy | done | done (catalog page) | done | +| Templates — **Compose catalog** (GitHub import, paste YAML, `x-containr` metadata, community source) | **missing** | **missing** | spec written, not built | +| Git providers (PAT add/remove, repo list, branches) | done | done (Settings → Git Providers) | done | +| Git repo connect + webhook auto-deploy | done (`/git/repositories/connect`, `/git/webhooks`, `/api/git/webhooks/:id` receiver) | done — provider→repo→branch in Add Service, connect+webhook on create | guides claim it works | +| GitHub App install flow | done (`install_url` + connect endpoint) | done — Install button + `installation_id` return handling | done (real-provider e2e pending credentials) | +| Cron jobs (CRUD, trigger, executions) | done — real scheduler + `docker exec` capture | done — Cron tab on service detail | OPERATIONS.md | +| Managed databases (CRUD, actions, backup/restore) | done | done — `/databases` page (conn info, actions, backups) | OPERATIONS.md | +| Preview environments (CRUD, promote, cleanup) | done (bookkeeping only — no real container deploy; URL is a placeholder) | done — Previews section on service detail | OPERATIONS.md | +| Security scans, vulnerabilities, compliance/GDPR reports | done (heuristic scanner — image tags, insecure URLs, build-command smells; no Trivy/Grype binary scanning) | done — `/security` page (scan trigger, findings, metrics, history) | OPERATIONS.md | +| Audit logs | done | done — `/settings/audit-logs` filterable page | done | +| Autoscaling policies + manual scale | done | done — per-service Scaling section; policies persisted (scale events still in-memory) | AUTOSCALING.md | +| HA / failover policies | done | done (beta) | OPERATIONS.md | +| Node agents (register additional VPS/LXC/VM hosts, heartbeats) | done | done — token issue/revoke + install command on Usage page | done | +| API gateway (merged APwhy): upstream services, API keys, rate limits, ops/traffic analytics | management API done — native handlers under `/api/v1/gateway/*`, schema-corrected; **traffic proxy path still not mounted** (Phase 2) | **missing** | not documented | +| Proxmox provider (cluster/nodes/VM/LXC CRUD) | written, **routes never registered** — unreachable | none | not documented | +| Notifications (build/audit feed) | via existing APIs | done (shell bell menu) | — | +| Self-upgrade (`/system/upgrade/*`) | done | done (Upgrade button) | — | +| Light theme | CSS vars done | done (toggle in top bar) | — | +| CLI (`cmd/cli`) | exists | — | thin | +| Traefik + Cloudflare Tunnel infra | `infra/` done | — | done | +| Demo mode (`?demo=1`) | n/a | done | — | + +### Known issues and debt + +- ~~**React Flow edge warnings**~~ — **fixed**: `ServiceNode` now renders + invisible `Handle` components (target left, source right), so inferred + auto-connections attach without `#008` warnings. +- ~~**OpenAPI drift**~~ — **resolved**: spec now covers all 153 mounted + operations (114 path items, 154 operations incl. root probes). Schemas + were corrected against the real Go structs (Service, Git*, NodeAgent, + HostMonitoring, UpgradeStatus, metrics); stale `source`/`build_config` + contract removed. `api-types.ts` regenerated; `api-client.ts` raw types + now alias generated schemas — camelCase normalizers stay by design. +- **Two DB access patterns remain**: GORM is gone (agents handler ported to + sqlc), but most handlers still hand-write `database/sql` — ~140 call sites + across ~14 files. Porting them to `sqlc/queries/` is mechanical but large; + scheduled as its own pass. +- ~~**Two migration systems**~~ — **resolved**: `migrations/` and its loader + are deleted. `migrations_goose/` is the only path; + `20260401163000_baseline.sql` carries the consolidated schema (idempotent — + safe over partially-migrated legacy installs), and + `20260917000000_drop_apwhy_parallel_auth.sql` removes the dead APwhy + objects. Fresh install and legacy upgrade both verified via `cmd/migrate`. +- ~~**APwhy is triple-implemented**~~ — **resolved**: vendored server, + `cmd/apwhy`, embedded SolidJS bundle, and the unwired copy under + `internal/api/server.go` + cloned packages are all deleted (~7.5k lines). + Management API lives at `/api/v1/gateway/*` on Containr auth; traffic + proxy mount remains Phase 2 work. Note: `umami_sync_cache` and + `database_connections` were dropped with the dead tables — re-add via a + new migration when Phase 2 Umami sync lands. +- **Proxmox written but never wired**: `internal/proxmox` + + `internal/api/proxmox.go` define 17 endpoints; `RegisterProxmoxRoutes` is + never called. Kept deliberately — see §2.2. +- ~~**Dead weight**~~ — **removed**: `reactflow` dep, `cmd/test-*` + harnesses, `migrations/0001_init.sql` + `.bak`. Correction to the earlier + audit: `feature_response.go` is *live* (`respondDependencyUnavailable` is + used by `logs.go`) and stays. +- **Test coverage**: frontend has focused tests (`variable-utils`) but no + canvas/flow coverage; backend coverage concentrated in `internal/api` and + infra packages; deployment engine, docker client, metrics, scaling, + agents have no tests. + +--- + +## 2. Decisions + +1. **APwhy — merged, consolidated.** DECIDED: APwhy becomes a first-class + Containr feature ("API Gateway"): deploy a service, publish it behind a + managed route with keys, rate limits, and analytics. Canonical + implementation is the Containr-native Gin handlers + (`internal/api/apwhy_handlers.go`) — they already use Containr auth and + DB. The vendored standalone server (`internal/apwhy/*`, `cmd/apwhy`, + embedded SolidJS bundle) is deleted after the useful parts are ported + (`gateway/proxy.go`, analytics queries). The parallel auth/RBAC schema + from migration 008 is dropped — Better Auth owns identity. APwhy's + upstream repo at `ref/APwhy` stays as the standalone edition. +2. **Proxmox — kept, and generalized into infrastructure providers over + Terraform/OpenTofu.** DECIDED: node provisioning becomes an + infrastructure-as-code pipeline. `internal/proxmox` stays for + read/inventory (cluster status, node stats, existing VMs/LXCs); all + *provisioning* goes through Terraform — one engine, providers as HCL + modules. Proxmox (bpg/proxmox) is provider #1, AWS EC2 is provider #2; + adding Hetzner/GCP/later providers is then a module, not a feature. + Provisioning a node = render module → `terraform apply` → cloud-init + installs Docker + `containr-agent` → node auto-enrolls. VM CRUD alone is + not the feature; capacity-on-click is. + - Engine: `terraform-exec` (or OpenTofu binary — same HCL, OSS license; + pick at implementation time). Provider binaries + tfstate live under a + `data/iac/` dir; provisioning requires the binary, core platform does + not. + - Secrets: provider credentials encrypted at rest in an + `infra_connections` table, instance-scoped. +3. **Multi-node / autoscaling / HA in v1.0?** — **Ship agents + host + monitoring; mark autoscaling/HA as beta.** They are backend-complete but + unproven end-to-end. +4. **Environments**: `environment_id` exists in the schema and the seeder + creates production/preview/development, but the UI exposes a single + `env:production` badge. — **Production-only in v1.0**; preview + environments land in Phase 3. +5. **DB access**: — **sqlc only.** Remove the GORM dependency (used by ~1 + handler) and stop hand-writing SQL in handlers. + +--- + +## 3. Phases + +### Phase 0 — Hygiene and contract (1 pass, blocking) + +Everything else builds on a truthful API contract and a clean tree. + +- [x] Remove dead code: `migrations/0001_init.sql`, `migrations/*.bak`, + `cmd/test-build-manager`, `cmd/test-railpack`, `reactflow` npm dep. + (`feature_response.go` kept — it is live.) +- [x] **APwhy consolidation** (per §2.1): Containr's native + `internal/gateway` was already the real proxy — nothing needed porting; + the vendored server, `cmd/apwhy`, the embedded SolidJS bundle, the + unwired `internal/api/server.go` copy, and the cloned packages are + deleted. Management routes moved to `/api/v1/gateway/*` (services CRUD + + validate, keys, ops/traffic analytics); handlers fixed against the real + schema (UUID ids, integer `enabled`, plan validation). Parallel + auth/RBAC tables dropped in the migration consolidation. +- [x] Consolidate migrations: `migrations/` collapsed into the goose baseline + `20260401163000_baseline.sql` (idempotent); `migrations_goose/` is the + only path; legacy loader + Dockerfile copy removed. Fresh-boot (53 + tables) and upgrade-from-legacy both verified through `cmd/migrate`. +- [x] Converge DB access on sqlc: agents handler ported off GORM, `gorm` + dependency dropped, agent queries in `sqlc/queries/agents.sql`. + Remaining hand-written `database/sql` in other handlers (~140 sites) + is tracked as a follow-up pass — sqlc is the target for all new code. +- [x] Bring `docs/api/openapi.yaml` to parity with `routes.go`: 114 paths / + 154 operations cover every mounted route (incl. `/live`, `/health`, + `/ready`, `/api/agents/*` token routes with server overrides; Proxmox + handlers are registered nowhere, intentionally undocumented). + `api-types.ts` regenerated; `api-client.ts` contract types deduped to + generated schemas — normalized view models kept. +- [x] Fix auto-connection edges — `ServiceNode` lacked `Handle` components; + invisible target/source handles added in `nodes.tsx`. +- [x] Rerun desloppify; record score. 2026-09-17 scan (`ref/` excluded): + objective **66.1**, strict **16.5** — note the tool's scoring model + changed: strict now drags the 75%-weighted subjective pool (20 human + review items, all unscored). Mechanical dimensions: code quality 90.7, + security 97.0, file health 81.4, test health 35.0. The old "≥89 strict" + target is not comparable; treat objective ≥ 70 as the next milestone. +- [x] `go vet`, `eslint`, `tsc`, `go test`, `vitest`, `vite build` all green; + boot smoke verified (`/live` → 200, `/health` → db/redis ok, protected + routes → 401) and documented in `CONTRIBUTING.md`. + +**Gate**: `docker compose up -d` from a clean clone produces a working stack; +OpenAPI spec matches the router 1:1; zero dead packages. + +### Phase 1 — Finish the core deploy loop (the Railway promise) + +The product's spine: *project → service → deploy → logs → rollback*. Most +pieces exist; this phase closes the gaps that block real usage. + +- [x] **Git connect flow, end to end**: Settings → GitProvidersSection manages + providers; service dialog picks provider → repo → branch; on create the + repo is connected (`POST /git/repositories/connect`, 409-tolerant) and a + push webhook is registered (`POST /git/webhooks`). Live provider push + verification pending real credentials. +- [x] **Webhook auto-deploy**: public receiver `POST /api/git/webhooks/:id` + verifies HMAC (GitHub `X-Hub-Signature-256`, Gitea `X-Gitea-Signature`) + or GitLab `X-Gitlab-Token`, maps repo+branch → services, inserts + deployment rows and enqueues `runDeploymentAndSync` (trigger=webhook), + which flips canvas node status to building. Unit-tested signature + verification. Not yet e2e-verified against a real provider. +- [ ] **Compose template catalog** per `docs/superpowers/specs/2026-04-14-compose-template-catalog-design.md`: + - `service_templates.compose_yaml`, `screenshots`, `source_type`, + parsed `config` summary (migration + sqlc). + - `POST /templates/import/github`, `POST /templates/import/compose`. + - Variables discovered from `${VAR}`/`${VAR:-default}` placeholders. + - Catalog UI: screenshots, badges, import dialogs (GitHub + paste), + install wizard → creates one service per Compose service. +- [x] **Service creation flows on canvas**: Add Service covers Image, Git + repo (provider → repo → branch → connect+webhook), Database (engine + + plan → real `POST /databases` provisioning, not a hollow service row), + worker/cron types, and a Template entry that jumps to the catalog with + the project preselected (`/templates?project=`). +- [x] **Variables UX**: Variables section on the service detail page — add / + edit / remove rows, secret masking (masked `********` secrets preserved on + save; editing replaces), bulk `.env` paste (`KEY=VAL`, comments, `export` + prefix, quoted values), inline validation (required / invalid / duplicate + keys). Per-environment values are already covered by the schema: each + environment owns its service instances, and variables are per-service. +- [x] **Deploy lifecycle polish**: backend already writes deploy progress to + `services.status`; the canvas now polls faster while any service is in a + transitional state so build → deploy propagates to nodes, deployment + history rows link straight into that deployment's log tail, and rollback + asks for confirmation before firing. +- [x] **Node/port collision handling**: verified structurally impossible in the + current model — services never claim host ports (`PortMappings` is never + populated; traffic routes via container IPs + Traefik discovery), and + managed databases bind `127.0.0.1` with ephemeral host ports. If a claim + ever conflicts, Docker's bind error lands in `deployments.error` and shows + in the failed-deploy log tail — not silent. Revisit if a service-level + host-port config is ever added. +- [x] Auth hardening: e2e-verified against a live stack (scratch Postgres + + Redis + real Better Auth sidecar). Fixed a real gate hole: sign-ups write + `auth_users` but the gate counted `users`, which only mirrors on the + first authenticated call — a second registration slipped through the + pre-mirror window. `countLocalUsers` now unions both tables (verified: + `users=0, auth_users=1` already returns `mode: login` and 403s on both + public register paths; internal-token manual creation works, wrong token + refused). Session expiry redirect confirmed in `App.tsx` + (401 → null → `/auth/sign-in?redirect=…`). OAuth happy paths remain + unverifiable without real GitHub/Google credentials — providers are + env-gated and `disableImplicitSignUp` means OAuth cannot bypass the + gate by creating accounts. + +**Gate**: a fresh instance can register → create project → connect GitHub repo +→ push → see build → see deploy → view logs → roll back, with zero manual API +calls. Template install from pasted Compose works the same way. + +### Phase 2 — Surface the headless backend + +All of these have working APIs and no UI. Cheapest feature wins in the repo. + +- [x] **Databases** (`/databases/*` + backup/restore): `/databases` page + (nav item) — expandable cards with connection URL + copy, start/stop/ + restart actions, metrics summary, backup list + New backup + Restore + (with confirm). Verified e2e against a real `postgres:16` image: + provision → `psql` connect → stop/start → seed → backup (6.4MB + archive) → drop table → restore → data recovered. +- [x] **Cron jobs** (`/cron-jobs/*`): real engine + UI. Backend was a stub + (`calculateNextRun` returned now+1h, `executeCronJob` slept 2s and wrote + "success" without running anything). Now: `robfig/cron` parses the + schedule (validated on create → 400), a 60s scheduler goroutine executes + due enabled jobs, and commands run via `docker exec sh -c` inside the + service's `containr--*` container with stdout+stderr captured + into `cron_executions` — verified e2e (trigger + scheduler tick, real + alpine output). UI: "Cron" tab on the service detail page — list/create/ + edit/enable-toggle, manual Run, execution history with logs. (Not added + to Add Service: jobs exec into an existing service's container, so they + live on the service, not as a standalone type.) +- [x] **Preview environments UI**: service-detail "Previews" section — list + per service, create (branch/PR/TTL), promote-to-production with + confirm, delete with confirm. Verified e2e: create→list→promote→delete. + Fixed two latent backend bugs: create scanned NULL `services.type` + (every real service failed) and three deployment inserts omitted the + NOT NULL `version` column (create + rollback + promote all broke on + fresh installs). +- [ ] **Preview environments runtime**: preview creation writes bookkeeping + rows and a fake `*.preview.containr.local` URL — nothing is actually + deployed. Real preview = build the branch + deploy container + route a + real subdomain + TTL cleanup that tears down containers. Sizeable; + schedule with env support (§2.4). +- [x] **Audit logs**: real page at `/settings/audit-logs` (linked from the + Settings page) — filterable table by resource, action, actor (email), + and time range, with pagination. Backend widened accordingly: the list + endpoint was self-scoped and dropped `user_email`; it now joins `users` + and accepts `user_id`/`actor`/`since` filters. Widening is consistent + with the platform's no-roles trust model (every authed user can already + create users). +- [x] **Agents/nodes**: onboarding flow on the Usage page — issue per-node + tokens (raw `cagt_…` shown once, only sha256 hash stored in + `agent_auth_tokens`), one-time copy + prefilled + `CONTAINR_API_URL`/`CONTAINR_AGENT_AUTH_TOKEN` install command, issued + tokens list with label/created/last-used and revoke (revoked tokens + rejected at register/heartbeat — verified e2e). Env-configured shared + tokens (`CONTAINR_AGENT_AUTH_TOKEN(S)`) still accepted. Node cards with + heartbeat status + telemetry were already on the Usage page. +- [ ] **API Gateway (merged APwhy, full revival)** — new runtime work, not + just UI. Target: every upstream service published through Containr is + reachable, key-gated, rate-limited, and metered: + - [ ] Mount the traffic path: catch-all route (e.g. `/g/:serviceSlug/*path` + or per-service subdomain) → resolve `api_services` → API-key check → + rate limit (`rpm_limit`, `monthly_quota` via `usage_counters`) → + proxy through the ported `proxy.go` → record `metrics_timeseries` + + `incident_events`. + - [ ] Port `POST /services/:id/validate` (upstream reachability check) from + the vendored server. + - [ ] Full management surface under `/api/v1/gateway/*`: services CRUD + + validate, keys issue/revoke/quotas, `analytics/ops`, + `analytics/traffic`, incidents list. + - [ ] UI: new "Gateway" page — register upstream (auto-suggest running + Containr services), assign route prefix, issue/revoke keys with + quotas, per-service traffic + ops charts (feeds Usage page too), + incident timeline. + - [ ] Optional: Umami sync (`umami_sync_cache`) if a Umami instance is + configured — otherwise drop the table. + - [ ] Note: APwhy's own auth/RBAC/deploy/database endpoints are *not* + ported — Better Auth owns identity, Containr owns deployments and + databases natively. +- [x] **Notifications depth**: `notifications` table + `GET /notifications`, + `POST /notifications/:id/read`, `POST /notifications/read-all`. Bell + feed now reads real persisted rows with unread badge + mark-read. + Producer wired: deployment success/failure writes a notification. + Verified e2e: list, unread count, mark-one, mark-all. Further producers + (backup completion, agent offline, upgrade available) can reuse + `insertUserNotification`. +- [x] **GitHub App install surfacing**: Settings → Git Providers shows an + "Install GitHub App" action that opens `install_url`, and handles the + `?installation_id=` return by calling `POST /git/github-app/connect`. + (Verified at typecheck/lint level; real GitHub App redirect e2e tracked + under the OAuth/provider-credentials item in Phase 4.) +- [x] **Database → service binding**: `/databases` cards have a Bind action — + pick project → service → variable key (default `DATABASE_URL`) and the + `connection_url` is injected as a secret env var. Verified e2e: merged + PUT lands the URL and preserves masked secrets verbatim. +- [x] **Backup export**: `GET /databases/:id/backups/:bid/download` streams + the archive out of the `containr-db-backups` volume via a stopped utility + container + `CopyFromContainer` (daemon logs mangle non-UTF-8, so `cat` + through logs was not viable). Download action in the `/databases` backups + list. Verified e2e: byte-identical archive, 404 missing/cross-user, + 401 unauthenticated. +- [x] **Scheduled database backups**: `database_services.backup_schedule` + (cron expr) + `next_backup_at`; `PUT /databases/:id` accepts + `backup_schedule` (validated, empty string disables). The cron tick folds + due databases into `createBackupProcess`. Schedule row in the + `/databases` backups section. Verified e2e: `* * * * *` produced three + consecutive completed archives with real volume data. + +**Gate**: every registered route is reachable from the UI or has an explicit +"API-only" note in the spec. A request to `/g/{service}` with a valid key +reaches the upstream container and appears in the analytics tables. + +### Phase 3 — Advanced operations + +- [x] **Autoscaling UI** (`/scaling/*`): per-service policy editor + (min/max/target CPU/mem + enable toggle) and manual scale control + on service detail Scaling section. Verified live: policy CRUD, + state auto-registration, bounds rejection, scheduler dispatch + (fails "no ready nodes" without agents — error surfaces in UI). + Scaling history timeline not surfaced. Policies now persisted to + Postgres (survive restart); scale events remain in-memory. +- [x] **HA / failover UI** (`/ha/*`): `/ha` page with manager enable/disable, + status cards (nodes/health checks/alerts), manual failover with + confirmation, per-service failover policy CRUD (project→service + picker), active alerts with resolve, health check results. Verified + live. Beta: policies/alerts are in-memory, lost on restart. + Health-check and notifier CRUD endpoints remain unexposed. +- [x] **Security center** (`/security/*`): `/security` page with project + picker, scan trigger (type + optional service), security score and + vuln metrics cards, findings list with resolve/ignore, scan history. + Fixed latent bug: `security/metrics` queried a non-existent `score` + column (score lives in `summary` jsonb) — was 500 on every project. + Verified live: comprehensive scan produced 4 findings (3 high, 1 + medium), resolve flow, metrics aggregation. Real mechanism + documented: heuristic config analysis, NOT Trivy/Grype. + Remaining: compliance assessment/report viewer UI, security audit + correlation view. +- [ ] **Multi-node scheduling**: service → node placement rules using agent + telemetry; health-based rescheduling. Depends on Phase 2 agents. +- [ ] **Infrastructure providers over Terraform** (per §2.2): + - [ ] `internal/iac` package: wraps terraform-exec/OpenTofu — render + provider module + vars, `init`/`apply`/`destroy` in per-node + workspaces under `data/iac/`, capture outputs (IP, name, node id). + - [ ] `infra_connections` table: provider type (`proxmox`, `aws`), + encrypted credentials, instance-scoped; managed in Settings. + - [ ] `proxmox` module: LXC/VM on chosen node/storage, cloud-init installs + Docker + `containr-agent` with enrollment token → auto-registers. + - [ ] `aws` module: EC2 instance (AMI + instance type + SG) with the same + cloud-init enrollment → same node pipeline, different provider. + - [ ] Register `RegisterProxmoxRoutes` behind auth at + `/api/v1/proxmox/*` for read/inventory (cluster, nodes, stats, + existing VMs/LXCs) — write/provision path is Terraform only. + - [ ] Node lifecycle: destroy = `terraform destroy` + agent deregistration; + drift detection via periodic `plan` (mark node drifted in UI). + - [ ] UI — Nodes/Infrastructure page: provider connections, provision + wizard (pick provider → region/node → size → enroll), node cards + with provider badge, VM/LXC lifecycle actions. +- [ ] **Environments** (per §2.4): env switcher, per-env variables/domains, + promote flow — ships with preview environments. +- [x] **Service exec console**: `POST /services/:id/exec` + Console section + on service detail — one-off `sh -c` commands via `ExecRun` (30s cap, + 64KB output), ownership-checked, captured output + exit code per run. + Verified live: real container exec, stderr capture, 404 foreign + service, 401 unauthenticated. Interactive PTY remains out of scope. + +**Gate**: scaling and failover demonstrably work against a two-node lab; +security scans produce real reports, not empty tables. + +### Phase 4 — Productization and release + +- [ ] **First-run experience**: setup wizard (create admin → optional OAuth → + optional GitHub App → done), seeded demo project optional, empty states + everywhere. +- [ ] **Install story**: single `curl | sh`-style installer that writes + `.env`, pulls images, runs compose; verify on a clean VPS. +- [ ] **Self-upgrade loop**: verify `/system/upgrade/pull` actually swaps the + running image; document rollback. +- [x] **Docs pass (partial)**: `AUTOSCALING.md` rewritten to document the + real `/scaling` API + Scaling section (was generic Cloudflare/Swarm + advice); new `docs/guides/OPERATIONS.md` covers cron, exec console, + managed databases, preview environments, security scans, HA, and + node-agent onboarding — all synced to verified behavior. Remaining: + rewrite getting-started to the canvas flow; publish OpenAPI at + `/docs` inside the app (Docs page exists — render the spec there). +- [ ] **Tests**: raise frontend beyond smoke (canvas interactions, template + install wizard, git connect); backend table-driven tests for + deployments, templates import, webhooks; Playwright e2e suite for the + Phase-1 golden path. +- [ ] **OAuth e2e with real credentials**: GitHub/Google providers are + env-gated and config-verified only — run the happy path once real + provider apps exist (bootstrap gate already proven: OAuth cannot bypass + it via `disableImplicitSignUp`). +- [ ] **sqlc port — remaining raw SQL**: ~140 hand-written `database/sql` + call sites across ~14 handler files remain (agents + tokens are sqlc). + Mechanical port to `sqlc/queries/`; the consolidated baseline schema is + now the codegen source of truth. +- [x] **Autoscaling persistence**: `scaling_policies` table + autoscaler + `WithPersistence`/`LoadPolicies` — policies upsert on every set and + hydrate on boot, re-registering service state at `min_replicas`. + Verified: policy survives a backend restart. Scale event history and + placement maps remain in-memory (history resets on restart — + acceptable for beta). +- [x] **Demo-data parity for new pages**: `demoDatabases` fixture wired + into `/databases?demo=1`; `demoCronJobsByService` fixtures wired into + the service Cron tab (worker + api demo services). Scaling, Previews, + HA and Security sections still show empty in demo mode — acceptable: + those are interaction surfaces, not dashboards. +- [x] **CI**: existing workflow already covered Go tests, frontend + lint/typecheck/build, compose config validation, and startup + preflight. Added: compose **boot** smoke (postgres + redis + backend + + frontend + traefik up, `/health` poll, frontend check, teardown) + and a tag-gated GHCR publish job (backend + frontend images on + `v*` tags). Not yet observed green on GitHub — first run pending. +- [ ] **Release**: `v1.0.0` tag, changelog, release notes, screenshots refresh + in README. +- [ ] Post-build workflow per repo rules: codebase-to-course, desloppify, + security-reviewer, audit; append README scorecard. + +**Gate**: clean VPS → installer → first service deployed in under 10 minutes; +CI green; docs match the product. + +--- + +## 4. Cross-Cutting (every phase) + +- **Spec parity**: OpenAPI is the contract — regenerate types on every API + change (`npm run generate:api`); CI check for drift. +- **No manual API type duplication** in `api-client.ts` — consume + `generated/api-types.ts`. +- **Vertice design system**: all new UI follows `index.css` tokens, mono + labels, lime accent; no new palettes. +- **Demo mode parity**: every new page ships demo data in `demo-data.ts`. +- **Accessibility**: keyboard nav, focus rings, aria labels — the shell + already sets the pattern. +- **Commits**: conventional messages, `Authored By: TDvorak ` + trailer; squash-merge bodies kept short. +- **Pre-push gate**: build + test + lint + boot smoke, every time. + +## 5. Explicitly Out of Scope for v1.0 + +- Billing / metering (self-hosted; Usage page is monitoring-only). +- Kubernetes backend — Docker/Compose is the runtime. +- Magic-link, GitLab/Bitbucket/Gitea **auth** (removed by auth spec). Git + *providers* beyond GitHub remain PAT-based. +- Mobile app, TUI, desktop app. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..dd14e28 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,45 @@ +# Security Policy + +## Reporting a Vulnerability + +Please do **not** open a public issue for security vulnerabilities. + +Report privately via GitHub's private vulnerability reporting +(Security tab → Report a vulnerability), or contact the maintainer directly. + +Include: + +- A description of the vulnerability and its impact +- Steps to reproduce +- Affected version/commit if known + +You will receive an acknowledgement as soon as possible. Please allow +reasonable time for a fix before any public disclosure. + +## Scope + +Containr is a self-hosted platform that runs Docker containers, stores +credentials, and terminates user traffic. In scope: + +- Authentication and session handling (Better Auth sidecar, JWT, agent tokens) +- Secrets storage (Git provider tokens, environment variables) +- API authorization and per-user resource scoping +- Webhook signature validation +- CORS and trusted-origin configuration +- Deployment engine command/path handling + +## Secrets + +- Never commit `.env`, `.env.prod`, or any file containing credentials. +- Git provider access tokens are stored server-side only and are never + returned by the API. +- Rotate any secret that has ever been committed to git history. + +## Self-Hosting Notes + +- Set strong `JWT_SECRET`, `BETTER_AUTH_SECRET`, and + `BETTER_AUTH_INTERNAL_TOKEN` values in production. +- Set `COOKIE_SECURE=true` when serving over HTTPS. +- Restrict `CORS_ALLOWED_ORIGINS` to your actual frontend origin. +- Keep Traefik's dashboard disabled or behind auth in production + (`TRAEFIK_API_INSECURE=false`). diff --git a/app/backend/Dockerfile b/app/backend/Dockerfile index c65bec0..e9cb550 100644 --- a/app/backend/Dockerfile +++ b/app/backend/Dockerfile @@ -32,7 +32,6 @@ RUN addgroup -g 1001 -S appgroup && \ WORKDIR /app COPY --from=go-builder /app/main ./main -COPY --from=go-builder /app/migrations ./migrations COPY --from=go-builder /app/migrations_goose ./migrations_goose COPY --from=auth-builder /auth ./auth diff --git a/app/backend/README_BACKEND.md b/app/backend/README_BACKEND.md index 95d6535..e889063 100644 --- a/app/backend/README_BACKEND.md +++ b/app/backend/README_BACKEND.md @@ -170,8 +170,7 @@ internal/ ├── config/ - Configuration management ├── database/ - Database connections and migrations └── middleware/ - HTTP middleware -migrations/ - SQL migration files -migrations_goose/ - Goose-managed migrations for new schema changes +migrations_goose/ - Goose-managed migrations (single migration path) ``` ### Adding New Endpoints @@ -179,9 +178,9 @@ migrations_goose/ - Goose-managed migrations for new schema changes 1. Create handler functions in `internal/api/` 2. Add routes in `internal/api/routes.go` 3. Update database schema: - - Legacy chain lives in `migrations/` and is executed first. - - New migrations must be added to `migrations_goose/` using goose format (`-- +goose Up/Down`). - - Keep legacy files immutable after they are shipped. + - All migrations live in `migrations_goose/` using goose format (`-- +goose Up/Down`). + - `20260401163000_baseline.sql` carries the consolidated schema; append new files after it. + - Never edit the baseline in place; always add a new migration file. ### SQLC (Type-Safe Queries) diff --git a/app/backend/auth/src/auth.js b/app/backend/auth/src/auth.js index dc3ba0a..ae12029 100644 --- a/app/backend/auth/src/auth.js +++ b/app/backend/auth/src/auth.js @@ -1,6 +1,4 @@ import { betterAuth } from 'better-auth'; -import { magicLink } from 'better-auth/plugins/magic-link'; -import { genericOAuth } from 'better-auth/plugins/generic-oauth'; import { Pool } from 'pg'; import { PostgresDialect } from 'kysely'; import { getMigrations } from 'better-auth/db/migration'; @@ -50,41 +48,22 @@ const database = new PostgresDialect({ const githubClientId = env.GITHUB_CLIENT_ID?.trim() || ''; const githubClientSecret = env.GITHUB_CLIENT_SECRET?.trim() || ''; - -const gitlabClientId = env.GITLAB_CLIENT_ID?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_ID'; -const gitlabClientSecret = env.GITLAB_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITLAB_CLIENT_SECRET'; -const gitlabAuthorizeUrl = - env.GITLAB_OAUTH_AUTHORIZE_URL?.trim() || 'https://gitlab.com/oauth/authorize'; -const gitlabTokenUrl = env.GITLAB_OAUTH_TOKEN_URL?.trim() || 'https://gitlab.com/oauth/token'; -const gitlabUserUrl = env.GITLAB_OAUTH_USERINFO_URL?.trim() || 'https://gitlab.com/api/v4/user'; - -const giteaClientId = env.GITEA_CLIENT_ID?.trim() || 'PLACEHOLDER_GITEA_CLIENT_ID'; -const giteaClientSecret = env.GITEA_CLIENT_SECRET?.trim() || 'PLACEHOLDER_GITEA_CLIENT_SECRET'; -const giteaAuthorizeUrl = - env.GITEA_OAUTH_AUTHORIZE_URL?.trim() || - 'https://gitea.example.com/login/oauth/authorize'; -const giteaTokenUrl = - env.GITEA_OAUTH_TOKEN_URL?.trim() || - 'https://gitea.example.com/login/oauth/access_token'; -const giteaUserUrl = env.GITEA_OAUTH_USERINFO_URL?.trim() || 'https://gitea.example.com/api/v1/user'; - -const bitbucketClientId = env.BITBUCKET_CLIENT_ID?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_ID'; -const bitbucketClientSecret = - env.BITBUCKET_CLIENT_SECRET?.trim() || 'PLACEHOLDER_BITBUCKET_CLIENT_SECRET'; -const bitbucketAuthorizeUrl = - env.BITBUCKET_OAUTH_AUTHORIZE_URL?.trim() || 'https://bitbucket.org/site/oauth2/authorize'; -const bitbucketTokenUrl = - env.BITBUCKET_OAUTH_TOKEN_URL?.trim() || 'https://bitbucket.org/site/oauth2/access_token'; -const bitbucketUserUrl = - env.BITBUCKET_OAUTH_USERINFO_URL?.trim() || 'https://api.bitbucket.org/2.0/user'; -const bitbucketEmailsUrl = - env.BITBUCKET_OAUTH_EMAILS_URL?.trim() || 'https://api.bitbucket.org/2.0/user/emails'; +const googleClientId = env.GOOGLE_CLIENT_ID?.trim() || ''; +const googleClientSecret = env.GOOGLE_CLIENT_SECRET?.trim() || ''; const socialProviders = {}; if (githubClientId && githubClientSecret) { socialProviders.github = { clientId: githubClientId, clientSecret: githubClientSecret, + disableImplicitSignUp: true, + }; +} +if (googleClientId && googleClientSecret) { + socialProviders.google = { + clientId: googleClientId, + clientSecret: googleClientSecret, + disableImplicitSignUp: true, }; } @@ -97,6 +76,12 @@ const trustedOrigins = [ 'http://localhost:3000', 'http://localhost:8082', 'http://localhost:3001', + 'http://localhost:5173', + 'http://localhost:5174', + 'http://localhost:5175', + 'http://127.0.0.1:5173', + 'http://127.0.0.1:5174', + 'http://127.0.0.1:5175', ] .map((origin) => (origin || '').trim()) .filter(Boolean); @@ -130,158 +115,6 @@ export const auth = betterAuth({ maxPasswordLength: 128, }, socialProviders: Object.keys(socialProviders).length > 0 ? socialProviders : undefined, - plugins: [ - magicLink({ - sendMagicLink: async ({ email, url, token, metadata }) => { - const payload = { - email, - url, - token, - metadata, - }; - console.log('[better-auth] magic-link', JSON.stringify(payload)); - }, - disableSignUp: false, - }), - genericOAuth({ - config: [ - { - providerId: 'gitlab', - clientId: gitlabClientId, - clientSecret: gitlabClientSecret, - authorizationUrl: gitlabAuthorizeUrl, - tokenUrl: gitlabTokenUrl, - userInfoUrl: gitlabUserUrl, - scopes: ['read_user', 'read_api'], - getUserInfo: async (tokens) => { - if (!tokens.accessToken) { - return null; - } - - const response = await fetch(gitlabUserUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - - if (!response.ok) { - return null; - } - - const profile = await response.json(); - if (!profile || (!profile.id && !profile.username)) { - return null; - } - - return { - id: profile.id || profile.username, - email: profile.email || null, - name: profile.name || profile.username || 'GitLab User', - image: profile.avatar_url || null, - emailVerified: Boolean(profile.email), - }; - }, - }, - { - providerId: 'gitea', - clientId: giteaClientId, - clientSecret: giteaClientSecret, - authorizationUrl: giteaAuthorizeUrl, - tokenUrl: giteaTokenUrl, - userInfoUrl: giteaUserUrl, - scopes: ['read:user', 'read:repository'], - getUserInfo: async (tokens) => { - if (!tokens.accessToken) { - return null; - } - - const response = await fetch(giteaUserUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `token ${tokens.accessToken}`, - }, - }); - - if (!response.ok) { - return null; - } - - const profile = await response.json(); - if (!profile || (!profile.id && !profile.login)) { - return null; - } - - return { - id: profile.id || profile.login, - email: profile.email || null, - name: profile.full_name || profile.login || 'Gitea User', - image: profile.avatar_url || null, - emailVerified: Boolean(profile.email), - }; - }, - }, - { - providerId: 'bitbucket', - clientId: bitbucketClientId, - clientSecret: bitbucketClientSecret, - authorizationUrl: bitbucketAuthorizeUrl, - tokenUrl: bitbucketTokenUrl, - userInfoUrl: bitbucketUserUrl, - scopes: ['account', 'email', 'repository'], - getUserInfo: async (tokens) => { - if (!tokens.accessToken) { - return null; - } - - const userResponse = await fetch(bitbucketUserUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - - if (!userResponse.ok) { - return null; - } - - const user = await userResponse.json(); - if (!user || (!user.account_id && !user.username && !user.nickname)) { - return null; - } - - let primaryEmail = null; - const emailsResponse = await fetch(bitbucketEmailsUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - Authorization: `Bearer ${tokens.accessToken}`, - }, - }); - if (emailsResponse.ok) { - const emailsPayload = await emailsResponse.json(); - const values = Array.isArray(emailsPayload?.values) ? emailsPayload.values : []; - const primary = values.find((entry) => entry?.is_primary) || values[0]; - if (primary && typeof primary.email === 'string') { - primaryEmail = primary.email; - } - } - - return { - id: user.account_id || user.username || user.nickname, - email: primaryEmail, - name: user.display_name || user.nickname || 'Bitbucket User', - image: user?.links?.avatar?.href || null, - emailVerified: Boolean(primaryEmail), - }; - }, - }, - ], - }), - ], }); export async function runAuthMigrations() { diff --git a/app/backend/cmd/agent/main.go b/app/backend/cmd/agent/main.go new file mode 100644 index 0000000..9d88336 --- /dev/null +++ b/app/backend/cmd/agent/main.go @@ -0,0 +1,566 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "time" +) + +type agentConfig struct { + APIURL string + Token string + Name string + Hostname string + IP string + Port int + Interval time.Duration +} + +type capabilities struct { + ContainerRuntimes []string `json:"container_runtimes"` + SupportedArchitectures []string `json:"supported_architectures"` + MaxContainers int `json:"max_containers"` + StorageDriver string `json:"storage_driver"` + NetworkPlugins []string `json:"network_plugins"` + Features []string `json:"features"` +} + +type nodeResources struct { + CPU cpuResources `json:"cpu"` + Memory memoryResources `json:"memory"` + Storage storageResources `json:"storage"` + Network networkResources `json:"network"` +} + +type cpuResources struct { + Cores int `json:"cores"` + Allocation float64 `json:"allocation"` + Usage float64 `json:"usage"` +} + +type memoryResources struct { + Total int `json:"total"` + Allocated int `json:"allocated"` + Used int `json:"used"` + Available int `json:"available"` +} + +type storageResources struct { + Total int `json:"total"` + Allocated int `json:"allocated"` + Used int `json:"used"` + Available int `json:"available"` +} + +type networkResources struct { + Interfaces []networkInterface `json:"interfaces"` + Bandwidth bandwidthInfo `json:"bandwidth"` +} + +type networkInterface struct { + Name string `json:"name"` + IPAddress string `json:"ip_address"` + MACAddress string `json:"mac_address"` + Speed int `json:"speed"` + Status string `json:"status"` +} + +type bandwidthInfo struct { + Inbound int `json:"inbound"` + Outbound int `json:"outbound"` +} + +type systemLoad struct { + Load1M float64 `json:"load_1m"` + Load5M float64 `json:"load_5m"` + Load15M float64 `json:"load_15m"` +} + +type command struct { + ID string `json:"id"` + Type string `json:"type"` + Payload map[string]interface{} `json:"payload"` + CreatedAt string `json:"created_at"` +} + +type commandList struct { + Commands []command `json:"commands"` +} + +type registerResponse struct { + AgentID string `json:"agent_id"` + Status string `json:"status"` +} + +func main() { + cfg, err := loadConfig() + if err != nil { + log.Fatal(err) + } + + client := &http.Client{Timeout: 20 * time.Second} + agentID, err := register(context.Background(), client, cfg) + if err != nil { + log.Fatalf("register agent: %v", err) + } + + log.Printf("containr agent connected: id=%s api=%s", agentID, cfg.APIURL) + ticker := time.NewTicker(cfg.Interval) + defer ticker.Stop() + + for { + if err := sendHeartbeat(context.Background(), client, cfg, agentID); err != nil { + log.Printf("heartbeat failed: %v", err) + } + if err := processCommands(context.Background(), client, cfg, agentID); err != nil { + log.Printf("command poll failed: %v", err) + } + <-ticker.C + } +} + +func loadConfig() (agentConfig, error) { + hostname, _ := os.Hostname() + apiURL := strings.TrimRight(firstEnv("CONTAINR_API_URL", "http://localhost:8082"), "/") + apiURL = strings.TrimSuffix(strings.TrimSuffix(apiURL, "/api/v1"), "/api") + token := strings.TrimSpace(os.Getenv("CONTAINR_AGENT_AUTH_TOKEN")) + if token == "" { + token = strings.TrimSpace(os.Getenv("CONTAINR_AGENT_TOKEN")) + } + if token == "" { + return agentConfig{}, errors.New("set CONTAINR_AGENT_AUTH_TOKEN") + } + + intervalSeconds, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_INTERVAL_SECONDS", "15")) + if intervalSeconds < 5 { + intervalSeconds = 5 + } + port, _ := strconv.Atoi(firstEnv("CONTAINR_AGENT_PORT", "9090")) + + return agentConfig{ + APIURL: apiURL, + Token: token, + Name: firstEnv("CONTAINR_AGENT_NAME", hostname), + Hostname: firstEnv("CONTAINR_AGENT_HOSTNAME", hostname), + IP: firstEnv("CONTAINR_AGENT_IP", detectOutboundIP()), + Port: port, + Interval: time.Duration(intervalSeconds) * time.Second, + }, nil +} + +func register(ctx context.Context, client *http.Client, cfg agentConfig) (string, error) { + payload := map[string]interface{}{ + "name": cfg.Name, + "hostname": cfg.Hostname, + "ip_address": cfg.IP, + "port": cfg.Port, + "capabilities": detectCapabilities(), + } + var response registerResponse + if err := postJSON(ctx, client, cfg, "/api/agents/register", payload, &response); err != nil { + return "", err + } + if response.AgentID == "" { + return "", errors.New("register response missing agent_id") + } + return response.AgentID, nil +} + +func sendHeartbeat(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error { + resources := collectResources(cfg.IP) + payload := map[string]interface{}{ + "node_agent_id": agentID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "status": agentStatus(), + "resources": resources, + "container_count": dockerContainerCount(), + "system_load": readSystemLoad(), + "uptime": readSystemUptime(), + "version": firstEnv("CONTAINR_AGENT_VERSION", "dev"), + } + return postJSON(ctx, client, cfg, "/api/agents/heartbeat", payload, nil) +} + +func processCommands(ctx context.Context, client *http.Client, cfg agentConfig, agentID string) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api/agents/%s/commands", cfg.APIURL, agentID), nil) + if err != nil { + return err + } + req.Header.Set("X-Containr-Agent-Token", cfg.Token) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("command poll status %d", resp.StatusCode) + } + + var list commandList + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + return err + } + for _, item := range list.Commands { + result, runErr := executeCommand(item) + status := "completed" + errText := "" + if runErr != nil { + status = "failed" + errText = runErr.Error() + } + payload := map[string]string{ + "status": status, + "result": result, + "error": errText, + } + path := fmt.Sprintf("/api/agents/%s/commands/%s/result", agentID, item.ID) + if err := postJSON(ctx, client, cfg, path, payload, nil); err != nil { + log.Printf("report command result failed: command=%s err=%v", item.ID, err) + } + } + return nil +} + +func executeCommand(item command) (string, error) { + switch item.Type { + case "create_container": + return createContainer(item.Payload) + case "start_container": + return runDocker("start", commandTarget(item.Payload)) + case "stop_container": + return runDocker("stop", commandTarget(item.Payload)) + case "restart_container": + return runDocker("restart", commandTarget(item.Payload)) + case "remove_container": + return runDocker("rm", "-f", commandTarget(item.Payload)) + default: + return "", fmt.Errorf("unsupported command type %q", item.Type) + } +} + +func createContainer(payload map[string]interface{}) (string, error) { + container, ok := payload["container"].(map[string]interface{}) + if !ok { + return "", errors.New("missing container payload") + } + + name := stringValue(container["name"]) + image := stringValue(container["image"]) + if name == "" || image == "" { + return "", errors.New("container name and image are required") + } + + args := []string{"run", "-d", "--name", name, "--restart", "unless-stopped"} + if env, ok := container["environment"].(map[string]interface{}); ok { + for key, value := range env { + args = append(args, "-e", fmt.Sprintf("%s=%v", key, value)) + } + } + if ports, ok := container["ports"].([]interface{}); ok { + for _, rawPort := range ports { + port, ok := rawPort.(map[string]interface{}) + if !ok || !boolValue(port["published"]) { + continue + } + hostPort := intValue(port["host_port"]) + containerPort := intValue(port["container_port"]) + protocol := firstString(stringValue(port["protocol"]), "tcp") + if hostPort > 0 && containerPort > 0 { + args = append(args, "-p", fmt.Sprintf("%d:%d/%s", hostPort, containerPort, protocol)) + } + } + } + if volumes, ok := container["volumes"].([]interface{}); ok { + for _, rawVolume := range volumes { + volume, ok := rawVolume.(map[string]interface{}) + if !ok { + continue + } + source := stringValue(volume["source"]) + target := stringValue(volume["target"]) + if source != "" && target != "" { + mount := source + ":" + target + if boolValue(volume["read_only"]) { + mount += ":ro" + } + args = append(args, "-v", mount) + } + } + } + args = append(args, image) + return runDocker(args...) +} + +func commandTarget(payload map[string]interface{}) string { + for _, key := range []string{"container_name", "docker_name", "name", "container_id"} { + if value := stringValue(payload[key]); value != "" { + return value + } + } + return "" +} + +func postJSON(ctx context.Context, client *http.Client, cfg agentConfig, path string, payload interface{}, out interface{}) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.APIURL+path, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Containr-Agent-Token", cfg.Token) + + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("request %s failed with status %d", path, resp.StatusCode) + } + if out == nil { + return nil + } + return json.NewDecoder(resp.Body).Decode(out) +} + +func detectCapabilities() capabilities { + runtimes := []string{} + if dockerAvailable() { + runtimes = append(runtimes, "docker") + } + return capabilities{ + ContainerRuntimes: runtimes, + SupportedArchitectures: []string{runtime.GOARCH}, + MaxContainers: 100, + StorageDriver: dockerStorageDriver(), + NetworkPlugins: []string{"bridge"}, + Features: []string{"host-metrics", "heartbeats", "command-poll", "docker-lifecycle"}, + } +} + +func collectResources(ip string) nodeResources { + mem := readMemory() + storage := readStorage("/") + return nodeResources{ + CPU: cpuResources{ + Cores: runtime.NumCPU(), + Allocation: 0, + Usage: readCPULoadPercent(), + }, + Memory: mem, + Storage: storage, + Network: networkResources{ + Interfaces: []networkInterface{{ + Name: "primary", + IPAddress: ip, + Speed: 0, + Status: "up", + }}, + Bandwidth: bandwidthInfo{}, + }, + } +} + +func readMemory() memoryResources { + values := map[string]int{} + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return memoryResources{} + } + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + value, err := strconv.Atoi(fields[1]) + if err == nil { + values[strings.TrimSuffix(fields[0], ":")] = value * 1024 + } + } + total := values["MemTotal"] + available := values["MemAvailable"] + used := total - available + if used < 0 { + used = 0 + } + return memoryResources{Total: total, Used: used, Available: available} +} + +func readStorage(path string) storageResources { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return storageResources{} + } + total := int(stat.Blocks * uint64(stat.Bsize)) + available := int(stat.Bavail * uint64(stat.Bsize)) + used := total - available + if used < 0 { + used = 0 + } + return storageResources{Total: total, Used: used, Available: available} +} + +func readSystemLoad() systemLoad { + data, err := os.ReadFile("/proc/loadavg") + if err != nil { + return systemLoad{} + } + fields := strings.Fields(string(data)) + if len(fields) < 3 { + return systemLoad{} + } + load1, _ := strconv.ParseFloat(fields[0], 64) + load5, _ := strconv.ParseFloat(fields[1], 64) + load15, _ := strconv.ParseFloat(fields[2], 64) + return systemLoad{Load1M: load1, Load5M: load5, Load15M: load15} +} + +func readSystemUptime() int64 { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + return 0 + } + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return 0 + } + uptime, _ := strconv.ParseFloat(fields[0], 64) + return int64(uptime) +} + +func readCPULoadPercent() float64 { + load := readSystemLoad() + cores := runtime.NumCPU() + if cores == 0 { + return 0 + } + percent := load.Load1M / float64(cores) * 100 + if percent < 0 { + return 0 + } + if percent > 100 { + return 100 + } + return percent +} + +func runDocker(args ...string) (string, error) { + if len(args) == 0 || args[len(args)-1] == "" { + return "", errors.New("missing docker target") + } + cmd := exec.Command("docker", args...) + output, err := cmd.CombinedOutput() + return strings.TrimSpace(string(output)), err +} + +func dockerAvailable() bool { + _, err := exec.LookPath("docker") + if err != nil { + return false + } + cmd := exec.Command("docker", "info", "--format", "{{.ServerVersion}}") + return cmd.Run() == nil +} + +func dockerStorageDriver() string { + out, err := exec.Command("docker", "info", "--format", "{{.Driver}}").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func dockerContainerCount() int { + out, err := exec.Command("docker", "ps", "-q").Output() + if err != nil { + return 0 + } + count := 0 + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + if strings.TrimSpace(line) != "" { + count++ + } + } + return count +} + +func agentStatus() string { + if dockerAvailable() { + return "online" + } + return "degraded" +} + +func detectOutboundIP() string { + conn, err := net.DialTimeout("udp", "8.8.8.8:80", 2*time.Second) + if err != nil { + return "127.0.0.1" + } + defer conn.Close() + if addr, ok := conn.LocalAddr().(*net.UDPAddr); ok { + return addr.IP.String() + } + return "127.0.0.1" +} + +func firstEnv(key, fallback string) string { + if value := strings.TrimSpace(os.Getenv(key)); value != "" { + return value + } + return fallback +} + +func firstString(value, fallback string) string { + if value != "" { + return value + } + return fallback +} + +func stringValue(value interface{}) string { + switch typed := value.(type) { + case string: + return typed + case fmt.Stringer: + return typed.String() + default: + return "" + } +} + +func intValue(value interface{}) int { + switch typed := value.(type) { + case int: + return typed + case int64: + return int(typed) + case float64: + return int(typed) + case json.Number: + out, _ := typed.Int64() + return int(out) + default: + return 0 + } +} + +func boolValue(value interface{}) bool { + typed, _ := value.(bool) + return typed +} diff --git a/app/backend/cmd/apwhy/main.go b/app/backend/cmd/apwhy/main.go deleted file mode 100644 index 852e0c7..0000000 --- a/app/backend/cmd/apwhy/main.go +++ /dev/null @@ -1,29 +0,0 @@ -package main - -import ( - "fmt" - "log" - "net/http" - - "containr/internal/apwhy/api" - "containr/internal/apwhy/config" - "containr/internal/apwhy/storage" -) - -func main() { - cfg := config.Load() - - store, err := storage.Open(cfg) - if err != nil { - log.Fatalf("failed to initialize storage: %v", err) - } - defer store.Close() - - server := api.NewServer(store, cfg) - - addr := fmt.Sprintf(":%d", cfg.Port) - log.Printf("APwhy server listening on http://localhost%s", addr) - if err := http.ListenAndServe(addr, server.Handler()); err != nil { - log.Fatalf("server exited: %v", err) - } -} diff --git a/app/backend/cmd/migrate/main.go b/app/backend/cmd/migrate/main.go index 63aabc2..b57c0a5 100644 --- a/app/backend/cmd/migrate/main.go +++ b/app/backend/cmd/migrate/main.go @@ -25,11 +25,6 @@ func main() { } defer db.Close() - legacyDir := os.Getenv("LEGACY_MIGRATIONS_DIR") - if legacyDir == "" { - legacyDir = "migrations" - } - gooseDir := os.Getenv("GOOSE_MIGRATIONS_DIR") if gooseDir == "" { gooseDir = "migrations_goose" @@ -41,24 +36,14 @@ func main() { } switch command { - case "up": + case "up", "goose-up": migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) - if err := db.MigrateAllWithLock(migrationCtx, legacyDir, gooseDir); err != nil { + if err := db.MigrateAllWithLock(migrationCtx, gooseDir); err != nil { migrationCancel() log.Fatalf("Migration failed: %v", err) } migrationCancel() - log.Println("Legacy + goose migrations completed successfully") - case "legacy-up": - if err := db.Migrate(legacyDir); err != nil { - log.Fatalf("Legacy migration failed: %v", err) - } - log.Println("Legacy migrations completed successfully") - case "goose-up": - if err := db.MigrateGoose(gooseDir); err != nil { - log.Fatalf("Goose migration failed: %v", err) - } - log.Println("Goose migrations completed successfully") + log.Println("Migrations completed successfully") case "goose-status": if err := db.GooseStatus(gooseDir); err != nil { log.Fatalf("Goose status failed: %v", err) @@ -73,6 +58,6 @@ func main() { } log.Printf("Created goose migration %q in %q", name, gooseDir) default: - log.Fatalf("Unknown command %q. Supported commands: up | legacy-up | goose-up | goose-status | goose-create", command) + log.Fatalf("Unknown command %q. Supported commands: up | goose-status | goose-create", command) } } diff --git a/app/backend/cmd/server/main.go b/app/backend/cmd/server/main.go index 36feb1e..97e0cdb 100644 --- a/app/backend/cmd/server/main.go +++ b/app/backend/cmd/server/main.go @@ -39,7 +39,7 @@ func main() { // Run startup migrations unless explicitly disabled. if cfg.AutoMigrate { migrationCtx, migrationCancel := context.WithTimeout(context.Background(), cfg.MigrationLockTimeout) - if err := db.MigrateAllWithLock(migrationCtx, "migrations", "migrations_goose"); err != nil { + if err := db.MigrateAllWithLock(migrationCtx, "migrations_goose"); err != nil { migrationCancel() log.Fatalf("Failed to run database migrations: %v", err) } @@ -48,8 +48,8 @@ func main() { log.Println("AUTO_MIGRATE disabled; skipping startup migrations") } - // Seed demo data in development (or when explicitly requested). - if cfg.IsDevelopment() || cfg.SeedDataOnStart { + // Seed demo data only when explicitly requested. + if cfg.SeedDataOnStart { if err := db.SeedData(); err != nil { log.Printf("Warning: Failed to seed data: %v", err) } diff --git a/app/backend/cmd/test-build-manager/main.go b/app/backend/cmd/test-build-manager/main.go deleted file mode 100644 index 21435b6..0000000 --- a/app/backend/cmd/test-build-manager/main.go +++ /dev/null @@ -1,54 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "containr/internal/build" -) - -func main() { - fmt.Println("🧪 Testing Build Manager Detection...") - - // Test build type detection on the current project - fmt.Println("\n📁 Testing on current project (has package.json)...") - - // Note: We can't fully test BuildManager without a docker client, - // but we can test the detection logic - - // Create mock scenarios - testCases := []struct { - name string - expected string - }{ - {"Node.js project (package.json)", "railpack"}, - {"Python project (requirements.txt)", "railpack"}, - {"Go project (go.mod)", "railpack"}, - {"Dockerfile project", "dockerfile"}, - {"Unknown project", "nixpacks"}, - } - - fmt.Println("\n🎯 Expected detection priorities:") - for _, tc := range testCases { - fmt.Printf(" • %s → %s\n", tc.name, tc.expected) - } - - // Test Railpack builder directly - builder := build.NewRailpackBuilder("/tmp/test", nil) - - fmt.Println("\n🔍 Testing Railpack detection on current project:") - err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr") - if err != nil { - fmt.Printf("❌ Detection failed: %v\n", err) - } else { - fmt.Println("✅ Railpack can build this project!") - } - - fmt.Println("\n📋 Build Priority Order:") - fmt.Println(" 1. Dockerfile (if present)") - fmt.Println(" 2. Railpack (primary choice)") - fmt.Println(" 3. Nixpacks (fallback)") - fmt.Println(" 4. Prebuilt (manual)") - - fmt.Println("\n🚀 Build Manager Integration Test Complete!") -} diff --git a/app/backend/cmd/test-railpack/main.go b/app/backend/cmd/test-railpack/main.go deleted file mode 100644 index 59c0667..0000000 --- a/app/backend/cmd/test-railpack/main.go +++ /dev/null @@ -1,32 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "containr/internal/build" -) - -func main() { - // Create a RailpackBuilder - builder := build.NewRailpackBuilder("/tmp/containr-build-test", nil) - - // Test detection on the current project (has package.json) - fmt.Println("Testing Railpack detection on current project...") - - err := builder.DetectRailpack(context.Background(), "/home/tdvorak/Desktop/PROG+HTML/Containr") - if err != nil { - fmt.Printf("❌ Railpack detection failed: %v\n", err) - } else { - fmt.Println("✅ Railpack can build this project!") - } - - // Show supported frameworks - fmt.Println("\nSupported frameworks:") - frameworks := builder.GetSupportedFrameworks() - for _, fw := range frameworks { - fmt.Printf(" - %s\n", fw) - } - - fmt.Println("\n✅ Railpack integration test completed successfully!") -} diff --git a/app/backend/go.mod b/app/backend/go.mod index 7fcbb54..81f273b 100644 --- a/app/backend/go.mod +++ b/app/backend/go.mod @@ -8,11 +8,9 @@ require ( github.com/gin-gonic/gin v1.9.1 github.com/go-playground/validator/v10 v10.30.1 github.com/go-redis/redis/v8 v8.11.5 - github.com/go-sql-driver/mysql v1.9.3 github.com/golang-jwt/jwt/v5 v5.0.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 - github.com/jackc/pgx/v5 v5.7.1 github.com/lib/pq v1.10.9 github.com/miekg/dns v1.1.72 github.com/pressly/goose/v3 v3.24.1 @@ -22,13 +20,9 @@ require ( github.com/sqlc-dev/pqtype v0.3.0 github.com/stretchr/testify v1.11.1 golang.org/x/crypto v0.47.0 - gorm.io/driver/postgres v1.6.0 - gorm.io/gorm v1.31.1 - modernc.org/sqlite v1.39.0 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -40,7 +34,6 @@ require ( github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect @@ -52,11 +45,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/jackc/pgpassfile v1.0.0 // indirect - github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/puddle/v2 v2.2.2 // indirect - github.com/jinzhu/inflection v1.0.0 // indirect - github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -68,13 +56,12 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/morikuni/aec v1.1.0 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect @@ -95,7 +82,6 @@ require ( go.uber.org/multierr v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.31.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect @@ -106,7 +92,5 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.2 // indirect - modernc.org/libc v1.66.3 // indirect - modernc.org/mathutil v1.7.1 // indirect - modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.39.0 // indirect ) diff --git a/app/backend/go.sum b/app/backend/go.sum index 22eb2f5..32f8caf 100644 --- a/app/backend/go.sum +++ b/app/backend/go.sum @@ -1,5 +1,3 @@ -filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= -filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -63,8 +61,6 @@ github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy0 github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= -github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= @@ -74,8 +70,6 @@ github.com/golang-jwt/jwt/v5 v5.0.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -84,18 +78,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= -github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= -github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= -github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= -github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -152,6 +134,8 @@ github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4 github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/cors v1.10.1 h1:L0uuZVXIKlI1SShY2nhFfo44TYvDPQ1w4oFkUJNfhyo= @@ -259,36 +243,14 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= -gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= -gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= -modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= -modernc.org/cc/v4 v4.26.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= -modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= -modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= -modernc.org/fileutil v1.3.8 h1:qtzNm7ED75pd1C7WgAGcK4edm4fvhtBsEiI/0NQ54YM= -modernc.org/fileutil v1.3.8/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= -modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= -modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= -modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ= modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= -modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= -modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= -modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= -modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= -modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= -modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/app/backend/internal/analytics/umami.go b/app/backend/internal/analytics/umami.go deleted file mode 100644 index 5ed8d56..0000000 --- a/app/backend/internal/analytics/umami.go +++ /dev/null @@ -1,74 +0,0 @@ -package analytics - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/url" - "time" -) - -type Client struct { - BaseURL string - APIKey string - WebsiteID string - HTTP *http.Client -} - -func NewClient(baseURL, apiKey, websiteID string) *Client { - return &Client{ - BaseURL: baseURL, - APIKey: apiKey, - WebsiteID: websiteID, - HTTP: &http.Client{ - Timeout: 10 * time.Second, - }, - } -} - -func (c *Client) Enabled() bool { - return c.BaseURL != "" && c.APIKey != "" && c.WebsiteID != "" -} - -func (c *Client) FetchTraffic(ctx context.Context, from, to time.Time) (map[string]any, error) { - if !c.Enabled() { - return map[string]any{ - "enabled": false, - }, nil - } - - u, err := url.Parse(fmt.Sprintf("%s/api/websites/%s/stats", c.BaseURL, c.WebsiteID)) - if err != nil { - return nil, err - } - - query := u.Query() - query.Set("startAt", fmt.Sprintf("%d", from.UnixMilli())) - query.Set("endAt", fmt.Sprintf("%d", to.UnixMilli())) - u.RawQuery = query.Encode() - - req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) - if err != nil { - return nil, err - } - req.Header.Set("Authorization", "Bearer "+c.APIKey) - req.Header.Set("Accept", "application/json") - - res, err := c.HTTP.Do(req) - if err != nil { - return nil, err - } - defer res.Body.Close() - - if res.StatusCode >= 400 { - return nil, fmt.Errorf("umami returned %d", res.StatusCode) - } - - var payload map[string]any - if err := json.NewDecoder(res.Body).Decode(&payload); err != nil { - return nil, err - } - payload["enabled"] = true - return payload, nil -} diff --git a/app/backend/internal/api/agents.go b/app/backend/internal/api/agents.go index e568254..ac82b79 100644 --- a/app/backend/internal/api/agents.go +++ b/app/backend/internal/api/agents.go @@ -1,7 +1,14 @@ package api import ( + "context" + "crypto/rand" + "crypto/sha256" "crypto/subtle" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" "fmt" "math" "net/http" @@ -9,26 +16,29 @@ import ( "strings" "time" + "containr/internal/database" + "containr/internal/database/sqlcdb" + "github.com/gin-gonic/gin" "github.com/google/uuid" - "gorm.io/gorm" + "github.com/sqlc-dev/pqtype" ) // NodeAgent represents a container orchestration agent type NodeAgent struct { - ID string `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Hostname string `json:"hostname" gorm:"not null"` - IPAddress string `json:"ip_address" gorm:"not null"` - Port int `json:"port" gorm:"not null"` - Status string `json:"status" gorm:"default:'offline'"` + ID string `json:"id"` + Name string `json:"name"` + Hostname string `json:"hostname"` + IPAddress string `json:"ip_address"` + Port int `json:"port"` + Status string `json:"status"` Version string `json:"version"` - Capabilities AgentCapabilities `json:"capabilities" gorm:"serializer:json"` - Resources NodeResources `json:"resources" gorm:"serializer:json"` + Capabilities AgentCapabilities `json:"capabilities"` + Resources NodeResources `json:"resources"` LastHeartbeat time.Time `json:"last_heartbeat"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Metadata map[string]interface{} `json:"metadata" gorm:"serializer:json"` + Metadata map[string]interface{} `json:"metadata"` } // AgentCapabilities defines what the agent can do @@ -89,20 +99,20 @@ type BandwidthInfo struct { // ContainerInstance represents a container running on an agent type ContainerInstance struct { - ID string `json:"id" gorm:"primaryKey"` - Name string `json:"name" gorm:"not null"` - Image string `json:"image" gorm:"not null"` - ProjectID string `json:"project_id" gorm:"not null"` - ServiceID string `json:"service_id" gorm:"not null"` - NodeAgentID string `json:"node_agent_id" gorm:"not null"` - Status ContainerStatus `json:"status" gorm:"serializer:json"` - Resources ContainerResources `json:"resources" gorm:"serializer:json"` - Ports []PortMapping `json:"ports" gorm:"serializer:json"` - Environment map[string]string `json:"environment" gorm:"serializer:json"` - Volumes []VolumeMount `json:"volumes" gorm:"serializer:json"` - Networks []string `json:"networks" gorm:"serializer:json"` - RestartPolicy RestartPolicy `json:"restart_policy" gorm:"serializer:json"` - HealthCheck *HealthCheck `json:"health_check" gorm:"serializer:json"` + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + ProjectID string `json:"project_id"` + ServiceID string `json:"service_id"` + NodeAgentID string `json:"node_agent_id"` + Status ContainerStatus `json:"status"` + Resources ContainerResources `json:"resources"` + Ports []PortMapping `json:"ports"` + Environment map[string]string `json:"environment"` + Volumes []VolumeMount `json:"volumes"` + Networks []string `json:"networks"` + RestartPolicy RestartPolicy `json:"restart_policy"` + HealthCheck *HealthCheck `json:"health_check"` CreatedAt time.Time `json:"created_at"` StartedAt *time.Time `json:"started_at"` UpdatedAt time.Time `json:"updated_at"` @@ -155,12 +165,12 @@ type HealthCheck struct { // AgentCommand represents a command sent to an agent type AgentCommand struct { - ID string `json:"id" gorm:"primaryKey"` - Type string `json:"type" gorm:"not null"` - NodeAgentID string `json:"node_agent_id" gorm:"not null"` + ID string `json:"id"` + Type string `json:"type"` + NodeAgentID string `json:"node_agent_id"` ContainerID *string `json:"container_id"` - Payload map[string]interface{} `json:"payload" gorm:"serializer:json"` - Status string `json:"status" gorm:"default:'pending'"` + Payload map[string]interface{} `json:"payload"` + Status string `json:"status"` Result *string `json:"result"` Error *string `json:"error"` CreatedAt time.Time `json:"created_at"` @@ -171,6 +181,7 @@ type AgentCommand struct { // AgentHeartbeat represents a heartbeat message from an agent type AgentHeartbeat struct { NodeAgentID string `json:"node_agent_id"` + AuthToken string `json:"auth_token,omitempty"` Timestamp time.Time `json:"timestamp"` Status string `json:"status"` Resources NodeResources `json:"resources"` @@ -180,19 +191,6 @@ type AgentHeartbeat struct { Version string `json:"version"` } -type AgentHeartbeatRecord struct { - ID string `json:"id" gorm:"primaryKey"` - NodeAgentID string `json:"node_agent_id" gorm:"index;not null"` - Timestamp time.Time `json:"timestamp" gorm:"index;not null"` - Status string `json:"status"` - Resources NodeResources `json:"resources" gorm:"serializer:json"` - ContainerCount int `json:"container_count"` - SystemLoad SystemLoad `json:"system_load" gorm:"serializer:json"` - Uptime int64 `json:"uptime"` - Version string `json:"version"` - CreatedAt time.Time `json:"created_at"` -} - type SystemLoad struct { Load1M float64 `json:"load_1m"` Load5M float64 `json:"load_5m"` @@ -201,13 +199,125 @@ type SystemLoad struct { // NodeAgentHandler handles agent-related endpoints type NodeAgentHandler struct { - db *gorm.DB + q *sqlcdb.Queries } -func NewNodeAgentHandler(db *gorm.DB) *NodeAgentHandler { - return &NodeAgentHandler{db: db} +func NewNodeAgentHandler(db *database.DB) *NodeAgentHandler { + return &NodeAgentHandler{q: sqlcdb.New(db.DB)} } +// --- row <-> API type conversion helpers --- + +func rawJSON(v interface{}) pqtype.NullRawMessage { + if v == nil { + return pqtype.NullRawMessage{} + } + b, err := json.Marshal(v) + if err != nil { + return pqtype.NullRawMessage{} + } + return pqtype.NullRawMessage{RawMessage: b, Valid: true} +} + +func unmarshalRaw(raw pqtype.NullRawMessage, dst interface{}) { + if raw.Valid && len(raw.RawMessage) > 0 { + _ = json.Unmarshal(raw.RawMessage, dst) + } +} + +func agentFromRow(row sqlcdb.NodeAgent) NodeAgent { + agent := NodeAgent{ + ID: row.ID, + Name: row.Name, + Hostname: row.Hostname, + IPAddress: row.IpAddress, + Port: int(row.Port), + Status: row.Status.String, + Version: row.Version.String, + Metadata: map[string]interface{}{}, + } + if row.LastHeartbeat.Valid { + agent.LastHeartbeat = row.LastHeartbeat.Time + } + if row.CreatedAt.Valid { + agent.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + agent.UpdatedAt = row.UpdatedAt.Time + } + unmarshalRaw(row.Capabilities, &agent.Capabilities) + unmarshalRaw(row.Resources, &agent.Resources) + unmarshalRaw(row.Metadata, &agent.Metadata) + return agent +} + +func containerFromRow(row sqlcdb.ContainerInstance) ContainerInstance { + container := ContainerInstance{ + ID: row.ID, + Name: row.Name, + Image: row.Image, + ProjectID: row.ProjectID, + ServiceID: row.ServiceID, + NodeAgentID: row.NodeAgentID, + } + if row.CreatedAt.Valid { + container.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + container.UpdatedAt = row.UpdatedAt.Time + } + if row.StartedAt.Valid { + t := row.StartedAt.Time + container.StartedAt = &t + } + unmarshalRaw(row.Status, &container.Status) + unmarshalRaw(row.Resources, &container.Resources) + unmarshalRaw(row.Ports, &container.Ports) + unmarshalRaw(row.Environment, &container.Environment) + unmarshalRaw(row.Volumes, &container.Volumes) + unmarshalRaw(row.Networks, &container.Networks) + unmarshalRaw(row.RestartPolicy, &container.RestartPolicy) + if row.HealthCheck.Valid { + var hc HealthCheck + if err := json.Unmarshal(row.HealthCheck.RawMessage, &hc); err == nil { + container.HealthCheck = &hc + } + } + return container +} + +func commandFromRow(row sqlcdb.AgentCommand) AgentCommand { + cmd := AgentCommand{ + ID: row.ID, + Type: row.Type, + NodeAgentID: row.NodeAgentID, + Status: row.Status.String, + } + if row.ContainerID.Valid { + cmd.ContainerID = &row.ContainerID.String + } + if row.Result.Valid { + cmd.Result = &row.Result.String + } + if row.Error.Valid { + cmd.Error = &row.Error.String + } + if row.CreatedAt.Valid { + cmd.CreatedAt = row.CreatedAt.Time + } + if row.UpdatedAt.Valid { + cmd.UpdatedAt = row.UpdatedAt.Time + } + if row.CompletedAt.Valid { + t := row.CompletedAt.Time + cmd.CompletedAt = &t + } + unmarshalRaw(row.Payload, &cmd.Payload) + return cmd +} + +// --- handlers --- + // RegisterAgent handles agent registration func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { var req struct { @@ -216,7 +326,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { IPAddress string `json:"ip_address" binding:"required"` Port int `json:"port" binding:"required"` Capabilities AgentCapabilities `json:"capabilities" binding:"required"` - AuthToken string `json:"auth_token" binding:"required"` + AuthToken string `json:"auth_token"` } if err := c.ShouldBindJSON(&req); err != nil { @@ -224,113 +334,115 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) { return } - if !isValidAgentAuthToken(req.AuthToken) { + authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c)) + if !h.isValidAgentAuthToken(c.Request.Context(), authToken) { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) return } - // Check if agent already exists - var existingAgent NodeAgent - if err := h.db.Where("hostname = ? AND ip_address = ?", req.Hostname, req.IPAddress).First(&existingAgent).Error; err == nil { - // Update existing agent - existingAgent.Name = req.Name - existingAgent.Port = req.Port - existingAgent.Capabilities = req.Capabilities - existingAgent.Status = "connecting" - existingAgent.LastHeartbeat = time.Now() + ctx := c.Request.Context() - if err := h.db.Save(&existingAgent).Error; err != nil { + // Re-registering an existing host updates it in place. + if existing, err := h.q.GetAgentByHostAndIP(ctx, sqlcdb.GetAgentByHostAndIPParams{ + Hostname: req.Hostname, + IpAddress: req.IPAddress, + }); err == nil { + updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ + ID: existing.ID, + Name: req.Name, + Hostname: existing.Hostname, + IpAddress: existing.IpAddress, + Port: int32(req.Port), + Status: sql.NullString{String: "connecting", Valid: true}, + Version: existing.Version, + Capabilities: rawJSON(req.Capabilities), + Resources: existing.Resources, + LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, + Metadata: existing.Metadata, + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } c.JSON(http.StatusOK, gin.H{ - "agent_id": existingAgent.ID, - "auth_token": req.AuthToken, + "agent_id": updated.ID, + "auth_token": authToken, "status": "updated", }) return } - // Create new agent - agent := NodeAgent{ - ID: uuid.New().String(), - Name: req.Name, - Hostname: req.Hostname, - IPAddress: req.IPAddress, - Port: req.Port, - Status: "connecting", - Capabilities: req.Capabilities, - Resources: NodeResources{ - CPU: CPUResources{ - Cores: 4, - Allocation: 0, - Usage: 0, - }, - Memory: MemoryResources{ - Total: 8 * 1024 * 1024 * 1024, // 8GB - Allocated: 0, - Used: 0, - Available: 8 * 1024 * 1024 * 1024, - }, - Storage: StorageResources{ - Total: 100 * 1024 * 1024 * 1024, // 100GB - Allocated: 0, - Used: 0, - Available: 100 * 1024 * 1024 * 1024, - }, - Network: NetworkResources{ - Interfaces: []NetworkInterface{ - { - Name: "eth0", - IPAddress: req.IPAddress, - MACAddress: "00:00:00:00:00:00", - Speed: 1000, - Status: "up", - }, - }, - Bandwidth: BandwidthInfo{ - Inbound: 0, - Outbound: 0, + agentID := uuid.New().String() + resources := NodeResources{ + CPU: CPUResources{Cores: 4}, + Memory: MemoryResources{ + Total: 8 * 1024 * 1024 * 1024, // 8GB + Available: 8 * 1024 * 1024 * 1024, + }, + Storage: StorageResources{ + Total: 100 * 1024 * 1024 * 1024, // 100GB + Available: 100 * 1024 * 1024 * 1024, + }, + Network: NetworkResources{ + Interfaces: []NetworkInterface{ + { + Name: "eth0", + IPAddress: req.IPAddress, + MACAddress: "00:00:00:00:00:00", + Speed: 1000, + Status: "up", }, }, }, - LastHeartbeat: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - Metadata: make(map[string]interface{}), } - if err := h.db.Create(&agent).Error; err != nil { + _, err := h.q.CreateAgent(ctx, sqlcdb.CreateAgentParams{ + ID: agentID, + Name: req.Name, + Hostname: req.Hostname, + IpAddress: req.IPAddress, + Port: int32(req.Port), + Status: sql.NullString{String: "connecting", Valid: true}, + Version: sql.NullString{}, + Capabilities: rawJSON(req.Capabilities), + Resources: rawJSON(resources), + LastHeartbeat: sql.NullTime{Time: time.Now(), Valid: true}, + Metadata: rawJSON(map[string]interface{}{}), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent"}) return } c.JSON(http.StatusCreated, gin.H{ - "agent_id": agent.ID, - "auth_token": req.AuthToken, + "agent_id": agentID, + "auth_token": authToken, "status": "registered", }) } // GetAgents returns all registered agents func (h *NodeAgentHandler) GetAgents(c *gin.Context) { - var agents []NodeAgent - if err := h.db.Find(&agents).Error; err != nil { + rows, err := h.q.ListAgents(c.Request.Context()) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agents"}) return } + agents := make([]NodeAgent, 0, len(rows)) + for _, row := range rows { + agents = append(agents, agentFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"agents": agents}) } // GetAgent returns a specific agent func (h *NodeAgentHandler) GetAgent(c *gin.Context) { - id := c.Param("id") - - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", id).Error; err != nil { - if err == gorm.ErrRecordNotFound { + row, err := h.q.GetAgent(c.Request.Context(), c.Param("id")) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -338,16 +450,17 @@ func (h *NodeAgentHandler) GetAgent(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"agent": agent}) + c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(row)}) } // UpdateAgent updates an agent's information func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { id := c.Param("id") + ctx := c.Request.Context() - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", id).Error; err != nil { - if err == gorm.ErrRecordNotFound { + row, err := h.q.GetAgent(ctx, id) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -361,19 +474,53 @@ func (h *NodeAgentHandler) UpdateAgent(c *gin.Context) { return } - if err := h.db.Model(&agent).Updates(updates).Error; err != nil { + agent := agentFromRow(row) + applyAgentUpdates(&agent, updates) + + updated, err := h.q.UpdateAgent(ctx, sqlcdb.UpdateAgentParams{ + ID: agent.ID, + Name: agent.Name, + Hostname: agent.Hostname, + IpAddress: agent.IPAddress, + Port: int32(agent.Port), + Status: sql.NullString{String: agent.Status, Valid: agent.Status != ""}, + Version: sql.NullString{String: agent.Version, Valid: agent.Version != ""}, + Capabilities: rawJSON(agent.Capabilities), + Resources: rawJSON(agent.Resources), + LastHeartbeat: sql.NullTime{Time: agent.LastHeartbeat, Valid: !agent.LastHeartbeat.IsZero()}, + Metadata: rawJSON(agent.Metadata), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } - c.JSON(http.StatusOK, gin.H{"agent": agent}) + c.JSON(http.StatusOK, gin.H{"agent": agentFromRow(updated)}) +} + +// applyAgentUpdates merges whitelisted keys from an arbitrary update map. +func applyAgentUpdates(agent *NodeAgent, updates map[string]interface{}) { + remarshal := func(key string, dst interface{}) { + if v, ok := updates[key]; ok { + if b, err := json.Marshal(v); err == nil { + _ = json.Unmarshal(b, dst) + } + } + } + remarshal("name", &agent.Name) + remarshal("hostname", &agent.Hostname) + remarshal("ip_address", &agent.IPAddress) + remarshal("port", &agent.Port) + remarshal("status", &agent.Status) + remarshal("version", &agent.Version) + remarshal("capabilities", &agent.Capabilities) + remarshal("resources", &agent.Resources) + remarshal("metadata", &agent.Metadata) } // DeleteAgent removes an agent func (h *NodeAgentHandler) DeleteAgent(c *gin.Context) { - id := c.Param("id") - - if err := h.db.Delete(&NodeAgent{}, "id = ?", id).Error; err != nil { + if err := h.q.DeleteAgent(c.Request.Context(), c.Param("id")); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete agent"}) return } @@ -388,13 +535,18 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c)) + if !h.isValidAgentAuthToken(c.Request.Context(), authToken) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) + return + } if heartbeat.Timestamp.IsZero() { heartbeat.Timestamp = time.Now() } - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", heartbeat.NodeAgentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + if _, err := h.q.GetAgent(ctx, heartbeat.NodeAgentID); err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -402,50 +554,160 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) { return } - // Update agent status and resources - agent.Status = heartbeat.Status - agent.Resources = heartbeat.Resources - agent.LastHeartbeat = heartbeat.Timestamp - agent.UpdatedAt = time.Now() - - if err := h.db.Save(&agent).Error; err != nil { + if err := h.q.UpdateAgentHeartbeat(ctx, sqlcdb.UpdateAgentHeartbeatParams{ + ID: heartbeat.NodeAgentID, + Status: sql.NullString{String: heartbeat.Status, Valid: heartbeat.Status != ""}, + Resources: rawJSON(heartbeat.Resources), + LastHeartbeat: sql.NullTime{Time: heartbeat.Timestamp, Valid: true}, + }); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update agent"}) return } - record := AgentHeartbeatRecord{ + err := h.q.InsertAgentHeartbeat(ctx, sqlcdb.InsertAgentHeartbeatParams{ ID: uuid.New().String(), NodeAgentID: heartbeat.NodeAgentID, Timestamp: heartbeat.Timestamp, Status: heartbeat.Status, - Resources: heartbeat.Resources, - ContainerCount: heartbeat.ContainerCount, - SystemLoad: heartbeat.SystemLoad, + Resources: rawJSON(heartbeat.Resources).RawMessage, + ContainerCount: int32(heartbeat.ContainerCount), + SystemLoad: rawJSON(heartbeat.SystemLoad).RawMessage, Uptime: heartbeat.Uptime, Version: heartbeat.Version, - CreatedAt: time.Now(), - } - if err := h.db.Create(&record).Error; err != nil { + }) + if err != nil && !isMissingTableError(err) { // Keep heartbeat endpoint available even if history table is not yet migrated. - if !isMissingTableError(err) { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) - return - } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to persist heartbeat"}) + return } c.Status(http.StatusOK) } +// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents. +func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) { + if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) + return + } + + rows, err := h.q.ListPendingCommands(c.Request.Context(), c.Param("id")) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) + return + } + + commands := make([]AgentCommand, 0, len(rows)) + for _, row := range rows { + commands = append(commands, commandFromRow(row)) + } + + c.JSON(http.StatusOK, gin.H{"commands": commands}) +} + +// CompleteCommand lets a node agent report command completion or failure. +func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) { + if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"}) + return + } + + agentID := c.Param("id") + commandID := c.Param("commandId") + var req struct { + Status string `json:"status" binding:"required"` + Result string `json:"result"` + Error string `json:"error"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if req.Status != "completed" && req.Status != "failed" { + c.JSON(http.StatusBadRequest, gin.H{"error": "status must be completed or failed"}) + return + } + + ctx := c.Request.Context() + command, err := h.q.CompleteCommand(ctx, sqlcdb.CompleteCommandParams{ + ID: commandID, + NodeAgentID: agentID, + Status: sql.NullString{String: req.Status, Valid: true}, + Result: sql.NullString{String: req.Result, Valid: req.Result != ""}, + Error: sql.NullString{String: req.Error, Valid: req.Error != ""}, + }) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update command"}) + return + } + + out := commandFromRow(command) + if out.ContainerID != nil { + h.updateContainerStatusAfterCommand(ctx, out) + } + + c.JSON(http.StatusOK, gin.H{"command": out}) +} + +func (h *NodeAgentHandler) updateContainerStatusAfterCommand(ctx context.Context, command AgentCommand) { + row, err := h.q.GetContainer(ctx, *command.ContainerID) + if err != nil { + return + } + + container := containerFromRow(row) + + if command.Status == "failed" { + errorMessage := "" + if command.Error != nil { + errorMessage = *command.Error + } + container.Status.State = "error" + container.Status.Error = &errorMessage + _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: container.ID, + Status: rawJSON(container.Status), + }) + return + } + + now := time.Now() + switch command.Type { + case "create_container", "start_container", "restart_container": + container.Status.State = "running" + container.Status.Health = "unknown" + container.Status.StartedAt = &now + container.Status.Error = nil + case "stop_container": + container.Status.State = "stopped" + container.Status.FinishedAt = &now + case "remove_container": + container.Status.State = "removed" + container.Status.FinishedAt = &now + } + _ = h.q.UpdateContainerStatus(ctx, sqlcdb.UpdateContainerStatusParams{ + ID: container.ID, + Status: rawJSON(container.Status), + }) +} + // GetAgentContainers returns containers running on a specific agent func (h *NodeAgentHandler) GetAgentContainers(c *gin.Context) { - agentID := c.Param("id") - - var containers []ContainerInstance - if err := h.db.Where("node_agent_id = ?", agentID).Find(&containers).Error; err != nil { + rows, err := h.q.ListContainersForAgent(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch containers"}) return } + containers := make([]ContainerInstance, 0, len(rows)) + for _, row := range rows { + containers = append(containers, containerFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"containers": containers}) } @@ -472,10 +734,11 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { return } + ctx := c.Request.Context() + // Verify agent exists - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + if _, err := h.q.GetAgent(ctx, agentID); err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } @@ -483,17 +746,15 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { return } + containerID := uuid.New().String() container := ContainerInstance{ - ID: uuid.New().String(), - Name: req.Name, - Image: req.Image, - ProjectID: req.ProjectID, - ServiceID: req.ServiceID, - NodeAgentID: agentID, - Status: ContainerStatus{ - State: "created", - Health: "none", - }, + ID: containerID, + Name: req.Name, + Image: req.Image, + ProjectID: req.ProjectID, + ServiceID: req.ServiceID, + NodeAgentID: agentID, + Status: ContainerStatus{State: "created", Health: "none"}, Resources: req.Resources, Ports: req.Ports, Environment: req.Environment, @@ -505,26 +766,36 @@ func (h *NodeAgentHandler) CreateContainer(c *gin.Context) { UpdatedAt: time.Now(), } - if err := h.db.Create(&container).Error; err != nil { + _, err := h.q.CreateContainer(ctx, sqlcdb.CreateContainerParams{ + ID: container.ID, + Name: container.Name, + Image: container.Image, + ProjectID: container.ProjectID, + ServiceID: container.ServiceID, + NodeAgentID: container.NodeAgentID, + Status: rawJSON(container.Status), + Resources: rawJSON(container.Resources), + Ports: rawJSON(container.Ports), + Environment: rawJSON(container.Environment), + Volumes: rawJSON(container.Volumes), + Networks: rawJSON(container.Networks), + RestartPolicy: rawJSON(container.RestartPolicy), + HealthCheck: rawJSON(container.HealthCheck), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container"}) return } // Create command to start container on agent - command := AgentCommand{ + _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: "create_container", NodeAgentID: agentID, - ContainerID: &container.ID, - Payload: map[string]interface{}{ - "container": container, - }, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + ContainerID: sql.NullString{String: container.ID, Valid: true}, + Payload: rawJSON(map[string]interface{}{"container": container}), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create container command"}) return } @@ -546,45 +817,44 @@ func (h *NodeAgentHandler) ExecuteCommand(c *gin.Context) { return } - command := AgentCommand{ + command, err := h.q.CreateCommand(c.Request.Context(), sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: req.Type, NodeAgentID: agentID, - Payload: req.Payload, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + Payload: rawJSON(req.Payload), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) return } - c.JSON(http.StatusCreated, gin.H{"command": command}) + c.JSON(http.StatusCreated, gin.H{"command": commandFromRow(command)}) } // GetAgentCommands returns commands for an agent func (h *NodeAgentHandler) GetAgentCommands(c *gin.Context) { - agentID := c.Param("id") - - var commands []AgentCommand - if err := h.db.Where("node_agent_id = ?", agentID).Order("created_at DESC").Find(&commands).Error; err != nil { + rows, err := h.q.ListCommandsForAgent(c.Request.Context(), c.Param("id")) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch commands"}) return } + commands := make([]AgentCommand, 0, len(rows)) + for _, row := range rows { + commands = append(commands, commandFromRow(row)) + } + c.JSON(http.StatusOK, gin.H{"commands": commands}) } // GetCommandStatus returns the status of a specific command func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { - agentID := c.Param("id") - commandID := c.Param("commandId") - - var command AgentCommand - if err := h.db.First(&command, "id = ? AND node_agent_id = ?", commandID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + command, err := h.q.GetCommandForAgent(c.Request.Context(), sqlcdb.GetCommandForAgentParams{ + ID: c.Param("commandId"), + NodeAgentID: c.Param("id"), + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Command not found"}) return } @@ -592,7 +862,7 @@ func (h *NodeAgentHandler) GetCommandStatus(c *gin.Context) { return } - c.JSON(http.StatusOK, gin.H{"command": command}) + c.JSON(http.StatusOK, gin.H{"command": commandFromRow(command)}) } // ContainerAction handles container lifecycle actions @@ -616,10 +886,15 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { return } + ctx := c.Request.Context() + // Verify container exists - var container ContainerInstance - if err := h.db.First(&container, "id = ? AND node_agent_id = ?", containerID, agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + containerRow, err := h.q.GetContainerForAgent(ctx, sqlcdb.GetContainerForAgentParams{ + ID: containerID, + NodeAgentID: agentID, + }) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Container not found"}) return } @@ -628,20 +903,18 @@ func (h *NodeAgentHandler) ContainerAction(c *gin.Context) { } // Create command for the action - command := AgentCommand{ + _, err = h.q.CreateCommand(ctx, sqlcdb.CreateCommandParams{ ID: uuid.New().String(), Type: fmt.Sprintf("%s_container", action), NodeAgentID: agentID, - ContainerID: &container.ID, - Payload: map[string]interface{}{ - "container_id": containerID, - }, - Status: "pending", - CreatedAt: time.Now(), - UpdatedAt: time.Now(), - } - - if err := h.db.Create(&command).Error; err != nil { + ContainerID: sql.NullString{String: containerRow.ID, Valid: true}, + Payload: rawJSON(map[string]interface{}{ + "container_id": containerID, + "container_name": containerRow.Name, + "docker_name": containerRow.Name, + }), + }) + if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create command"}) return } @@ -669,22 +942,23 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) { return } - var agent NodeAgent - if err := h.db.First(&agent, "id = ?", agentID).Error; err != nil { - if err == gorm.ErrRecordNotFound { + ctx := c.Request.Context() + agentRow, err := h.q.GetAgent(ctx, agentID) + if err != nil { + if err == sql.ErrNoRows { c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) return } c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent"}) return } + agent := agentFromRow(agentRow) from := time.Now().Add(-duration) - var records []AgentHeartbeatRecord - queryErr := h.db. - Where("node_agent_id = ? AND timestamp >= ?", agentID, from). - Order("timestamp ASC"). - Find(&records).Error + records, queryErr := h.q.ListAgentHeartbeatsSince(ctx, sqlcdb.ListAgentHeartbeatsSinceParams{ + NodeAgentID: agentID, + Timestamp: from, + }) if queryErr != nil && !isMissingTableError(queryErr) { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch agent metrics"}) return @@ -692,7 +966,11 @@ func (h *NodeAgentHandler) GetAgentMetrics(c *gin.Context) { metrics := make([]map[string]interface{}, 0, len(records)) for _, record := range records { - metrics = append(metrics, buildMetricPoint(record.Timestamp, record.Resources, record.SystemLoad, record.ContainerCount)) + var resources NodeResources + var load SystemLoad + _ = json.Unmarshal(record.Resources, &resources) + _ = json.Unmarshal(record.SystemLoad, &load) + metrics = append(metrics, buildMetricPoint(record.Timestamp, resources, load, int(record.ContainerCount))) } if len(metrics) == 0 { @@ -739,22 +1017,60 @@ func buildMetricPoint(ts time.Time, resources NodeResources, load SystemLoad, co } func isValidAgentAuthToken(token string) bool { - candidates := configuredAgentAuthTokens() - if len(candidates) == 0 { + token = strings.TrimSpace(token) + if token == "" { return false } + for _, candidate := range configuredAgentAuthTokens() { + if subtle.ConstantTimeCompare([]byte(token), []byte(candidate)) == 1 { + return true + } + } + return false +} + +// isValidAgentAuthTokenDB accepts env-configured shared tokens or a +// DB-issued onboarding token (matched by sha256 hash, never stored raw). +func (h *NodeAgentHandler) isValidAgentAuthToken(ctx context.Context, token string) bool { + if isValidAgentAuthToken(token) { + return true + } + token = strings.TrimSpace(token) if token == "" { return false } + sum := sha256.Sum256([]byte(token)) + hash := hex.EncodeToString(sum[:]) + if _, err := h.q.GetActiveAgentAuthTokenByHash(ctx, hash); err != nil { + return false + } + _ = h.q.TouchAgentAuthToken(ctx, hash) + return true +} - for _, candidate := range candidates { - if subtle.ConstantTimeCompare([]byte(token), []byte(candidate)) == 1 { - return true +func generateAgentAuthToken() (token string, hash string, err error) { + buf := make([]byte, 24) + if _, err = rand.Read(buf); err != nil { + return "", "", err + } + token = "cagt_" + hex.EncodeToString(buf) + sum := sha256.Sum256([]byte(token)) + return token, hex.EncodeToString(sum[:]), nil +} + +func agentAuthTokenFromRequest(c *gin.Context) string { + if token := strings.TrimSpace(c.GetHeader("X-Containr-Agent-Token")); token != "" { + return token + } + if authHeader := strings.TrimSpace(c.GetHeader("Authorization")); authHeader != "" { + const bearerPrefix = "Bearer " + if strings.HasPrefix(authHeader, bearerPrefix) { + return strings.TrimSpace(strings.TrimPrefix(authHeader, bearerPrefix)) } } - return false + return strings.TrimSpace(c.Query("auth_token")) } func configuredAgentAuthTokens() []string { @@ -806,16 +1122,116 @@ func maxInt(a, b int) int { return b } -// SetupRoutes registers the agent routes -func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { +// --- onboarding token management --- + +func agentAuthTokenJSON(t sqlcdb.AgentAuthToken) gin.H { + row := gin.H{ + "id": t.ID, + "label": t.Label, + "revoked": t.RevokedAt.Valid, + } + if t.CreatedAt.Valid { + row["created_at"] = t.CreatedAt.Time + } + if t.LastUsedAt.Valid { + row["last_used_at"] = t.LastUsedAt.Time + } + if t.RevokedAt.Valid { + row["revoked_at"] = t.RevokedAt.Time + } + return row +} + +// CreateAgentToken issues a new onboarding token. The raw token is returned +// once and never stored — only its sha256 hash persists. +func (h *NodeAgentHandler) CreateAgentToken(c *gin.Context) { + var req struct { + Label string `json:"label"` + } + _ = c.ShouldBindJSON(&req) + + token, hash, err := generateAgentAuthToken() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"}) + return + } + + row, err := h.q.CreateAgentAuthToken(c.Request.Context(), sqlcdb.CreateAgentAuthTokenParams{ + TokenHash: hash, + Label: strings.TrimSpace(req.Label), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"}) + return + } + + payload := agentAuthTokenJSON(row) + payload["token"] = token + c.JSON(http.StatusCreated, payload) +} + +// ListAgentTokens returns issued onboarding tokens (metadata only, never hashes). +func (h *NodeAgentHandler) ListAgentTokens(c *gin.Context) { + rows, err := h.q.ListAgentAuthTokens(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"}) + return + } + + tokens := make([]gin.H, 0, len(rows)) + for _, row := range rows { + tokens = append(tokens, agentAuthTokenJSON(row)) + } + c.JSON(http.StatusOK, gin.H{"tokens": tokens}) +} + +// RevokeAgentToken marks an onboarding token as revoked; agents presenting it +// are rejected from then on. +func (h *NodeAgentHandler) RevokeAgentToken(c *gin.Context) { + id, err := uuid.Parse(strings.TrimSpace(c.Param("id"))) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token id"}) + return + } + + row, err := h.q.RevokeAgentAuthToken(c.Request.Context(), id) + if err != nil { + status := http.StatusInternalServerError + if errors.Is(err, sql.ErrNoRows) { + status = http.StatusNotFound + } + c.JSON(status, gin.H{"error": "Token not found"}) + return + } + c.JSON(http.StatusOK, gin.H{"status": "revoked", "id": row.ID}) +} + +// SetupPublicRoutes registers token-authenticated agent ingestion routes. +func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) { agents := router.Group("/agents") { agents.POST("/register", h.RegisterAgent) + agents.POST("/heartbeat", h.SendHeartbeat) + agents.GET("/:id/commands", h.GetPendingCommandsForAgent) + agents.POST("/:id/commands/:commandId/result", h.CompleteCommand) + } +} + +// SetupRoutes registers authenticated dashboard/control agent routes. +func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) { + tokens := router.Group("/agent-tokens") + { + tokens.POST("", h.CreateAgentToken) + tokens.GET("", h.ListAgentTokens) + tokens.DELETE("/:id", h.RevokeAgentToken) + } + + agents := router.Group("/agents") + { agents.GET("", h.GetAgents) agents.GET("/:id", h.GetAgent) agents.PUT("/:id", h.UpdateAgent) agents.DELETE("/:id", h.DeleteAgent) - agents.POST("/heartbeat", h.SendHeartbeat) agents.GET("/:id/containers", h.GetAgentContainers) agents.POST("/:id/containers", h.CreateContainer) diff --git a/app/backend/internal/api/apwhy_handlers.go b/app/backend/internal/api/apwhy_handlers.go index 8ae564e..5ca696a 100644 --- a/app/backend/internal/api/apwhy_handlers.go +++ b/app/backend/internal/api/apwhy_handlers.go @@ -6,6 +6,7 @@ import ( "crypto/rand" "database/sql" "encoding/hex" + "errors" "fmt" "net/http" "strings" @@ -13,21 +14,14 @@ import ( "github.com/gin-gonic/gin" "github.com/go-playground/validator/v10" + "github.com/lib/pq" "golang.org/x/crypto/bcrypt" ) -// Constants for validation and limits +// Default quota limits applied when the request omits them. const ( - MaxServiceNameLength = 100 - MaxRoutePrefixLength = 200 - MaxUpstreamURLLength = 500 - MaxAPIKeyNameLength = 100 - MinAPIKeyLength = 20 - MaxAPIKeyLength = 100 - DefaultRPMLimit = 60 - DefaultMonthlyQuota = 1000 - MaxRPMLimit = 10000 - MaxMonthlyQuota = 10000000 + DefaultRPMLimit = 60 + DefaultMonthlyQuota = 1000 ) // Validator instance for request validation @@ -75,15 +69,6 @@ type APIKeyRequest struct { MonthlyQuota *int `json:"monthlyQuota,omitempty" validate:"omitempty,min=1,max=10000000"` } -// generateServiceID generates a cryptographically secure service ID -func generateServiceID() (string, error) { - bytes := make([]byte, 16) - if _, err := rand.Read(bytes); err != nil { - return "", fmt.Errorf("failed to generate random bytes: %w", err) - } - return "svc_" + hex.EncodeToString(bytes), nil -} - // generateAPIKey generates a cryptographically secure API key func generateAPIKey() (string, error) { bytes := make([]byte, 32) @@ -162,6 +147,84 @@ func validateRequest(c *gin.Context, req interface{}) error { return nil } +// isUniqueViolation reports whether err is a Postgres unique-constraint violation. +func isUniqueViolation(err error) bool { + var pqErr *pq.Error + return errors.As(err, &pqErr) && pqErr.Code == "23505" +} + +// handleAPwhyServiceValidate probes the upstream health path and records the +// result on the service row. Ported from the standalone APwhy server. +func handleAPwhyServiceValidate(c *gin.Context) { + serviceID := c.Param("id") + db := c.MustGet("db").(*database.DB) + ctx := c.Request.Context() + + var upstreamURL, healthPath string + var authHeader, authValue sql.NullString + var timeoutMs sql.NullInt64 + err := db.QueryRowContext(ctx, ` + SELECT upstream_url, health_path, upstream_auth_header, upstream_auth_value, request_timeout_ms + FROM api_services WHERE id = $1 + `, serviceID).Scan(&upstreamURL, &healthPath, &authHeader, &authValue, &timeoutMs) + if err != nil { + sendErrorResponse(c, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found", nil) + return + } + + timeout := 8000 + if timeoutMs.Valid && timeoutMs.Int64 > 0 { + timeout = int(timeoutMs.Int64) + } + target := strings.TrimRight(upstreamURL, "/") + "/" + strings.TrimLeft(healthPath, "/") + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if authHeader.Valid && authHeader.String != "" { + req.Header.Set(authHeader.String, authValue.String) + } + + client := &http.Client{Timeout: time.Duration(timeout) * time.Millisecond} + res, probeErr := client.Do(req) + + status := http.StatusBadGateway + message := "" + ok := false + if probeErr != nil { + message = probeErr.Error() + } else { + defer res.Body.Close() + status = res.StatusCode + message = fmt.Sprintf("HTTP %d", res.StatusCode) + ok = res.StatusCode >= 200 && res.StatusCode < 300 + } + + validationStatus := "failed" + if ok { + validationStatus = "healthy" + } + + _, _ = db.ExecContext(ctx, ` + UPDATE api_services + SET last_validation_at = NOW(), last_validation_status = $1, last_validation_message = $2, updated_at = NOW() + WHERE id = $3 + `, validationStatus, message, serviceID) + + if !ok { + _, _ = db.ExecContext(ctx, ` + INSERT INTO incident_events (service_id, code, message, severity, http_status, occurred_at) + VALUES ($1, 'SERVICE_VALIDATION_FAILED', $2, 'medium', $3, NOW()) + `, serviceID, message, status) + } + + sendSuccessResponse(c, http.StatusOK, map[string]interface{}{ + "validation": map[string]interface{}{ + "ok": ok, + "status": status, + "message": message, + }, + }) +} + // handleAPwhyServicesList returns a list of API services func handleAPwhyServicesList(c *gin.Context) { db := c.MustGet("db").(*database.DB) @@ -170,7 +233,7 @@ func handleAPwhyServicesList(c *gin.Context) { // Query services from database rows, err := db.QueryContext(ctx, ` SELECT id, name, slug, upstream_url, route_prefix, enabled, created_at, updated_at - FROM api_services + FROM api_services ORDER BY created_at DESC `) if err != nil { @@ -180,10 +243,10 @@ func handleAPwhyServicesList(c *gin.Context) { } defer rows.Close() - var services []map[string]interface{} + services := make([]map[string]interface{}, 0) for rows.Next() { var id, name, slug, upstreamURL, routePrefix, createdAt, updatedAt string - var enabled bool + var enabled int err := rows.Scan(&id, &name, &slug, &upstreamURL, &routePrefix, &enabled, &createdAt, &updatedAt) if err != nil { continue // Skip malformed rows @@ -195,7 +258,7 @@ func handleAPwhyServicesList(c *gin.Context) { "slug": slug, "upstreamUrl": upstreamURL, "routePrefix": routePrefix, - "enabled": enabled, + "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, }) @@ -220,24 +283,8 @@ func handleAPwhyServicesCreate(c *gin.Context) { db := c.MustGet("db").(*database.DB) ctx := context.Background() - // Generate slug and ID - id, err := generateServiceID() - if err != nil { - sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", - "Failed to generate service ID", err.Error()) - return - } - slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")) - // Insert service into database - query := ` - INSERT INTO api_services ( - id, name, slug, upstream_url, route_prefix, health_path, - enabled, rpm_limit, monthly_quota, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, '/health', true, $6, $7, NOW(), NOW()) - ` - var rpmLimit, monthlyQuota int if req.RPMLimit != nil { rpmLimit = *req.RPMLimit @@ -251,10 +298,22 @@ func handleAPwhyServicesCreate(c *gin.Context) { monthlyQuota = DefaultMonthlyQuota } - _, err = db.ExecContext(ctx, query, id, req.Name, slug, req.UpstreamURL, - req.RoutePrefix, rpmLimit, monthlyQuota) + // id defaults to gen_random_uuid(); enabled defaults to 1. + var id string + err := db.QueryRowContext(ctx, ` + INSERT INTO api_services ( + name, slug, upstream_url, route_prefix, health_path, + rpm_limit, monthly_quota, created_at, updated_at + ) VALUES ($1, $2, $3, $4, '/health', $5, $6, NOW(), NOW()) + RETURNING id + `, req.Name, slug, req.UpstreamURL, req.RoutePrefix, rpmLimit, monthlyQuota).Scan(&id) if err != nil { + if isUniqueViolation(err) { + sendErrorResponse(c, http.StatusConflict, "CONFLICT", + "A service with this slug or route prefix already exists", err.Error()) + return + } sendErrorResponse(c, http.StatusInternalServerError, "DATABASE_ERROR", "Failed to create service", err.Error()) return @@ -297,9 +356,13 @@ func handleAPwhyServicesPatch(c *gin.Context) { db := c.MustGet("db").(*database.DB) if input.Enabled != nil { + enabled := 0 + if *input.Enabled { + enabled = 1 + } _, err := db.ExecContext(context.Background(), "UPDATE api_services SET enabled = $1, updated_at = NOW() WHERE id = $2", - *input.Enabled, serviceID) + enabled, serviceID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -334,10 +397,10 @@ func handleAPwhyKeysList(c *gin.Context) { } defer rows.Close() - var keys []map[string]interface{} + keys := make([]map[string]interface{}, 0) for rows.Next() { var id, name, keyPrefix, plan, createdAt, updatedAt string - var enabled bool + var enabled int var rpmLimit, monthlyQuota sql.NullInt64 err := rows.Scan(&id, &name, &keyPrefix, &plan, &enabled, &rpmLimit, &monthlyQuota, &createdAt, &updatedAt) @@ -350,7 +413,7 @@ func handleAPwhyKeysList(c *gin.Context) { "name": name, "keyPrefix": keyPrefix, "plan": plan, - "enabled": enabled, + "enabled": enabled != 0, "createdAt": createdAt, "updatedAt": updatedAt, } @@ -407,36 +470,25 @@ func handleAPwhyKeysCreate(c *gin.Context) { return } - // Generate ID and prefix - id, err := generateServiceID() - if err != nil { - sendErrorResponse(c, http.StatusInternalServerError, "GENERATION_ERROR", - "Failed to generate key ID", err.Error()) - return - } - keyPrefix := apiKey[:8] // Set default limits based on plan - var rpmLimit, monthlyQuota int - switch input.Plan { - case "free": - rpmLimit, monthlyQuota = 60, 1000 - case "pro": - rpmLimit, monthlyQuota = 600, 50000 - case "business": - rpmLimit, monthlyQuota = 3000, 300000 - default: - rpmLimit, monthlyQuota = 60, 1000 + plan, rpmLimit, monthlyQuota, err := validateAPIKeyPlan(input.Plan) + if err != nil { + sendErrorResponse(c, http.StatusBadRequest, "VALIDATION_ERROR", + "Invalid plan", err.Error()) + return } - // Insert key into database - _, err = db.ExecContext(context.Background(), ` + // id defaults to gen_random_uuid(); enabled defaults to 1. + var id string + err = db.QueryRowContext(context.Background(), ` INSERT INTO api_keys ( - id, name, key_hash, key_prefix, plan, allowed_service_ids, - enabled, rpm_limit, monthly_quota, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, '[]', true, $6, $7, NOW(), NOW()) - `, id, input.Name, keyHash, keyPrefix, input.Plan, rpmLimit, monthlyQuota) + name, key_hash, key_prefix, plan, allowed_service_ids, + rpm_limit, monthly_quota, created_at, updated_at + ) VALUES ($1, $2, $3, $4, '[]', $5, $6, NOW(), NOW()) + RETURNING id + `, input.Name, keyHash, keyPrefix, plan, rpmLimit, monthlyQuota).Scan(&id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ @@ -451,7 +503,7 @@ func handleAPwhyKeysCreate(c *gin.Context) { "data": gin.H{ "id": id, "name": input.Name, - "plan": input.Plan, + "plan": plan, "key": apiKey, // Only return the actual key once "keyPrefix": keyPrefix, "enabled": true, @@ -480,9 +532,13 @@ func handleAPwhyKeysPatch(c *gin.Context) { db := c.MustGet("db").(*database.DB) if input.Enabled != nil { + enabled := 0 + if *input.Enabled { + enabled = 1 + } _, err := db.ExecContext(context.Background(), "UPDATE api_keys SET enabled = $1, updated_at = NOW() WHERE id = $2", - *input.Enabled, keyID) + enabled, keyID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{ diff --git a/app/backend/internal/api/audit.go b/app/backend/internal/api/audit.go index 9d7d2fc..64a43b2 100644 --- a/app/backend/internal/api/audit.go +++ b/app/backend/internal/api/audit.go @@ -93,10 +93,11 @@ func SetAuditDB(db *database.DB) { func handleGetAuditLogs(c *gin.Context) { db := c.MustGet("db").(*database.DB) - userID := c.MustGet("user_id").(string) - resource := strings.TrimSpace(c.Query("resource")) action := strings.TrimSpace(c.Query("action")) + actor := strings.TrimSpace(c.Query("actor")) + userIDFilter := strings.TrimSpace(c.Query("user_id")) + since := strings.TrimSpace(c.Query("since")) page := parsePositiveInt(c.DefaultQuery("page", "1"), 1) limit := parsePositiveInt(c.DefaultQuery("limit", "50"), 50) if limit > 500 { @@ -104,35 +105,57 @@ func handleGetAuditLogs(c *gin.Context) { } offset := (page - 1) * limit - conditions := []string{"user_id::text = $1"} - args := []interface{}{userID} - nextArg := 2 + conditions := []string{"TRUE"} + args := []interface{}{} + nextArg := 1 + if userIDFilter != "" { + conditions = append(conditions, fmt.Sprintf("a.user_id = $%d::uuid", nextArg)) + args = append(args, userIDFilter) + nextArg++ + } + if actor != "" { + conditions = append(conditions, fmt.Sprintf("u.email ILIKE $%d", nextArg)) + args = append(args, "%"+actor+"%") + nextArg++ + } if resource != "" { - conditions = append(conditions, fmt.Sprintf("resource = $%d", nextArg)) + conditions = append(conditions, fmt.Sprintf("a.resource = $%d", nextArg)) args = append(args, resource) nextArg++ } if action != "" { - conditions = append(conditions, fmt.Sprintf("action = $%d", nextArg)) + conditions = append(conditions, fmt.Sprintf("a.action = $%d", nextArg)) args = append(args, action) nextArg++ } + if since != "" { + sinceTime, err := time.Parse(time.RFC3339, since) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid since timestamp, expected RFC3339"}) + return + } + conditions = append(conditions, fmt.Sprintf("a.created_at >= $%d", nextArg)) + args = append(args, sinceTime) + nextArg++ + } whereClause := strings.Join(conditions, " AND ") query := fmt.Sprintf(`SELECT - id, - COALESCE(user_id::text, ''), - resource, - COALESCE(resource_id::text, ''), - action, - COALESCE(details::text, '{}'), - COALESCE(ip_address::text, ''), - COALESCE(user_agent, ''), - created_at - FROM audit_logs + a.id, + COALESCE(a.user_id::text, ''), + COALESCE(u.email, ''), + a.resource, + COALESCE(a.resource_id::text, ''), + a.action, + COALESCE(a.details::text, '{}'), + COALESCE(a.ip_address::text, ''), + COALESCE(a.user_agent, ''), + a.created_at + FROM audit_logs a + LEFT JOIN users u ON u.id = a.user_id WHERE %s - ORDER BY created_at DESC + ORDER BY a.created_at DESC LIMIT $%d OFFSET $%d`, whereClause, nextArg, nextArg+1) args = append(args, limit, offset) @@ -146,7 +169,7 @@ func handleGetAuditLogs(c *gin.Context) { var logs []AuditLog for rows.Next() { var log AuditLog - err := rows.Scan(&log.ID, &log.UserID, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) + err := rows.Scan(&log.ID, &log.UserID, &log.UserEmail, &log.Resource, &log.ResourceID, &log.Action, &log.Details, &log.IPAddress, &log.UserAgent, &log.CreatedAt) if err != nil { continue } diff --git a/app/backend/internal/api/auth.go b/app/backend/internal/api/auth.go index 3217162..8ee42d2 100644 --- a/app/backend/internal/api/auth.go +++ b/app/backend/internal/api/auth.go @@ -1,13 +1,21 @@ package api import ( + "bytes" "containr/internal/database" "database/sql" + "encoding/json" + "errors" + "log" "net/http" + "net/url" + "os" + "strings" "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "golang.org/x/crypto/bcrypt" ) @@ -18,10 +26,16 @@ type LoginRequest struct { type RegisterRequest struct { Email string `json:"email" binding:"required,email"` - Password string `json:"password" binding:"required,min=6"` + Password string `json:"password" binding:"required,min=8,max=128"` Name string `json:"name" binding:"required,min=2"` } +type ManualUserCreateRequest struct { + Email string `json:"email" binding:"required,email"` + Password string `json:"password" binding:"required,min=8,max=128"` + Name string `json:"name" binding:"required,min=2,max=255"` +} + type AuthResponse struct { Token string `json:"token"` User interface{} `json:"user"` @@ -55,17 +69,28 @@ func handleLogin(c *gin.Context) { `, req.Email).Scan(&user.ID, &user.Email, &hashedPassword, &user.Name, &user.AvatarURL, &user.CreatedAt) if err == sql.ErrNoRows { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) - return + authUser, ok := verifyBetterAuthCredentials(c, req.Email, req.Password) + if !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + + user, err = createLocalUserFromBetterAuth(db, authUser) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create local user"}) + return + } } else if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return - } - - // Check password - if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) - return + } else { + // Check password + if err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(req.Password)); err != nil { + if _, ok := verifyBetterAuthCredentials(c, req.Email, req.Password); !ok { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid credentials"}) + return + } + } } // Generate JWT token @@ -81,6 +106,154 @@ func handleLogin(c *gin.Context) { }) } +func handleAuthBootstrap(c *gin.Context) { + db := c.MustGet("db").(*database.DB) + + count, err := countLocalUsers(db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to inspect auth bootstrap state"}) + return + } + + mode := "login" + if count == 0 { + mode = "register" + } + + c.JSON(http.StatusOK, gin.H{ + "has_users": count > 0, + "user_count": count, + "mode": mode, + }) +} + +type betterAuthLoginUser struct { + ID string `json:"id"` + Email string `json:"email"` + Name string `json:"name"` + Image string `json:"image"` +} + +type betterAuthLoginResponse struct { + User betterAuthLoginUser `json:"user"` +} + +func verifyBetterAuthCredentials(c *gin.Context, email string, password string) (betterAuthLoginUser, bool) { + endpoint, err := betterAuthSignInURL() + if err != nil { + log.Printf("Better Auth fallback disabled: %v", err) + return betterAuthLoginUser{}, false + } + + body, err := json.Marshal(gin.H{ + "email": strings.TrimSpace(email), + "password": password, + }) + if err != nil { + return betterAuthLoginUser{}, false + } + + ctx := c.Request.Context() + request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return betterAuthLoginUser{}, false + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + + client := &http.Client{Timeout: 5 * time.Second} + response, err := client.Do(request) + if err != nil { + log.Printf("Better Auth fallback failed: %v", err) + return betterAuthLoginUser{}, false + } + defer response.Body.Close() + + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return betterAuthLoginUser{}, false + } + + var payload betterAuthLoginResponse + if err := json.NewDecoder(response.Body).Decode(&payload); err != nil { + log.Printf("Better Auth fallback response decode failed: %v", err) + return betterAuthLoginUser{}, false + } + + payload.User.Email = strings.ToLower(strings.TrimSpace(firstNonEmpty(payload.User.Email, email))) + payload.User.Name = strings.TrimSpace(payload.User.Name) + if payload.User.Name == "" { + payload.User.Name = "Containr User" + } + + if payload.User.Email == "" { + return betterAuthLoginUser{}, false + } + + return payload.User, true +} + +func betterAuthSignInURL() (string, error) { + rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL")) + if rawTarget == "" { + rawTarget = "http://127.0.0.1:3001" + } + + target, err := parseAuthProxyTarget(rawTarget) + if err != nil { + return "", err + } + + return target.ResolveReference(&url.URL{Path: "/api/auth/sign-in/email"}).String(), nil +} + +func betterAuthSignUpURL() (string, error) { + rawTarget := strings.TrimSpace(os.Getenv("BETTER_AUTH_PROXY_URL")) + if rawTarget == "" { + rawTarget = "http://127.0.0.1:3001" + } + + target, err := parseAuthProxyTarget(rawTarget) + if err != nil { + return "", err + } + + return target.ResolveReference(&url.URL{Path: "/api/auth/sign-up/email"}).String(), nil +} + +func createLocalUserFromBetterAuth(db *database.DB, authUser betterAuthLoginUser) (User, error) { + email := strings.ToLower(strings.TrimSpace(authUser.Email)) + if email == "" { + return User{}, errors.New("email is required") + } + + name := strings.TrimSpace(authUser.Name) + if name == "" { + name = "Containr User" + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(uuid.NewString()), bcrypt.DefaultCost) + if err != nil { + return User{}, err + } + + var user User + err = db.QueryRow(` + INSERT INTO users (email, password_hash, name, avatar_url) + VALUES ($1, $2, $3, NULLIF($4, '')) + ON CONFLICT (email) DO UPDATE + SET name = EXCLUDED.name, + avatar_url = COALESCE(EXCLUDED.avatar_url, users.avatar_url), + updated_at = NOW() + RETURNING id, email, name, COALESCE(avatar_url, ''), created_at + `, email, string(hashedPassword), name, strings.TrimSpace(authUser.Image)). + Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + if err != nil { + return User{}, err + } + + return user, nil +} + func handleRegister(c *gin.Context) { var req RegisterRequest if err := c.ShouldBindJSON(&req); err != nil { @@ -91,9 +264,18 @@ func handleRegister(c *gin.Context) { db := c.MustGet("db").(*database.DB) jwtSecret := c.MustGet("jwt_secret").(string) - // Check if user already exists - var count int - err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&count) + count, err := countLocalUsers(db) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + return + } + + if count > 0 { + c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) + return + } + + err = db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", strings.ToLower(strings.TrimSpace(req.Email))).Scan(&count) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return @@ -137,6 +319,140 @@ func handleRegister(c *gin.Context) { }) } +func handleCreateUser(c *gin.Context) { + var req ManualUserCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + db := c.MustGet("db").(*database.DB) + req.Email = strings.ToLower(strings.TrimSpace(req.Email)) + req.Name = strings.TrimSpace(req.Name) + if req.Email == "" || req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "Email and name are required"}) + return + } + + var existing int + if err := db.QueryRow("SELECT COUNT(*) FROM users WHERE email = $1", req.Email).Scan(&existing); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + return + } + if existing > 0 { + c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) + return + } + + if err := createBetterAuthUser(c, req.Name, req.Email, req.Password); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, errBetterAuthUserExists) { + status = http.StatusConflict + } + c.JSON(status, gin.H{"error": err.Error()}) + return + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to hash password"}) + return + } + + var user User + err = db.QueryRow(` + INSERT INTO users (email, password_hash, name) + VALUES ($1, $2, $3) + RETURNING id, email, name, COALESCE(avatar_url, ''), created_at + `, req.Email, string(hashedPassword), req.Name).Scan(&user.ID, &user.Email, &user.Name, &user.AvatarURL, &user.CreatedAt) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "User already exists"}) + return + } + + c.JSON(http.StatusCreated, gin.H{"user": user}) +} + +var errBetterAuthUserExists = errors.New("User already exists") + +func createBetterAuthUser(c *gin.Context, name string, email string, password string) error { + endpoint, err := betterAuthSignUpURL() + if err != nil { + return err + } + + body, err := json.Marshal(gin.H{ + "name": name, + "email": email, + "password": password, + }) + if err != nil { + return err + } + + request, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Accept", "application/json") + if token := strings.TrimSpace(os.Getenv("BETTER_AUTH_INTERNAL_TOKEN")); token != "" { + request.Header.Set("X-Containr-Auth-Internal", token) + } + + response, err := (&http.Client{Timeout: 5 * time.Second}).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + if response.StatusCode >= http.StatusOK && response.StatusCode < http.StatusMultipleChoices { + return nil + } + + var payload map[string]interface{} + _ = json.NewDecoder(response.Body).Decode(&payload) + message := "Failed to create auth user" + if rawMessage, ok := payload["message"].(string); ok && strings.TrimSpace(rawMessage) != "" { + message = rawMessage + } else if rawError, ok := payload["error"].(string); ok && strings.TrimSpace(rawError) != "" { + message = rawError + } + + if response.StatusCode == http.StatusConflict || strings.Contains(strings.ToLower(message), "already") { + return errBetterAuthUserExists + } + + return errors.New(message) +} + +func countLocalUsers(db *database.DB) (int, error) { + var count int + if err := db.QueryRow("SELECT COUNT(*) FROM users").Scan(&count); err != nil { + return 0, err + } + // Better Auth writes to auth_users; a row there only mirrors into users on + // the first authenticated call. Count it too or a second public sign-up + // slips through the bootstrap window. UNION by email avoids double-counting + // mirrored users. + var authTable *string + if err := db.QueryRow("SELECT to_regclass('auth_users')").Scan(&authTable); err != nil { + return 0, err + } + if authTable != nil && *authTable != "" { + if err := db.QueryRow(` + SELECT COUNT(*) FROM ( + SELECT email FROM users + UNION + SELECT email FROM auth_users + ) u + `).Scan(&count); err != nil { + return 0, err + } + } + return count, nil +} + func handleGetProfile(c *gin.Context) { userID := c.MustGet("user_id").(string) db := c.MustGet("db").(*database.DB) diff --git a/app/backend/internal/api/auth_proxy.go b/app/backend/internal/api/auth_proxy.go index be53875..ddced07 100644 --- a/app/backend/internal/api/auth_proxy.go +++ b/app/backend/internal/api/auth_proxy.go @@ -1,6 +1,7 @@ package api import ( + "containr/internal/database" "log" "net/http" "net/http/httputil" @@ -12,7 +13,7 @@ import ( "github.com/gin-gonic/gin" ) -func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) { +func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config, db *database.DB) { targetURL, err := parseAuthProxyTarget(cfg.BetterAuthProxyURL) if err != nil { log.Printf("Warning: auth proxy disabled: %v", err) @@ -28,6 +29,12 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) { } handler := func(c *gin.Context) { + if shouldBlockPublicBetterAuthSignup(c, cfg, db) { + c.JSON(http.StatusForbidden, gin.H{"error": "Public registration is disabled after bootstrap"}) + c.Abort() + return + } + proxy.ServeHTTP(c.Writer, c.Request) c.Abort() } @@ -36,6 +43,33 @@ func setupAuthProxyRoutes(router *gin.Engine, cfg *config.Config) { router.Any("/api/auth/*proxyPath", handler) } +func shouldBlockPublicBetterAuthSignup(c *gin.Context, cfg *config.Config, db *database.DB) bool { + if db == nil || db.DB == nil { + return false + } + + if !strings.EqualFold(c.Request.Method, http.MethodPost) { + return false + } + + if !strings.HasSuffix(c.Request.URL.Path, "/sign-up/email") { + return false + } + + internalToken := strings.TrimSpace(cfg.BetterAuthInternalToken) + if internalToken != "" && c.GetHeader("X-Containr-Auth-Internal") == internalToken { + return false + } + + count, err := countLocalUsers(db) + if err != nil { + log.Printf("Failed to inspect signup bootstrap state: %v", err) + return true + } + + return count > 0 +} + func parseAuthProxyTarget(rawTarget string) (*url.URL, error) { trimmed := strings.TrimSpace(rawTarget) if trimmed == "" { diff --git a/app/backend/internal/api/cron.go b/app/backend/internal/api/cron.go index 43afe45..7f5f7f6 100644 --- a/app/backend/internal/api/cron.go +++ b/app/backend/internal/api/cron.go @@ -2,12 +2,19 @@ package api import ( "containr/internal/database" - "encoding/json" + "containr/internal/database/sqlcdb" + "containr/internal/docker" + "context" + "database/sql" + "fmt" + "log" "net/http" + "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/robfig/cron/v3" ) type CronJob struct { @@ -62,6 +69,7 @@ func handleGetCronJobs(c *gin.Context) { db := c.MustGet("db").(*database.DB) userID := c.MustGet("user_id").(string) projectID := c.Query("project_id") + serviceID := c.Query("service_id") query := `SELECT cj.id, cj.project_id, cj.service_id, cj.name, cj.schedule, cj.timezone, cj.enabled, cj.last_run_at, cj.next_run_at, cj.last_status, cj.last_output, @@ -72,8 +80,12 @@ func handleGetCronJobs(c *gin.Context) { args := []interface{}{userID} if projectID != "" { - query += " AND cj.project_id = $2" args = append(args, projectID) + query += fmt.Sprintf(" AND cj.project_id = $%d", len(args)) + } + if serviceID != "" { + args = append(args, serviceID) + query += fmt.Sprintf(" AND cj.service_id = $%d", len(args)) } query += " ORDER BY cj.created_at DESC" @@ -130,7 +142,11 @@ func handleCreateCronJob(c *gin.Context) { req.Retention = 30 } - nextRun := calculateNextRun(req.Schedule, req.Timezone) + nextRun, err := calculateNextRun(req.Schedule, req.Timezone) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid schedule: %v", err)}) + return + } job := CronJob{ ID: uuid.New().String(), @@ -227,8 +243,13 @@ func handleUpdateCronJob(c *gin.Context) { updates["name"] = req.Name } if req.Schedule != "" { + next, err := calculateNextRun(req.Schedule, req.Timezone) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid schedule: %v", err)}) + return + } updates["schedule"] = req.Schedule - updates["next_run_at"] = calculateNextRun(req.Schedule, "UTC") + updates["next_run_at"] = next } if req.Command != "" { updates["command"] = req.Command @@ -352,11 +373,12 @@ func handleTriggerCronJob(c *gin.Context) { var job CronJob var ownerCheck string err := db.QueryRow( - `SELECT cj.command, p.owner_id FROM cron_jobs cj + `SELECT cj.service_id, cj.command, cj.schedule, cj.timezone, cj.retention, p.owner_id + FROM cron_jobs cj JOIN projects p ON cj.project_id = p.id WHERE cj.id = $1`, jobID, - ).Scan(&job.Command, &ownerCheck) + ).Scan(&job.ServiceID, &job.Command, &job.Schedule, &job.Timezone, &job.Retention, &ownerCheck) if err != nil || ownerCheck != userID { c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) @@ -372,7 +394,9 @@ func handleTriggerCronJob(c *gin.Context) { execID, jobID, now, "running", ) - go executeCronJob(jobID, execID, job.Command) + dockerClient, _ := c.Get("docker_client") + dockerCli, _ := dockerClient.(*docker.Client) + go executeCronJob(db, dockerCli, jobID, job.ServiceID, execID, job.Command, job.Schedule, job.Timezone, job.Retention) LogAudit(userID, "cron_job", jobID, "trigger", map[string]interface{}{ "execution_id": execID, @@ -384,33 +408,186 @@ func handleTriggerCronJob(c *gin.Context) { }) } -func calculateNextRun(schedule, timezone string) *time.Time { - now := time.Now() - next := now.Add(1 * time.Hour) - return &next +// cronScheduleParser parses standard 5-field cron expressions plus +// descriptors (@daily etc.) and CRON_TZ= prefixes. +var cronScheduleParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor) + +func calculateNextRun(schedule, timezone string) (*time.Time, error) { + expr := strings.TrimSpace(schedule) + if tz := strings.TrimSpace(timezone); tz != "" && tz != "UTC" { + expr = "CRON_TZ=" + tz + " " + expr + } + parsed, err := cronScheduleParser.Parse(expr) + if err != nil { + return nil, err + } + next := parsed.Next(time.Now()) + return &next, nil } -func executeCronJob(jobID, execID, command string) { - db := auditDB - if db == nil { +// StartCronScheduler ticks every minute and executes enabled cron jobs whose +// next_run_at is due. Each run is recorded in cron_executions and output is +// captured from docker exec inside the service's container. +func StartCronScheduler(ctx context.Context, db *database.DB, dockerClient *docker.Client) { + go func() { + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + runDueCronJobs(db, dockerClient) + runDueDatabaseBackups(db, dockerClient) + } + } + }() +} + +func runDueCronJobs(db *database.DB, dockerClient *docker.Client) { + rows, err := db.Query( + `SELECT id, project_id, service_id, name, schedule, command, timezone, enabled, retention + FROM cron_jobs + WHERE enabled = TRUE AND next_run_at IS NOT NULL AND next_run_at <= NOW()`) + if err != nil { + log.Printf("cron scheduler: failed to list due jobs: %v", err) return } + defer rows.Close() - time.Sleep(2 * time.Second) + type dueJob struct { + CronJob + Command string + } + var jobs []dueJob + for rows.Next() { + var j dueJob + if err := rows.Scan(&j.ID, &j.ProjectID, &j.ServiceID, &j.Name, &j.Schedule, + &j.Command, &j.Timezone, &j.Enabled, &j.Retention); err != nil { + continue + } + jobs = append(jobs, j) + } + + for _, j := range jobs { + execID := uuid.New().String() + if _, err := db.Exec( + `INSERT INTO cron_executions (id, cron_job_id, started_at, status) VALUES ($1, $2, $3, $4)`, + execID, j.ID, time.Now(), "running"); err != nil { + log.Printf("cron scheduler: failed to record execution for %s: %v", j.ID, err) + continue + } + go executeCronJob(db, dockerClient, j.ID, j.ServiceID, execID, j.Command, j.Schedule, j.Timezone, j.Retention) + } +} + +// runDueDatabaseBackups snapshots managed databases whose backup_schedule is +// due, reusing the same archive pipeline as manual backups. +func runDueDatabaseBackups(db *database.DB, dockerClient *docker.Client) { + if dockerClient == nil { + return + } + q := sqlcdb.New(db.DB) + due, err := q.ListDueDatabaseBackups(context.Background()) + if err != nil { + log.Printf("cron scheduler: failed to list due database backups: %v", err) + return + } + handler := NewDatabaseHandler(db.DB, dockerClient) + for _, row := range due { + schedule := row.BackupSchedule.String + next, err := calculateNextRun(schedule, "UTC") + if err != nil { + log.Printf("cron scheduler: invalid backup schedule %q on %s: %v", schedule, row.ID, err) + continue + } + now := time.Now() + if err := q.SetDatabaseNextBackupAt(context.Background(), sqlcdb.SetDatabaseNextBackupAtParams{ + NextBackupAt: sql.NullTime{Time: *next, Valid: true}, + ID: row.ID, + }); err != nil { + log.Printf("cron scheduler: failed to advance backup schedule on %s: %v", row.ID, err) + continue + } + backupID := generateDatabaseBackupID(row.ID) + archivePath := sanitizeBackupArchivePath(managedDatabaseBackupArchivePath(backupID)) + if err := q.CreateDatabaseBackup(context.Background(), sqlcdb.CreateDatabaseBackupParams{ + ID: backupID, + DatabaseID: row.ID, + Size: "pending", + Status: "in_progress", + BackupPath: sql.NullString{String: archivePath, Valid: true}, + CreatedAt: sql.NullTime{Time: now, Valid: true}, + }); err != nil { + log.Printf("cron scheduler: failed to record backup for %s: %v", row.ID, err) + continue + } + go handler.createBackupProcess(row.ID, backupID, archivePath) + } +} + +// findServiceContainer returns the first running container whose name matches +// the deployment naming convention containr--*. +func findServiceContainer(ctx context.Context, dockerClient *docker.Client, serviceID string) (string, error) { + containers, err := dockerClient.ListContainers(ctx, false) + if err != nil { + return "", err + } + prefix := "containr-" + serviceID + "-" + for _, ctr := range containers { + for _, name := range ctr.Names { + if strings.HasPrefix(strings.TrimPrefix(name, "/"), prefix) { + return ctr.ID, nil + } + } + } + return "", fmt.Errorf("no running container for service %s", serviceID) +} + +func executeCronJob(db *database.DB, dockerClient *docker.Client, jobID, serviceID, execID, command, schedule, timezone string, retention int) { + status := "success" + var output, errText string + + if dockerClient == nil { + status, errText = "failed", "docker unavailable" + } else { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + containerID, findErr := findServiceContainer(ctx, dockerClient, serviceID) + if findErr != nil { + status, errText = "failed", findErr.Error() + } else { + out, exitCode, runErr := dockerClient.ExecRun(ctx, containerID, []string{"sh", "-c", command}) + output = out + if len(output) > 64*1024 { + output = output[:64*1024] + } + if runErr != nil { + status, errText = "failed", runErr.Error() + } else if exitCode != 0 { + status, errText = "failed", fmt.Sprintf("exit code %d", exitCode) + } + } + } now := time.Now() db.Exec( - `UPDATE cron_executions SET finished_at = $1, status = $2, output = $3 WHERE id = $4`, - now, "success", "Job completed successfully", execID, + `UPDATE cron_executions SET finished_at = $1, status = $2, output = $3, error = $4 WHERE id = $5`, + now, status, output, errText, execID, ) + next, _ := calculateNextRun(schedule, timezone) db.Exec( - `UPDATE cron_jobs SET last_run_at = $1, last_status = $2, next_run_at = $3 WHERE id = $4`, - now, "success", time.Now().Add(1*time.Hour), jobID, + `UPDATE cron_jobs SET last_run_at = $1, last_status = $2, last_output = $3, next_run_at = $4 WHERE id = $5`, + now, status, output, next, jobID, ) -} -func init() { - cronJobsData, _ := json.Marshal([]CronJob{}) - _ = cronJobsData + if retention > 0 { + db.Exec( + `DELETE FROM cron_executions WHERE cron_job_id = $1 AND id NOT IN ( + SELECT id FROM cron_executions WHERE cron_job_id = $1 + ORDER BY started_at DESC LIMIT $2)`, + jobID, retention, + ) + } } diff --git a/app/backend/internal/api/databases.go b/app/backend/internal/api/databases.go index 1c3bfd0..1f0ea0b 100644 --- a/app/backend/internal/api/databases.go +++ b/app/backend/internal/api/databases.go @@ -1,6 +1,7 @@ package api import ( + "archive/tar" "containr/internal/database/sqlcdb" "containr/internal/docker" "context" @@ -11,6 +12,7 @@ import ( "errors" "fmt" "io" + "log" "math" "net" "net/http" @@ -38,19 +40,21 @@ const ( // DatabaseService represents a managed database service type DatabaseService struct { - ID string `json:"id" db:"id"` - Name string `json:"name" db:"name"` - Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly - Status string `json:"status" db:"status"` // running, stopped, building, error - Version string `json:"version" db:"version"` - Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business - Region string `json:"region" db:"region"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - ConnectionURL string `json:"connection_url"` - Metrics DatabaseMetrics `json:"metrics"` - Backups DatabaseBackupConfig `json:"backups"` - Settings DatabaseSettings `json:"settings"` + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Type string `json:"type" db:"type"` // postgresql, redis, mysql, mariadb, mongodb, clickhouse, dragonfly + Status string `json:"status" db:"status"` // running, stopped, building, error + Version string `json:"version" db:"version"` + Plan string `json:"plan" db:"plan"` // hobby, starter, standard, business + Region string `json:"region" db:"region"` + BackupSchedule string `json:"backup_schedule,omitempty"` + NextBackupAt *time.Time `json:"next_backup_at,omitempty"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ConnectionURL string `json:"connection_url"` + Metrics DatabaseMetrics `json:"metrics"` + Backups DatabaseBackupConfig `json:"backups"` + Settings DatabaseSettings `json:"settings"` } // DatabaseMetrics represents database performance metrics @@ -100,8 +104,9 @@ type DatabaseCreateRequest struct { // DatabaseUpdateRequest represents a request to update a database type DatabaseUpdateRequest struct { - Name string `json:"name,omitempty"` - Plan string `json:"plan,omitempty"` + Name string `json:"name,omitempty"` + Plan string `json:"plan,omitempty"` + BackupSchedule *string `json:"backup_schedule,omitempty"` } // DatabaseActionRequest represents a request to perform database actions @@ -181,7 +186,7 @@ func (h *DatabaseHandler) GetDatabases(c *gin.Context) { db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) - if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil { + if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { db.Backups = backupConfig } else { db.Backups = h.generateMockBackupConfig() @@ -218,7 +223,7 @@ func (h *DatabaseHandler) GetDatabase(c *gin.Context) { db = h.reconcileManagedDatabaseState(c.Request.Context(), db) db.Metrics = h.resolveDatabaseMetrics(c.Request.Context(), db) - if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db.ID); err == nil { + if backupConfig, err := h.resolveBackupConfig(c.Request.Context(), userID, db); err == nil { db.Backups = backupConfig } else { db.Backups = h.generateMockBackupConfig() @@ -362,40 +367,67 @@ func (h *DatabaseHandler) UpdateDatabase(c *gin.Context) { req.Name = strings.TrimSpace(req.Name) req.Plan = strings.TrimSpace(req.Plan) - if req.Name == "" && req.Plan == "" { + if req.Name == "" && req.Plan == "" && req.BackupSchedule == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } - now := sql.NullTime{Time: time.Now(), Valid: true} - var err error - switch { - case req.Name != "" && req.Plan != "": - err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{ - Name: req.Name, - Plan: req.Plan, - UpdatedAt: now, - ID: databaseID, - UserID: userID, - }) - case req.Name != "": - err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{ - Name: req.Name, - UpdatedAt: now, - ID: databaseID, - UserID: userID, - }) - default: - err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{ - Plan: req.Plan, - UpdatedAt: now, - ID: databaseID, - UserID: userID, + if req.BackupSchedule != nil { + var schedule sql.NullString + var nextBackup sql.NullTime + if expr := strings.TrimSpace(*req.BackupSchedule); expr != "" { + next, err := calculateNextRun(expr, "UTC") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid cron schedule"}) + return + } + schedule = sql.NullString{String: expr, Valid: true} + nextBackup = sql.NullTime{Time: *next, Valid: true} + } + err := h.queries.SetDatabaseBackupScheduleByIDAndUser(c.Request.Context(), sqlcdb.SetDatabaseBackupScheduleByIDAndUserParams{ + BackupSchedule: schedule, + NextBackupAt: nextBackup, + UpdatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + ID: databaseID, + UserID: userID, }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update backup schedule"}) + return + } } - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"}) - return + + if req.Name != "" || req.Plan != "" { + now := sql.NullTime{Time: time.Now(), Valid: true} + var err error + switch { + case req.Name != "" && req.Plan != "": + err = h.queries.UpdateDatabaseServiceNameAndPlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameAndPlanByIDAndUserParams{ + Name: req.Name, + Plan: req.Plan, + UpdatedAt: now, + ID: databaseID, + UserID: userID, + }) + case req.Name != "": + err = h.queries.UpdateDatabaseServiceNameByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServiceNameByIDAndUserParams{ + Name: req.Name, + UpdatedAt: now, + ID: databaseID, + UserID: userID, + }) + default: + err = h.queries.UpdateDatabaseServicePlanByIDAndUser(c.Request.Context(), sqlcdb.UpdateDatabaseServicePlanByIDAndUserParams{ + Plan: req.Plan, + UpdatedAt: now, + ID: databaseID, + UserID: userID, + }) + } + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update database"}) + return + } } c.JSON(http.StatusOK, gin.H{"message": "Database updated successfully"}) @@ -643,6 +675,87 @@ func (h *DatabaseHandler) RestoreBackup(c *gin.Context) { }) } +// DownloadBackup streams a completed backup archive out of the backups volume. +// Archives live inside the containr-db-backups docker volume, so a one-shot +// utility container cats the file and its stdout is demuxed to the response. +func (h *DatabaseHandler) DownloadBackup(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + databaseID := c.Param("id") + backupID := c.Param("bid") + + backup, err := h.queries.GetDatabaseBackupByIDAndDatabaseAndUser(c.Request.Context(), sqlcdb.GetDatabaseBackupByIDAndDatabaseAndUserParams{ + ID: backupID, + DatabaseID: databaseID, + UserID: userID, + }) + if err != nil { + if err == sql.ErrNoRows { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check backup"}) + return + } + if backup.Status != "completed" { + c.JSON(http.StatusConflict, gin.H{"error": "Backup is not completed"}) + return + } + if h.dockerClient == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"}) + return + } + + archive := managedDatabaseBackupArchivePath(backupID) + if backup.BackupPath.Valid && strings.TrimSpace(backup.BackupPath.String) != "" { + archive = sanitizeBackupArchivePath(backup.BackupPath.String) + } + + // Container logs cannot carry binary data (the daemon sanitizes non-UTF-8), + // so the archive is copied out of a stopped utility container instead. + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Minute) + defer cancel() + containerID, err := h.dockerClient.CreateContainer(ctx, docker.ContainerConfig{ + Name: h.generateUtilityContainerName("backup-dl", "shared"), + Image: managedDatabaseBackupImage, + Cmd: []string{"true"}, + RestartPolicy: "no", + Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: managedDatabaseBackupVolume, Target: "/backup", ReadOnly: true}}, + Labels: map[string]string{ + "containr.managed": "true", + "containr.utility": "backup-dl", + }, + NetworkMode: "none", + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read backup archive"}) + return + } + defer func() { _ = h.dockerClient.RemoveContainer(context.Background(), containerID, true) }() + + tarStream, err := h.dockerClient.CopyFromContainer(ctx, containerID, "/backup/"+archive) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"}) + return + } + defer tarStream.Close() + + tarReader := tar.NewReader(tarStream) + if _, err := tarReader.Next(); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Backup archive not found"}) + return + } + + c.Header("Content-Type", "application/gzip") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", archive)) + c.Status(http.StatusOK) + if _, err := io.Copy(c.Writer, tarReader); err != nil { + log.Printf("backup download %s: stream error: %v", backupID, err) + } +} + func (h *DatabaseHandler) resolveDatabaseMetrics(ctx context.Context, db DatabaseService) DatabaseMetrics { if h.dockerClient == nil { return h.generateMockMetrics() @@ -756,9 +869,9 @@ func roundFloat(value float64, places int) float64 { return math.Round(value*pow) / pow } -func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, databaseID string) (DatabaseBackupConfig, error) { +func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID string, db DatabaseService) (DatabaseBackupConfig, error) { rows, err := h.queries.ListDatabaseBackupsByDatabaseAndUser(ctx, sqlcdb.ListDatabaseBackupsByDatabaseAndUserParams{ - DatabaseID: databaseID, + DatabaseID: db.ID, UserID: userID, Limit: 20, }) @@ -782,17 +895,11 @@ func (h *DatabaseHandler) resolveBackupConfig(ctx context.Context, userID, datab } } - var nextBackup *time.Time - if lastBackup != nil { - t := lastBackup.Add(24 * time.Hour) - nextBackup = &t - } - return DatabaseBackupConfig{ - Enabled: true, + Enabled: db.BackupSchedule != "", LastBackup: lastBackup, Retention: 30, - NextBackup: nextBackup, + NextBackup: db.NextBackupAt, Backups: backups, }, nil } @@ -933,31 +1040,35 @@ func (h *DatabaseHandler) resolveManagedRuntimeStatus(ctx context.Context, db Da func mapDatabaseServiceListRow(row sqlcdb.ListDatabaseServicesByUserRow) DatabaseService { return DatabaseService{ - ID: row.ID, - Name: row.Name, - Type: row.Type, - Status: row.Status, - Version: row.Version, - Plan: row.Plan, - Region: row.Region, - ConnectionURL: databaseNullString(row.ConnectionUrl), - CreatedAt: databaseNullTime(row.CreatedAt), - UpdatedAt: databaseNullTime(row.UpdatedAt), + ID: row.ID, + Name: row.Name, + Type: row.Type, + Status: row.Status, + Version: row.Version, + Plan: row.Plan, + Region: row.Region, + BackupSchedule: databaseNullString(row.BackupSchedule), + NextBackupAt: databaseNullTimePtr(row.NextBackupAt), + ConnectionURL: databaseNullString(row.ConnectionUrl), + CreatedAt: databaseNullTime(row.CreatedAt), + UpdatedAt: databaseNullTime(row.UpdatedAt), } } func mapDatabaseServiceGetRow(row sqlcdb.GetDatabaseServiceByIDAndUserRow) DatabaseService { return DatabaseService{ - ID: row.ID, - Name: row.Name, - Type: row.Type, - Status: row.Status, - Version: row.Version, - Plan: row.Plan, - Region: row.Region, - ConnectionURL: databaseNullString(row.ConnectionUrl), - CreatedAt: databaseNullTime(row.CreatedAt), - UpdatedAt: databaseNullTime(row.UpdatedAt), + ID: row.ID, + Name: row.Name, + Type: row.Type, + Status: row.Status, + Version: row.Version, + Plan: row.Plan, + Region: row.Region, + BackupSchedule: databaseNullString(row.BackupSchedule), + NextBackupAt: databaseNullTimePtr(row.NextBackupAt), + ConnectionURL: databaseNullString(row.ConnectionUrl), + CreatedAt: databaseNullTime(row.CreatedAt), + UpdatedAt: databaseNullTime(row.UpdatedAt), } } @@ -968,6 +1079,14 @@ func databaseNullTime(value sql.NullTime) time.Time { return time.Time{} } +func databaseNullTimePtr(value sql.NullTime) *time.Time { + if value.Valid { + t := value.Time + return &t + } + return nil +} + func databaseNullString(value sql.NullString) string { if value.Valid { return value.String diff --git a/app/backend/internal/api/deployments.go b/app/backend/internal/api/deployments.go index e05c5b3..dc3bf5b 100644 --- a/app/backend/internal/api/deployments.go +++ b/app/backend/internal/api/deployments.go @@ -4,6 +4,7 @@ import ( "containr/internal/database" "containr/internal/deployment" "context" + "fmt" "net/http" "strings" "time" @@ -198,10 +199,10 @@ func handleCreateDeployment(c *gin.Context) { } _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, + `INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + d.ID, d.ServiceID, fmt.Sprintf("v%d", now.Unix()), d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, ) if err != nil { @@ -368,12 +369,19 @@ func runDeploymentAndSync( `UPDATE services SET status = 'running', updated_at = $1 WHERE id = $2`, time.Now(), service.ID, ) + insertUserNotification(db, userID, "deployment", "Deployment succeeded", + fmt.Sprintf("Service %s is now running.", service.Name), "service", service.ID.String()) return case "failed": _, _ = db.Exec( `UPDATE services SET status = 'failed', updated_at = $1 WHERE id = $2`, time.Now(), service.ID, ) + body := fmt.Sprintf("Service %s failed to deploy.", service.Name) + if current.Error != "" { + body = fmt.Sprintf("Service %s failed to deploy: %s", service.Name, current.Error) + } + insertUserNotification(db, userID, "deployment", "Deployment failed", body, "service", service.ID.String()) return } } @@ -525,10 +533,10 @@ func handleRollbackDeployment(c *gin.Context) { } _, err = db.(*database.DB).Exec( - `INSERT INTO deployments - (id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, - rollback.ID, rollback.ServiceID, rollback.CommitHash, rollback.Status, + `INSERT INTO deployments + (id, service_id, version, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, + rollback.ID, rollback.ServiceID, fmt.Sprintf("rollback-%d", now.Unix()), rollback.CommitHash, rollback.Status, rollback.ImageName, rollback.ImageTag, rollback.CreatedAt, rollback.UpdatedAt, ) diff --git a/app/backend/internal/api/exec.go b/app/backend/internal/api/exec.go new file mode 100644 index 0000000..09a125d --- /dev/null +++ b/app/backend/internal/api/exec.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "containr/internal/database" + "containr/internal/docker" +) + +// handleExecInService runs a one-off shell command inside the service's +// running container and returns captured output. Intended for debugging; +// commands run as the container's default user with a 30s ceiling. +func handleExecInService(c *gin.Context) { + serviceID := c.Param("id") + userID := c.MustGet("user_id").(string) + db := c.MustGet("db").(*database.DB) + + var req struct { + Command string `json:"command" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + command := strings.TrimSpace(req.Command) + if command == "" || len(command) > 4096 { + c.JSON(http.StatusBadRequest, gin.H{"error": "command must be 1-4096 chars"}) + return + } + + var ownerID string + err := db.QueryRow( + `SELECT p.owner_id FROM services s + JOIN projects p ON p.id = s.project_id + WHERE s.id = $1`, + serviceID, + ).Scan(&ownerID) + if err != nil || ownerID != userID { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return + } + + dockerClient, _ := c.Get("docker_client") + dockerCli, _ := dockerClient.(*docker.Client) + if dockerCli == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + containerID, err := findServiceContainer(ctx, dockerCli, serviceID) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "No running container for this service"}) + return + } + + output, exitCode, runErr := dockerCli.ExecRun(ctx, containerID, []string{"sh", "-c", command}) + if len(output) > 64*1024 { + output = output[:64*1024] + } + + errText := "" + if runErr != nil { + errText = runErr.Error() + } + + c.JSON(http.StatusOK, gin.H{ + "output": output, + "exit_code": exitCode, + "error": errText, + }) +} diff --git a/app/backend/internal/api/git.go b/app/backend/internal/api/git.go index f4299a2..a8c6e30 100644 --- a/app/backend/internal/api/git.go +++ b/app/backend/internal/api/git.go @@ -1306,3 +1306,208 @@ func generateWebhookSecret() (string, error) { } return hex.EncodeToString(buf), nil } + +// handleDeleteGitProvider removes a provider connection. Repositories and +// webhooks are removed by ON DELETE CASCADE. +func handleDeleteGitProvider(c *gin.Context) { + ctx, ok := requireGitRequestContext(c) + if !ok { + return + } + + providerID := strings.TrimSpace(c.Param("providerId")) + if _, err := uuid.Parse(providerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"}) + return + } + + result, err := ctx.db.Exec( + "DELETE FROM git_providers WHERE id = $1 AND user_id = $2", + providerID, ctx.userID, + ) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete provider"}) + return + } + + affected, _ := result.RowsAffected() + if affected == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Provider disconnected"}) +} + +// handleGetGitRepositoryBranches lists branches for a repository through the +// provider's stored credentials. +func handleGetGitRepositoryBranches(c *gin.Context) { + ctx, ok := requireGitRequestContext(c) + if !ok { + return + } + + providerID := strings.TrimSpace(c.Param("providerId")) + if _, err := uuid.Parse(providerID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid provider ID"}) + return + } + + owner := strings.TrimSpace(c.Param("owner")) + repo := strings.TrimSpace(c.Param("repo")) + if owner == "" || repo == "" || strings.ContainsAny(owner+repo, " \t\r\n") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid repository reference"}) + return + } + + var providerName, providerAPIURL, providerAccessToken string + err := ctx.db.QueryRow(` + SELECT name, api_url, access_token + FROM git_providers + WHERE id = $1 AND user_id = $2 + `, providerID, ctx.userID).Scan(&providerName, &providerAPIURL, &providerAccessToken) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + c.JSON(http.StatusNotFound, gin.H{"error": "Provider not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) + return + } + + branches, err := fetchRepositoryBranches(providerName, providerAPIURL, providerAccessToken, owner, repo) + if err != nil { + log.Printf("branch fetch failed for %s/%s via %s: %v", owner, repo, providerName, err) + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch branches from provider"}) + return + } + + c.JSON(http.StatusOK, gin.H{"branches": branches}) +} + +type remoteBranch struct { + Name string `json:"name"` + Protected bool `json:"protected"` +} + +func fetchRepositoryBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) { + switch strings.ToLower(strings.TrimSpace(providerName)) { + case "github", "github_app": + return fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo) + case "gitea": + return fetchGiteaBranches(providerAPIURL, accessToken, owner, repo) + default: + return nil, fmt.Errorf("branch listing is not supported for provider %s", providerName) + } +} + +func fetchGitHubBranches(providerName, providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) { + baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/") + if baseURL == "" { + baseURL = "https://api.github.com" + } + + token := strings.TrimSpace(accessToken) + if strings.EqualFold(providerName, "github_app") { + installationID, err := strconv.ParseInt(token, 10, 64) + if err != nil || installationID <= 0 { + return nil, fmt.Errorf("github_app installation id is invalid") + } + token, err = getGitHubAppInstallationToken(baseURL, installationID) + if err != nil { + return nil, err + } + } + if token == "" { + return nil, fmt.Errorf("github access token is missing") + } + + branches := make([]remoteBranch, 0) + for page := 1; page <= 5; page++ { + request, err := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/repos/%s/%s/branches?per_page=100&page=%d", baseURL, owner, repo, page), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/vnd.github+json") + request.Header.Set("Authorization", "Bearer "+token) + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, err + } + + if response.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) + response.Body.Close() + return nil, fmt.Errorf("github branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload))) + } + + var rows []map[string]interface{} + decodeErr := json.NewDecoder(response.Body).Decode(&rows) + response.Body.Close() + if decodeErr != nil { + return nil, decodeErr + } + + for _, row := range rows { + name := asGitString(row["name"]) + if name == "" { + continue + } + branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])}) + } + + if len(rows) < 100 { + break + } + } + + return branches, nil +} + +func fetchGiteaBranches(providerAPIURL, accessToken, owner, repo string) ([]remoteBranch, error) { + baseURL := strings.TrimSuffix(strings.TrimSpace(providerAPIURL), "/") + if baseURL == "" { + baseURL = strings.TrimSuffix(strings.TrimSpace(getenvOrDefault("GITEA_BASE_URL", "")), "/") + } + if baseURL == "" { + return nil, fmt.Errorf("gitea api_url is required") + } + + request, err := http.NewRequest(http.MethodGet, + fmt.Sprintf("%s/api/v1/repos/%s/%s/branches?limit=100", baseURL, owner, repo), nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + if token := strings.TrimSpace(accessToken); token != "" { + request.Header.Set("Authorization", "token "+token) + } + + client := &http.Client{Timeout: 10 * time.Second} + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + payload, _ := io.ReadAll(io.LimitReader(response.Body, 2048)) + return nil, fmt.Errorf("gitea branches request failed with status %d: %s", response.StatusCode, strings.TrimSpace(string(payload))) + } + + var rows []map[string]interface{} + if err := json.NewDecoder(response.Body).Decode(&rows); err != nil { + return nil, err + } + + branches := make([]remoteBranch, 0, len(rows)) + for _, row := range rows { + if name := asGitString(row["name"]); name != "" { + branches = append(branches, remoteBranch{Name: name, Protected: asBoolValue(row["protected"])}) + } + } + return branches, nil +} diff --git a/app/backend/internal/api/git_receiver.go b/app/backend/internal/api/git_receiver.go new file mode 100644 index 0000000..299beac --- /dev/null +++ b/app/backend/internal/api/git_receiver.go @@ -0,0 +1,214 @@ +package api + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "io" + "net/http" + "strings" + "time" + + "containr/internal/database" + "containr/internal/deployment" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// handleGitWebhookPush is the public push receiver. Providers call +// POST /api/git/webhooks/:id with a signed payload; the HMAC against +// git_webhooks.webhook_secret replaces session auth. +func handleGitWebhookPush(c *gin.Context) { + dbValue, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + db := dbValue.(*database.DB) + + webhookID := strings.TrimSpace(c.Param("id")) + if _, err := uuid.Parse(webhookID); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid webhook ID"}) + return + } + + var ( + secret string + repoID string + providerID string + branchFilter string + active bool + ) + err := db.QueryRow(` + SELECT webhook_secret, repo_id, provider_id, COALESCE(branch_filter, ''), active + FROM git_webhooks WHERE id = $1`, webhookID). + Scan(&secret, &repoID, &providerID, &branchFilter, &active) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Webhook not found"}) + return + } + if !active { + c.JSON(http.StatusForbidden, gin.H{"error": "Webhook is disabled"}) + return + } + + body, err := io.ReadAll(io.LimitReader(c.Request.Body, 4<<20)) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read payload"}) + return + } + + var providerName string + _ = db.QueryRow(`SELECT name FROM git_providers WHERE id = $1`, providerID).Scan(&providerName) + + if !verifyGitWebhookSignature(providerName, secret, c.Request, body) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid signature"}) + return + } + + var payload struct { + Ref string `json:"ref"` + After string `json:"after"` + Repository struct { + FullName string `json:"full_name"` + PathWithNamespace string `json:"path_with_namespace"` + } `json:"repository"` + Project struct { + PathWithNamespace string `json:"path_with_namespace"` + } `json:"project"` + HeadCommit struct { + ID string `json:"id"` + } `json:"head_commit"` + } + if err := json.Unmarshal(body, &payload); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid payload"}) + return + } + + if !strings.HasPrefix(payload.Ref, "refs/heads/") { + c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "not a branch push"}) + return + } + branch := strings.TrimPrefix(payload.Ref, "refs/heads/") + if branchFilter != "" && branchFilter != branch { + c.JSON(http.StatusAccepted, gin.H{"received": true, "ignored": "branch not watched"}) + return + } + commit := payload.HeadCommit.ID + if commit == "" { + commit = payload.After + } + + var cloneURL, fullName, repoUserID string + err = db.QueryRow( + `SELECT clone_url, full_name, user_id FROM git_repositories WHERE id = $1`, repoID, + ).Scan(&cloneURL, &fullName, &repoUserID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Repository not found"}) + return + } + + rows, err := db.Query(` + SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, + s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + s.created_at, s.updated_at + FROM services s + WHERE s.git_branch = $1 AND (s.git_repo = $2 OR s.git_repo = $3)`, + branch, cloneURL, fullName) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to match services"}) + return + } + defer rows.Close() + + var services []Service + for rows.Next() { + var s Service + if err := rows.Scan( + &s.ID, &s.ProjectID, &s.Name, &s.Type, &s.Status, &s.Image, &s.Command, + &s.Environment, &s.GitRepo, &s.GitBranch, &s.BuildPath, &s.CPU, &s.Memory, + &s.CreatedAt, &s.UpdatedAt, + ); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to scan service"}) + return + } + services = append(services, s) + } + + engineValue, _ := c.Get("deployment_engine") + engine, _ := engineValue.(*deployment.DeploymentEngine) + + enqueued := 0 + for _, service := range services { + now := time.Now() + var commitHash *string + if commit != "" { + commitHash = &commit + } + d := DeploymentModel{ + ID: uuid.New(), + ServiceID: service.ID, + CommitHash: commitHash, + Status: "pending", + CreatedAt: now, + UpdatedAt: now, + } + if _, err := db.Exec( + `INSERT INTO deployments + (id, service_id, commit_hash, status, image_name, image_tag, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + d.ID, d.ServiceID, d.CommitHash, d.Status, d.ImageName, d.ImageTag, d.CreatedAt, d.UpdatedAt, + ); err != nil { + continue + } + if engine == nil { + failedAt := time.Now() + failure := "Deployment engine unavailable. Docker may not be configured on this server." + _, _ = db.Exec( + `UPDATE deployments SET status = 'failed', error = $1, completed_at = $2, updated_at = $2 WHERE id = $3`, + failure, failedAt, d.ID, + ) + continue + } + _, _ = db.Exec( + `UPDATE services SET status = 'building', updated_at = $1 WHERE id = $2`, + time.Now(), service.ID, + ) + go runDeploymentAndSync( + context.Background(), db, engine, &d, service, + CreateDeploymentRequest{CommitHash: commit, Branch: branch, Trigger: "webhook"}, + repoUserID, + ) + enqueued++ + } + + c.JSON(http.StatusAccepted, gin.H{ + "received": true, + "branch": branch, + "deployments": enqueued, + }) +} + +// verifyGitWebhookSignature checks the push signature per provider: +// GitHub X-Hub-Signature-256 and Gitea X-Gitea-Signature are HMAC-SHA256 hex; +// GitLab compares X-Gitlab-Token to the stored secret directly. +func verifyGitWebhookSignature(provider, secret string, r *http.Request, body []byte) bool { + if strings.EqualFold(provider, "gitlab") { + token := r.Header.Get("X-Gitlab-Token") + return token != "" && subtle.ConstantTimeCompare([]byte(token), []byte(secret)) == 1 + } + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + sum := mac.Sum(nil) + for _, header := range []string{"X-Hub-Signature-256", "X-Gitea-Signature"} { + sig := strings.TrimPrefix(r.Header.Get(header), "sha256=") + if decoded, err := hex.DecodeString(sig); err == nil && len(decoded) > 0 && hmac.Equal(decoded, sum) { + return true + } + } + return false +} diff --git a/app/backend/internal/api/git_receiver_test.go b/app/backend/internal/api/git_receiver_test.go new file mode 100644 index 0000000..4fccac4 --- /dev/null +++ b/app/backend/internal/api/git_receiver_test.go @@ -0,0 +1,84 @@ +package api + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "net/http/httptest" + "testing" +) + +func signBody(t *testing.T, secret string, body []byte) string { + t.Helper() + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write(body) + return hex.EncodeToString(mac.Sum(nil)) +} + +func TestVerifyGitWebhookSignature(t *testing.T) { + body := []byte(`{"ref":"refs/heads/main"}`) + secret := "test-secret" + valid := signBody(t, secret, body) + + cases := []struct { + name string + provider string + headers map[string]string + want bool + }{ + { + name: "github sha256 prefix", + provider: "github", + headers: map[string]string{"X-Hub-Signature-256": "sha256=" + valid}, + want: true, + }, + { + name: "gitea signature", + provider: "gitea", + headers: map[string]string{"X-Gitea-Signature": valid}, + want: true, + }, + { + name: "gitlab token", + provider: "gitlab", + headers: map[string]string{"X-Gitlab-Token": secret}, + want: true, + }, + { + name: "gitlab rejects wrong token", + provider: "gitlab", + headers: map[string]string{"X-Gitlab-Token": "wrong"}, + want: false, + }, + { + name: "github rejects wrong signature", + provider: "github", + headers: map[string]string{"X-Hub-Signature-256": "sha256=" + signBody(t, "other", body)}, + want: false, + }, + { + name: "missing signature", + provider: "github", + headers: map[string]string{}, + want: false, + }, + { + name: "malformed hex", + provider: "github", + headers: map[string]string{"X-Hub-Signature-256": "sha256=zzz"}, + want: false, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "/api/git/webhooks/x", nil) + for k, v := range tc.headers { + req.Header.Set(k, v) + } + if got := verifyGitWebhookSignature(tc.provider, secret, req, body); got != tc.want { + t.Fatalf("verifyGitWebhookSignature = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/app/backend/internal/api/logs.go b/app/backend/internal/api/logs.go index 349e07d..c33cf95 100644 --- a/app/backend/internal/api/logs.go +++ b/app/backend/internal/api/logs.go @@ -64,11 +64,15 @@ func handleGetLogs(c *gin.Context) { dockerClient, exists := c.Get("docker_client") if !exists || dockerClient == nil { - respondDependencyUnavailable(c, "docker", "Container log streaming is unavailable because Docker is not configured.") + respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") return } - client := dockerClient.(*docker.Client) + client, ok := dockerClient.(*docker.Client) + if !ok || client == nil { + respondEmptyServiceLogs(c, "Container log streaming is unavailable because Docker is not configured.") + return + } containerName := fmt.Sprintf("containr-%s", serviceID) logOpts := docker.LogOptions{ @@ -82,6 +86,10 @@ func handleGetLogs(c *gin.Context) { ctx := context.Background() logsReader, err := client.GetContainerLogs(ctx, containerName, logOpts) if err != nil { + if isDockerNotFoundError(err) { + respondEmptyServiceLogs(c, "No container logs are available for this service yet.") + return + } respondDependencyUnavailable(c, "docker", "Failed to fetch container logs. The container may be unavailable.") return } @@ -156,6 +164,13 @@ func handleGetLogs(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"logs": logEntries}) } +func respondEmptyServiceLogs(c *gin.Context, message string) { + c.JSON(http.StatusOK, gin.H{ + "logs": []LogEntry{}, + "message": message, + }) +} + func handleGetDeploymentLogs(c *gin.Context) { db, exists := c.Get("db") if !exists { diff --git a/app/backend/internal/api/metrics.go b/app/backend/internal/api/metrics.go new file mode 100644 index 0000000..4b8fd65 --- /dev/null +++ b/app/backend/internal/api/metrics.go @@ -0,0 +1,207 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + "time" + + "containr/internal/database" + "containr/internal/docker" + + containertypes "github.com/docker/docker/api/types/container" + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +// ServiceMetricsResponse is the aggregated live telemetry for a service, +// sourced from Docker container stats for containers named +// containr---. +type ServiceMetricsResponse struct { + ServiceID string `json:"service_id"` + Status string `json:"status"` // ok, no_containers, docker_unavailable + Instances []ServiceInstanceMetrics `json:"instances"` + CPUPercent float64 `json:"cpu_percent"` + MemoryUsageBytes uint64 `json:"memory_usage_bytes"` + MemoryLimitBytes uint64 `json:"memory_limit_bytes"` + NetworkRxBytes uint64 `json:"network_rx_bytes"` + NetworkTxBytes uint64 `json:"network_tx_bytes"` + CollectedAt time.Time `json:"collected_at"` +} + +// ServiceInstanceMetrics holds live stats for one running container. +type ServiceInstanceMetrics struct { + ContainerID string `json:"container_id"` + Name string `json:"name"` + State string `json:"state"` + CPUPercent float64 `json:"cpu_percent"` + MemoryUsageBytes uint64 `json:"memory_usage_bytes"` + MemoryLimitBytes uint64 `json:"memory_limit_bytes"` + NetworkRxBytes uint64 `json:"network_rx_bytes"` + NetworkTxBytes uint64 `json:"network_tx_bytes"` + StartedAt time.Time `json:"started_at"` +} + +// handleGetServiceMetrics returns real resource usage for the service's +// Docker containers. Empty result when the service has never deployed. +func handleGetServiceMetrics(c *gin.Context) { + dbValue, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + db := dbValue.(*database.DB) + + serviceID, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid service ID"}) + return + } + + userID, exists := c.Get("user_id") + if !exists { + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not authenticated"}) + return + } + + var ownerID string + var status string + err = db.QueryRow( + `SELECT p.owner_id, COALESCE(s.status, '') + FROM services s JOIN projects p ON s.project_id = p.id + WHERE s.id = $1`, + serviceID, + ).Scan(&ownerID, &status) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return + } + if ownerID != userID.(string) { + c.JSON(http.StatusForbidden, gin.H{"error": "Access denied"}) + return + } + + response := ServiceMetricsResponse{ + ServiceID: serviceID.String(), + Status: "ok", + Instances: []ServiceInstanceMetrics{}, + CollectedAt: time.Now(), + } + + dockerValue, exists := c.Get("docker_client") + if !exists || dockerValue == nil { + response.Status = "docker_unavailable" + c.JSON(http.StatusOK, gin.H{"metrics": response}) + return + } + dockerClient := dockerValue.(*docker.Client) + + ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second) + defer cancel() + + containers, err := dockerClient.ListContainers(ctx, false) + if err != nil { + response.Status = "docker_unavailable" + c.JSON(http.StatusOK, gin.H{"metrics": response}) + return + } + + namePrefix := fmt.Sprintf("containr-%s-", serviceID.String()) + matched := 0 + for _, ctr := range containers { + name := "" + for _, n := range ctr.Names { + name = strings.TrimPrefix(n, "/") + if strings.HasPrefix(name, namePrefix) { + break + } + name = "" + } + if name == "" { + continue + } + matched++ + + instance := ServiceInstanceMetrics{ + ContainerID: ctr.ID, + Name: name, + State: ctr.State, + } + + if info, err := dockerClient.GetContainer(ctx, ctr.ID); err == nil { + if info.State != nil && info.State.StartedAt != "" { + if parsed, perr := time.Parse(time.RFC3339Nano, info.State.StartedAt); perr == nil { + instance.StartedAt = parsed + } + } + } + + if ctr.State == "running" { + if stats, err := readContainerStats(ctx, dockerClient, ctr.ID); err == nil { + instance.CPUPercent = stats.cpuPercent + instance.MemoryUsageBytes = stats.memoryUsage + instance.MemoryLimitBytes = stats.memoryLimit + instance.NetworkRxBytes = stats.networkRx + instance.NetworkTxBytes = stats.networkTx + } + } + + response.Instances = append(response.Instances, instance) + response.CPUPercent += instance.CPUPercent + response.MemoryUsageBytes += instance.MemoryUsageBytes + response.MemoryLimitBytes += instance.MemoryLimitBytes + response.NetworkRxBytes += instance.NetworkRxBytes + response.NetworkTxBytes += instance.NetworkTxBytes + } + + if matched == 0 { + response.Status = "no_containers" + } + + c.JSON(http.StatusOK, gin.H{"metrics": response}) +} + +type containerStatsSnapshot struct { + cpuPercent float64 + memoryUsage uint64 + memoryLimit uint64 + networkRx uint64 + networkTx uint64 +} + +func readContainerStats(ctx context.Context, client *docker.Client, containerID string) (*containerStatsSnapshot, error) { + reader, err := client.GetContainerStats(ctx, containerID, false) + if err != nil { + return nil, err + } + defer reader.Body.Close() + + var stats containertypes.StatsResponse + if err := json.NewDecoder(reader.Body).Decode(&stats); err != nil { + return nil, err + } + + snapshot := &containerStatsSnapshot{ + memoryUsage: stats.MemoryStats.Usage, + memoryLimit: stats.MemoryStats.Limit, + } + + cpuDelta := float64(stats.CPUStats.CPUUsage.TotalUsage) - float64(stats.PreCPUStats.CPUUsage.TotalUsage) + systemDelta := float64(stats.CPUStats.SystemUsage) - float64(stats.PreCPUStats.SystemUsage) + onlineCPUs := float64(stats.CPUStats.OnlineCPUs) + if onlineCPUs == 0 { + onlineCPUs = float64(len(stats.CPUStats.CPUUsage.PercpuUsage)) + } + if cpuDelta > 0 && systemDelta > 0 && onlineCPUs > 0 { + snapshot.cpuPercent = (cpuDelta / systemDelta) * onlineCPUs * 100 + } + + for _, net := range stats.Networks { + snapshot.networkRx += net.RxBytes + snapshot.networkTx += net.TxBytes + } + + return snapshot, nil +} diff --git a/app/backend/internal/api/notifications.go b/app/backend/internal/api/notifications.go new file mode 100644 index 0000000..801b963 --- /dev/null +++ b/app/backend/internal/api/notifications.go @@ -0,0 +1,162 @@ +package api + +import ( + "containr/internal/database" + "containr/internal/database/sqlcdb" + "context" + "database/sql" + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" +) + +type NotificationItem struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + ResourceType string `json:"resource_type"` + ResourceID string `json:"resource_id"` + ReadAt *time.Time `json:"read_at"` + CreatedAt time.Time `json:"created_at"` +} + +// insertUserNotification records a persistent notification for a user. +// Best-effort: notification delivery must never break the caller's flow. +func insertUserNotification(db *database.DB, userID, kind, title, body, resourceType, resourceID string) { + userUUID, err := uuid.Parse(userID) + if err != nil || db == nil { + return + } + _ = sqlcdb.New(db.DB).CreateNotification(context.Background(), sqlcdb.CreateNotificationParams{ + ID: uuid.New(), + UserID: userUUID, + Kind: kind, + Title: title, + Body: sql.NullString{String: body, Valid: body != ""}, + ResourceType: sql.NullString{String: resourceType, Valid: resourceType != ""}, + ResourceID: sql.NullString{String: resourceID, Valid: resourceID != ""}, + CreatedAt: sql.NullTime{Time: time.Now(), Valid: true}, + }) +} + +func mapNotification(row sqlcdb.Notification) NotificationItem { + var readAt *time.Time + if row.ReadAt.Valid { + t := row.ReadAt.Time + readAt = &t + } + return NotificationItem{ + ID: row.ID.String(), + Kind: row.Kind, + Title: row.Title, + Body: databaseNullString(row.Body), + ResourceType: databaseNullString(row.ResourceType), + ResourceID: databaseNullString(row.ResourceID), + ReadAt: readAt, + CreatedAt: databaseNullTime(row.CreatedAt), + } +} + +func handleListNotifications(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + q := sqlcdb.New(db.(*database.DB).DB) + limit := int64(20) + if raw := c.Query("limit"); raw != "" { + if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed + } + } + userUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"}) + return + } + rows, err := q.ListNotificationsByUser(c.Request.Context(), sqlcdb.ListNotificationsByUserParams{ + UserID: userUUID, + Limit: int32(limit), + }) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list notifications"}) + return + } + unread, err := q.CountUnreadNotificationsByUser(c.Request.Context(), userUUID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to count notifications"}) + return + } + items := make([]NotificationItem, 0, len(rows)) + for _, row := range rows { + items = append(items, mapNotification(row)) + } + c.JSON(http.StatusOK, gin.H{"notifications": items, "unread": unread}) +} + +func handleMarkNotificationRead(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid notification id"}) + return + } + userUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"}) + return + } + q := sqlcdb.New(db.(*database.DB).DB) + if err := q.MarkNotificationReadByIDAndUser(c.Request.Context(), sqlcdb.MarkNotificationReadByIDAndUserParams{ + ID: id, + UserID: userUUID, + ReadAt: sql.NullTime{Time: time.Now(), Valid: true}, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notification"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Notification marked read"}) +} + +func handleMarkAllNotificationsRead(c *gin.Context) { + userID, ok := requireAuthenticatedUserID(c) + if !ok { + return + } + db, exists := c.Get("db") + if !exists { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Database connection not available"}) + return + } + userUUID, err := uuid.Parse(userID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user id"}) + return + } + q := sqlcdb.New(db.(*database.DB).DB) + if err := q.MarkAllNotificationsReadByUser(c.Request.Context(), sqlcdb.MarkAllNotificationsReadByUserParams{ + UserID: userUUID, + ReadAt: sql.NullTime{Time: time.Now(), Valid: true}, + }); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to mark notifications"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "All notifications marked read"}) +} diff --git a/app/backend/internal/api/params.go b/app/backend/internal/api/params.go index 0266fde..04505c7 100644 --- a/app/backend/internal/api/params.go +++ b/app/backend/internal/api/params.go @@ -1,6 +1,10 @@ package api -import "github.com/gin-gonic/gin" +import ( + "strings" + + "github.com/gin-gonic/gin" +) // firstPathParam returns the first non-empty route param from the provided names. func firstPathParam(c *gin.Context, names ...string) string { @@ -11,3 +15,13 @@ func firstPathParam(c *gin.Context, names ...string) string { } return "" } + +// firstNonEmpty returns the first non-blank string, trimmed. +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/app/backend/internal/api/preview_environments.go b/app/backend/internal/api/preview_environments.go index c0e92a9..e897255 100644 --- a/app/backend/internal/api/preview_environments.go +++ b/app/backend/internal/api/preview_environments.go @@ -201,7 +201,7 @@ func handleCreatePreviewEnvironment(c *gin.Context) { // Check if service exists and belongs to the project var service Service err = db.(*database.DB).QueryRow( - "SELECT id, name, type FROM services WHERE id = $1 AND project_id = $2", + "SELECT id, name, COALESCE(type, service_type, '') FROM services WHERE id = $1 AND project_id = $2", req.ServiceID, req.ProjectID, ).Scan(&service.ID, &service.Name, &service.Type) @@ -586,10 +586,11 @@ func handlePromotePreviewEnvironment(c *gin.Context) { } deploymentID := uuid.New() + promotionVersion := fmt.Sprintf("promote-%s-%d", strings.ReplaceAll(env.BranchName, "/", "-"), time.Now().Unix()) _, err = db.(*database.DB).Exec( - `INSERT INTO deployments (id, service_id, status, created_at, updated_at) - VALUES ($1, $2, $3, NOW(), NOW())`, - deploymentID, env.ServiceID, "pending", + `INSERT INTO deployments (id, service_id, version, status, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW(), NOW())`, + deploymentID, env.ServiceID, promotionVersion, "pending", ) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create promotion deployment"}) diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 5f9e636..0fdc7f4 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -17,13 +17,11 @@ import ( "containr/internal/scaling" "github.com/gin-gonic/gin" - "gorm.io/driver/postgres" - "gorm.io/gorm" ) func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg *config.Config) { // Expose Better Auth through backend so frontend can use a single backend origin. - setupAuthProxyRoutes(router, cfg) + setupAuthProxyRoutes(router, cfg, db) // Initialize Docker client (non-fatal if it fails) var dockerClient *docker.Client @@ -50,20 +48,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg } metricsCollector := metrics.NewMetricsCollector(scheduler, metricsStorage) autoScaler := scaling.NewAutoScaler(scheduler, metricsCollector) + if db != nil && db.DB != nil { + autoScaler.WithPersistence(db.DB) + if err := autoScaler.LoadPolicies(context.Background()); err != nil { + log.Printf("Failed to restore scaling policies: %v", err) + } + } haManager := ha.NewHighAvailabilityManager(scheduler, metricsCollector) haAPIManager := NewHAManager(haManager) // Initialize scaling handler scalingHandler := NewScalingHandler(autoScaler) - // Initialize GORM for agent system - gormDB, err := gorm.Open(postgres.Open(cfg.DatabaseURL), &gorm.Config{}) - if err != nil { - panic("Failed to initialize GORM: " + err.Error()) - } + // Initialize agent handler (sqlc-backed) + agentHandler := NewNodeAgentHandler(db) - // Initialize agent handler - agentHandler := NewNodeAgentHandler(gormDB) + // Start the cron scheduler — executes due cron_jobs via docker exec. + StartCronScheduler(context.Background(), db, dockerClient) // Initialize database handler databaseHandler := NewDatabaseHandler(db.DB, dockerClient) @@ -90,7 +91,6 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg c.Set("auto_scaler", autoScaler) c.Set("ha_manager", haManager) c.Set("scaling_handler", scalingHandler) - c.Set("gorm_db", gormDB) c.Next() }) @@ -158,11 +158,19 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg router.HEAD("/ready", healthHandler) // API v1 routes + publicAgents := router.Group("/api") + agentHandler.SetupPublicRoutes(publicAgents) + + // Public push receiver: HMAC signature on git_webhooks.webhook_secret + // replaces session auth. Providers POST to /api/git/webhooks/:id. + router.POST("/api/git/webhooks/:id", handleGitWebhookPush) + v1 := router.Group("/api/v1") { // Public routes (no authentication required) public := v1.Group("/") { + public.GET("/auth/bootstrap", handleAuthBootstrap) public.POST("/auth/login", handleLogin) public.POST("/auth/register", handleRegister) } @@ -174,6 +182,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // User routes protected.GET("/user/profile", handleGetProfile) protected.PUT("/user/profile", handleUpdateProfile) + protected.POST("/users", handleCreateUser) // Project routes protected.GET("/projects", handleGetProjects) @@ -192,6 +201,7 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.GET("/services/:id", handleGetService) protected.PUT("/services/:id", handleUpdateService) protected.DELETE("/services/:id", handleDeleteService) + protected.GET("/services/:id/metrics", handleGetServiceMetrics) // Deployment routes protected.GET("/services/:id/deployments", handleGetDeployments) @@ -207,12 +217,17 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.GET("/services/:id/logs", handleGetLogs) protected.GET("/deployments/:id/logs", handleGetDeploymentLogs) + // One-off exec console (docker exec, 30s ceiling) + protected.POST("/services/:id/exec", handleExecInService) + // Git integration routes protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) protected.POST("/git/github-app/connect", handleConnectGitHubApp) protected.GET("/git/providers", handleGetGitProviders) protected.POST("/git/providers", handleCreateGitProvider) + protected.DELETE("/git/providers/:providerId", handleDeleteGitProvider) protected.GET("/git/providers/:providerId/repositories", handleGetGitRepositories) + protected.GET("/git/providers/:providerId/repositories/:owner/:repo/branches", handleGetGitRepositoryBranches) protected.POST("/git/repositories/connect", handleConnectGitRepository) protected.GET("/git/repositories", handleGetConnectedRepositories) protected.POST("/git/webhooks", handleCreateWebhook) @@ -226,6 +241,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.POST("/builds/plan", buildHandler.GetBuildPlan) protected.GET("/builds/detect", buildHandler.DetectBuildType) + // System routes + protected.GET("/system/upgrade/status", handleGetUpgradeStatus) + protected.POST("/system/upgrade/pull", handlePullUpgradeImage) + protected.GET("/system/host", handleGetHostMonitoring) + // Scaling routes scalingHandler.RegisterRoutes(protected) haAPIManager.RegisterRoutes(protected) @@ -239,11 +259,15 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.POST("/databases/:id/action", databaseHandler.PerformDatabaseAction) protected.POST("/databases/:id/backup", databaseHandler.CreateBackup) protected.POST("/databases/:id/restore", databaseHandler.RestoreBackup) + protected.GET("/databases/:id/backups/:bid/download", databaseHandler.DownloadBackup) + + // Notification routes + protected.GET("/notifications", handleListNotifications) + protected.POST("/notifications/:id/read", handleMarkNotificationRead) + protected.POST("/notifications/read-all", handleMarkAllNotificationsRead) // Node Agent routes - api := router.Group("/api") - api.Use(middleware.Auth(cfg.JWTSecret)) - agentHandler.SetupRoutes(api) + agentHandler.SetupRoutes(protected) // Preview Environments routes protected.GET("/projects/:id/preview-environments", handleGetPreviewEnvironments) @@ -287,42 +311,23 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg // Audit Logs routes protected.GET("/audit-logs", handleGetAuditLogs) protected.GET("/audit-logs/:resource/:id", handleGetResourceAuditLogs) - } - // APwhy Gateway routes - apwhy := router.Group("/api/v1") - { - // Health check (no auth required) - apwhy.GET("/health", func(c *gin.Context) { - c.JSON(200, gin.H{ - "ok": true, - "data": gin.H{ - "status": "ok", - "name": "Containr + APwhy", - "database": "postgresql", - "generatedAt": time.Now().UTC().Format(time.RFC3339), - }, - }) - }) - } + // API Gateway routes (merged APwhy) - namespaced to avoid + // colliding with Containr's own /services paths. + gateway := protected.Group("/gateway") + { + gateway.GET("/services", handleAPwhyServicesList) + gateway.POST("/services", handleAPwhyServicesCreate) + gateway.PATCH("/services/:id", handleAPwhyServicesPatch) + gateway.GET("/services/:id/validate", handleAPwhyServiceValidate) - // Protected APwhy routes (authentication required) - protectedAPwhy := router.Group("/api/v1") - protectedAPwhy.Use(middleware.Auth(cfg.JWTSecret)) - { - // Service management - protectedAPwhy.GET("/services", handleAPwhyServicesList) - protectedAPwhy.POST("/services", handleAPwhyServicesCreate) - protectedAPwhy.PATCH("/services/:id", handleAPwhyServicesPatch) + gateway.GET("/keys", handleAPwhyKeysList) + gateway.POST("/keys", handleAPwhyKeysCreate) + gateway.PATCH("/keys/:id", handleAPwhyKeysPatch) - // API Keys - protectedAPwhy.GET("/keys", handleAPwhyKeysList) - protectedAPwhy.POST("/keys", handleAPwhyKeysCreate) - protectedAPwhy.PATCH("/keys/:id", handleAPwhyKeysPatch) - - // Analytics - protectedAPwhy.GET("/analytics/ops", handleAPwhyAnalyticsOps) - protectedAPwhy.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic) + gateway.GET("/analytics/ops", handleAPwhyAnalyticsOps) + gateway.GET("/analytics/traffic", handleAPwhyAnalyticsTraffic) + } } } } diff --git a/app/backend/internal/api/security.go b/app/backend/internal/api/security.go index 6ce3a49..0a61e26 100644 --- a/app/backend/internal/api/security.go +++ b/app/backend/internal/api/security.go @@ -394,10 +394,10 @@ func (sh *SecurityHandler) GetSecurityMetrics(c *gin.Context) { } err = sh.db.QueryRow(` - SELECT id, score, started_at as scanned_at, status - FROM security_scans - WHERE project_id = $1 - ORDER BY started_at DESC + SELECT id, COALESCE((summary->>'score')::int, 0), started_at as scanned_at, status + FROM security_scans + WHERE project_id = $1 + ORDER BY started_at DESC LIMIT 1 `, projectID).Scan(&latestScan.ID, &latestScan.Score, &latestScan.ScannedAt, &latestScan.Status) diff --git a/app/backend/internal/api/server.go b/app/backend/internal/api/server.go deleted file mode 100644 index 3a9419e..0000000 --- a/app/backend/internal/api/server.go +++ /dev/null @@ -1,2694 +0,0 @@ -package api - -import ( - "context" - "database/sql" - "embed" - "encoding/json" - "errors" - "fmt" - "io" - "io/fs" - "mime" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "sort" - "strconv" - "strings" - "sync" - "time" - - "containr/internal/analytics" - "containr/internal/auth" - "containr/internal/config" - "containr/internal/gateway" - "containr/internal/storage" -) - -//go:embed static/* -var staticFS embed.FS - -type Server struct { - Store *storage.Store - Cfg config.Config - Umami *analytics.Client - proxyClient *http.Client - - rpmMu sync.Mutex - rpmUsage map[string]rpmWindow - - loginMu sync.Mutex - loginAttempts map[string]loginWindow -} - -type rpmWindow struct { - WindowStart time.Time - Count int -} - -type loginWindow struct { - WindowStart time.Time - Count int -} - -type authContext struct { - UserID string - Email string - Enabled bool - ForcePasswordReset bool - Roles []string - Permissions map[string]bool - SessionID string -} - -func NewServer(store *storage.Store, cfg config.Config) *Server { - return &Server{ - Store: store, - Cfg: cfg, - Umami: analytics.NewClient(cfg.UmamiBaseURL, cfg.UmamiAPIKey, cfg.UmamiWebsiteID), - proxyClient: &http.Client{ - Timeout: cfg.DefaultServiceTimeout, - }, - rpmUsage: map[string]rpmWindow{}, - loginAttempts: map[string]loginWindow{}, - } -} - -func (s *Server) Handler() http.Handler { - mux := http.NewServeMux() - - mux.HandleFunc("GET /health", s.handleHealth) - - mux.HandleFunc("GET /api/v1/bootstrap/status", s.handleBootstrapStatus) - mux.HandleFunc("POST /api/v1/bootstrap/register-owner", s.handleBootstrapRegisterOwner) - - mux.HandleFunc("POST /api/v1/auth/login", s.handleAuthLogin) - mux.HandleFunc("POST /api/v1/auth/logout", s.handleAuthLogout) - mux.HandleFunc("POST /api/v1/auth/refresh", s.handleAuthRefresh) - mux.HandleFunc("GET /api/v1/auth/me", s.withAuth("", true, s.handleAuthMe)) - mux.HandleFunc("POST /api/v1/auth/reset-password", s.withAuth("", true, s.handleAuthResetPassword)) - - mux.HandleFunc("GET /api/v1/users", s.withAuth("users.read", false, s.handleUsersList)) - mux.HandleFunc("POST /api/v1/users", s.withAuth("users.write", false, s.handleUsersCreate)) - mux.HandleFunc("PATCH /api/v1/users/{id}", s.withAuth("users.write", false, s.handleUsersPatch)) - - mux.HandleFunc("GET /api/v1/roles", s.withAuth("roles.read", false, s.handleRolesList)) - mux.HandleFunc("POST /api/v1/roles", s.withAuth("roles.write", false, s.handleRolesCreate)) - mux.HandleFunc("PATCH /api/v1/roles/{id}", s.withAuth("roles.write", false, s.handleRolesPatch)) - mux.HandleFunc("GET /api/v1/permissions", s.withAuth("roles.read", false, s.handlePermissionsList)) - - mux.HandleFunc("GET /api/v1/services", s.withAuth("services.read", false, s.handleServicesList)) - mux.HandleFunc("POST /api/v1/services", s.withAuth("services.write", false, s.handleServicesCreate)) - mux.HandleFunc("PATCH /api/v1/services/{id}", s.withAuth("services.write", false, s.handleServicesPatch)) - mux.HandleFunc("POST /api/v1/services/{id}/validate", s.withAuth("services.write", false, s.handleServicesValidate)) - - mux.HandleFunc("GET /api/v1/databases", s.withAuth("databases.read", false, s.handleDatabasesList)) - mux.HandleFunc("POST /api/v1/databases", s.withAuth("databases.write", false, s.handleDatabasesCreate)) - mux.HandleFunc("PATCH /api/v1/databases/{id}", s.withAuth("databases.write", false, s.handleDatabasesPatch)) - mux.HandleFunc("POST /api/v1/databases/{id}/validate", s.withAuth("databases.write", false, s.handleDatabasesValidate)) - - mux.HandleFunc("GET /api/v1/keys", s.withAuth("keys.read", false, s.handleKeysList)) - mux.HandleFunc("POST /api/v1/keys", s.withAuth("keys.write", false, s.handleKeysCreate)) - mux.HandleFunc("PATCH /api/v1/keys/{id}", s.withAuth("keys.write", false, s.handleKeysPatch)) - - mux.HandleFunc("GET /api/v1/analytics/ops", s.withAuth("analytics.read", false, s.handleAnalyticsOps)) - mux.HandleFunc("GET /api/v1/analytics/traffic", s.withAuth("analytics.read", false, s.handleAnalyticsTraffic)) - mux.HandleFunc("POST /api/v1/analytics/events", s.withAuth("", true, s.handleAnalyticsEvents)) - - mux.HandleFunc("/", s.handleGatewayOrUI) - - return s.withSecurityHeaders(s.withLogging(mux)) -} - -func (s *Server) withSecurityHeaders(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("X-Frame-Options", "DENY") - w.Header().Set("X-Content-Type-Options", "nosniff") - w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") - next.ServeHTTP(w, r) - }) -} - -func (s *Server) withLogging(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - next.ServeHTTP(w, r) - _ = s.logMetric("http_request", 1, map[string]any{ - "method": r.Method, - "path": r.URL.Path, - "ms": time.Since(start).Milliseconds(), - }) - }) -} - -func (s *Server) withAuth(requiredPermission string, allowForceReset bool, handler func(http.ResponseWriter, *http.Request, *authContext)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - ctx, err := s.resolveAuth(r) - if err != nil { - writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Authentication required") - return - } - if !ctx.Enabled { - writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled") - return - } - if ctx.ForcePasswordReset && !allowForceReset { - writeError(w, http.StatusPreconditionRequired, "PASSWORD_RESET_REQUIRED", "Password reset required") - return - } - if requiredPermission != "" && !ctx.Permissions[requiredPermission] { - writeError(w, http.StatusForbidden, "FORBIDDEN", "Insufficient permissions") - return - } - handler(w, r, ctx) - } -} - -func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "status": "ok", - "name": "APwhy", - "database": "sqlite", - "generatedAt": time.Now().UTC().Format(time.RFC3339), - }, - }) -} - -func (s *Server) handleBootstrapStatus(w http.ResponseWriter, r *http.Request) { - hasUsers := s.hasUsers(r.Context()) - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "hasUsers": hasUsers, - "registrationOpen": !hasUsers, - }, - }) -} - -func (s *Server) handleBootstrapRegisterOwner(w http.ResponseWriter, r *http.Request) { - if s.hasUsers(r.Context()) { - writeError(w, http.StatusForbidden, "BOOTSTRAP_CLOSED", "Owner registration is closed") - return - } - - var input struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - input.Email = strings.ToLower(strings.TrimSpace(input.Email)) - if input.Email == "" || !strings.Contains(input.Email, "@") { - writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required") - return - } - - hash, err := auth.HashPassword(input.Password) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error()) - return - } - - now := storage.NowISO() - userID, _ := auth.RandomID("usr") - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at) - VALUES (?, ?, ?, 1, 0, ?, ?) - `, userID, input.Email, hash, now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists") - return - } - - ownerRoleID := "" - if err := tx.QueryRowContext(r.Context(), `SELECT id FROM roles WHERE slug = 'owner'`).Scan(&ownerRoleID); err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_MISSING", "Owner role not found") - return - } - - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, ownerRoleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign owner role") - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit owner registration") - return - } - - s.audit(r.Context(), userID, "owner.registered", "user", userID, map[string]any{"email": input.Email}) - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": userID, - "email": input.Email, - "roles": []string{"owner"}, - }, - }) -} - -func (s *Server) handleAuthLogin(w http.ResponseWriter, r *http.Request) { - var input struct { - Email string `json:"email"` - Password string `json:"password"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - email := strings.ToLower(strings.TrimSpace(input.Email)) - if email == "" || input.Password == "" { - writeError(w, http.StatusBadRequest, "INVALID_CREDENTIALS", "Email and password are required") - return - } - - if s.isLoginRateLimited(r, email) { - writeError(w, http.StatusTooManyRequests, "LOGIN_RATE_LIMITED", "Too many login attempts") - return - } - - var user struct { - ID string - Email string - PasswordHash string - Enabled int - ForcePasswordReset int - } - err := s.Store.DB.QueryRowContext(r.Context(), ` - SELECT id, email, password_hash, enabled, force_password_reset - FROM users - WHERE email = ? - `, email).Scan(&user.ID, &user.Email, &user.PasswordHash, &user.Enabled, &user.ForcePasswordReset) - if err != nil { - s.markLoginAttempt(r, email) - writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials") - return - } - if user.Enabled != 1 { - writeError(w, http.StatusForbidden, "USER_DISABLED", "User account is disabled") - return - } - - ok, err := auth.VerifyPassword(user.PasswordHash, input.Password) - if err != nil || !ok { - s.markLoginAttempt(r, email) - writeError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Invalid credentials") - return - } - - s.clearLoginAttempts(r, email) - - sessionID, accessToken, refreshToken, err := s.createSession(r.Context(), user.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, "SESSION_CREATE_FAILED", "Failed to create login session") - return - } - - now := storage.NowISO() - _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE users SET last_login_at = ?, updated_at = ? WHERE id = ?`, now, now, user.ID) - _ = sessionID - - s.setAuthCookies(w, accessToken, refreshToken) - - ctx, _ := s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessToken)) - if ctx == nil { - writeError(w, http.StatusInternalServerError, "SESSION_RESOLVE_FAILED", "Failed to load user profile") - return - } - - s.audit(r.Context(), user.ID, "auth.login", "session", sessionID, map[string]any{"email": user.Email}) - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "user": map[string]any{ - "id": ctx.UserID, - "email": ctx.Email, - "roles": ctx.Roles, - "permissions": keysOfMap(ctx.Permissions), - "forcePasswordReset": ctx.ForcePasswordReset, - }, - }, - }) -} - -func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { - accessCookie, _ := r.Cookie(s.Cfg.SessionAccessCookie) - refreshCookie, _ := r.Cookie(s.Cfg.SessionRefreshCookie) - - now := storage.NowISO() - if accessCookie != nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE access_token_hash = ? - `, now, now, auth.HashToken(accessCookie.Value)) - } - if refreshCookie != nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions SET revoked_at = ?, updated_at = ? WHERE refresh_token_hash = ? - `, now, now, auth.HashToken(refreshCookie.Value)) - } - - s.clearAuthCookies(w) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleAuthRefresh(w http.ResponseWriter, r *http.Request) { - refreshCookie, err := r.Cookie(s.Cfg.SessionRefreshCookie) - if err != nil || strings.TrimSpace(refreshCookie.Value) == "" { - writeError(w, http.StatusUnauthorized, "REFRESH_MISSING", "Refresh token is missing") - return - } - - refreshHash := auth.HashToken(refreshCookie.Value) - - var session struct { - ID string - UserID string - ExpiresAt string - RevokedAt sql.NullString - } - err = s.Store.DB.QueryRowContext(r.Context(), ` - SELECT id, user_id, refresh_expires_at, revoked_at - FROM sessions - WHERE refresh_token_hash = ? - `, refreshHash).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt) - if err != nil { - writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is invalid") - return - } - if session.RevokedAt.Valid { - writeError(w, http.StatusUnauthorized, "INVALID_REFRESH", "Refresh token is revoked") - return - } - expiresAt, _ := time.Parse(time.RFC3339, session.ExpiresAt) - if time.Now().After(expiresAt) { - writeError(w, http.StatusUnauthorized, "REFRESH_EXPIRED", "Refresh token has expired") - return - } - - accessToken, _ := auth.RandomToken(32) - newRefreshToken, _ := auth.RandomToken(48) - accessHash := auth.HashToken(accessToken) - refreshHashNew := auth.HashToken(newRefreshToken) - - accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339) - refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339) - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE sessions - SET access_token_hash = ?, refresh_token_hash = ?, access_expires_at = ?, refresh_expires_at = ?, updated_at = ? - WHERE id = ? - `, accessHash, refreshHashNew, accessExp, refreshExp, storage.NowISO(), session.ID) - if err != nil { - writeError(w, http.StatusInternalServerError, "REFRESH_FAILED", "Failed to refresh session") - return - } - - s.setAuthCookies(w, accessToken, newRefreshToken) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleAuthMe(w http.ResponseWriter, r *http.Request, ctx *authContext) { - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": ctx.UserID, - "email": ctx.Email, - "roles": ctx.Roles, - "permissions": keysOfMap(ctx.Permissions), - "forcePasswordReset": ctx.ForcePasswordReset, - }, - }) -} - -func (s *Server) handleAuthResetPassword(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - NewPassword string `json:"newPassword"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - hash, err := auth.HashPassword(input.NewPassword) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_PASSWORD", err.Error()) - return - } - - now := storage.NowISO() - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE users SET password_hash = ?, force_password_reset = 0, updated_at = ? WHERE id = ? - `, hash, now, ctx.UserID) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_UPDATE_FAILED", "Failed to update password") - return - } - - s.audit(r.Context(), ctx.UserID, "auth.reset_password", "user", ctx.UserID, nil) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handleUsersList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT u.id, u.email, u.enabled, u.force_password_reset, u.created_at, u.updated_at, - COALESCE(GROUP_CONCAT(r.slug), '') AS role_slugs - FROM users u - LEFT JOIN user_roles ur ON ur.user_id = u.id - LEFT JOIN roles r ON r.id = ur.role_id - GROUP BY u.id - ORDER BY u.created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list users") - return - } - defer rows.Close() - - items := make([]map[string]any, 0) - for rows.Next() { - var id, email, createdAt, updatedAt, roleSlugs string - var enabled, forceReset int - if err := rows.Scan(&id, &email, &enabled, &forceReset, &createdAt, &updatedAt, &roleSlugs); err != nil { - continue - } - roles := []string{} - if strings.TrimSpace(roleSlugs) != "" { - roles = strings.Split(roleSlugs, ",") - } - items = append(items, map[string]any{ - "id": id, - "email": email, - "enabled": enabled == 1, - "forcePasswordReset": forceReset == 1, - "roles": roles, - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleUsersCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Email string `json:"email"` - RoleIDs []string `json:"roleIds"` - RoleSlugs []string `json:"roleSlugs"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - email := strings.ToLower(strings.TrimSpace(input.Email)) - if email == "" || !strings.Contains(email, "@") { - writeError(w, http.StatusBadRequest, "INVALID_EMAIL", "Valid email is required") - return - } - - roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error()) - return - } - if len(roleIDs) == 0 { - viewerID, err := s.roleIDBySlug(r.Context(), "viewer") - if err == nil { - roleIDs = []string{viewerID} - } - } - - tempPassword, _ := auth.RandomPassword(16) - passwordHash, err := auth.HashPassword(tempPassword) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate temporary password") - return - } - - inviteToken, _ := auth.RandomToken(32) - inviteHash := auth.HashToken(inviteToken) - now := storage.NowISO() - expiresAt := time.Now().Add(72 * time.Hour).UTC().Format(time.RFC3339) - - userID, _ := auth.RandomID("usr") - inviteID, _ := auth.RandomID("inv") - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO users (id, email, password_hash, enabled, force_password_reset, created_at, updated_at) - VALUES (?, ?, ?, 1, 1, ?, ?) - `, userID, email, passwordHash, now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "USER_CREATE_FAILED", "Email already exists") - return - } - - for _, roleID := range roleIDs { - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, userID, roleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_ASSIGN_FAILED", "Failed to assign roles") - return - } - } - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO invites (id, user_id, email, token_hash, expires_at, created_by, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?) - `, inviteID, userID, email, inviteHash, expiresAt, ctx.UserID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "INVITE_FAILED", "Failed to create invite") - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user creation") - return - } - - s.audit(r.Context(), ctx.UserID, "users.create", "user", userID, map[string]any{"email": email, "roleCount": len(roleIDs)}) - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "id": userID, - "email": email, - "inviteToken": inviteToken, - "temporaryPass": tempPassword, - "expiresAt": expiresAt, - }, - }) -} - -func (s *Server) handleUsersPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "User ID is required") - return - } - - var input struct { - Enabled *bool `json:"enabled"` - RoleIDs []string `json:"roleIds"` - RoleSlugs []string `json:"roleSlugs"` - ResetPassword bool `json:"resetPassword"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - now := storage.NowISO() - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - if input.Enabled != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE users SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "USER_UPDATE_FAILED", "Failed to update user") - return - } - } - - if len(input.RoleIDs) > 0 || len(input.RoleSlugs) > 0 { - roleIDs, err := s.resolveRoleIDs(r.Context(), input.RoleIDs, input.RoleSlugs) - if err != nil { - writeError(w, http.StatusBadRequest, "INVALID_ROLE", err.Error()) - return - } - _, err = tx.ExecContext(r.Context(), `DELETE FROM user_roles WHERE user_id = ?`, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update user roles") - return - } - for _, roleID := range roleIDs { - _, err = tx.ExecContext(r.Context(), `INSERT INTO user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)`, id, roleID, now) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to assign role") - return - } - } - } - - response := map[string]any{} - if input.ResetPassword { - tempPassword, _ := auth.RandomPassword(16) - hash, err := auth.HashPassword(tempPassword) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_ERROR", "Failed to generate password") - return - } - _, err = tx.ExecContext(r.Context(), ` - UPDATE users SET password_hash = ?, force_password_reset = 1, updated_at = ? WHERE id = ? - `, hash, now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "PASSWORD_RESET_FAILED", "Failed to reset password") - return - } - response["temporaryPass"] = tempPassword - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit user patch") - return - } - - s.audit(r.Context(), ctx.UserID, "users.patch", "user", id, input) - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": response}) -} - -func (s *Server) handleRolesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT r.id, r.name, r.slug, r.description, r.is_system, r.enabled, - COALESCE(GROUP_CONCAT(p.code), '') AS permission_codes, - r.created_at, r.updated_at - FROM roles r - LEFT JOIN role_permissions rp ON rp.role_id = r.id - LEFT JOIN permissions p ON p.id = rp.permission_id - GROUP BY r.id - ORDER BY r.created_at ASC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list roles") - return - } - defer rows.Close() - - roles := []map[string]any{} - for rows.Next() { - var id, name, slug, description, permissionCodes, createdAt, updatedAt string - var isSystem, enabled int - if err := rows.Scan(&id, &name, &slug, &description, &isSystem, &enabled, &permissionCodes, &createdAt, &updatedAt); err != nil { - continue - } - codes := []string{} - if strings.TrimSpace(permissionCodes) != "" { - codes = strings.Split(permissionCodes, ",") - sort.Strings(codes) - } - roles = append(roles, map[string]any{ - "id": id, - "name": name, - "slug": slug, - "description": description, - "isSystem": isSystem == 1, - "enabled": enabled == 1, - "permissionCodes": codes, - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": roles}) - _ = ctx -} - -func (s *Server) handleRolesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Slug string `json:"slug"` - Description string `json:"description"` - PermissionCodes []string `json:"permissionCodes"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - name := strings.TrimSpace(input.Name) - if name == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "Role name is required") - return - } - slug := slugify(firstNonEmpty(input.Slug, input.Name), "role") - id, _ := auth.RandomID("role") - now := storage.NowISO() - - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - _, err = tx.ExecContext(r.Context(), ` - INSERT INTO roles (id, name, slug, description, is_system, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, 0, 1, ?, ?) - `, id, name, slug, strings.TrimSpace(input.Description), now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "ROLE_CREATE_FAILED", "Role slug already exists") - return - } - - if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil { - writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error()) - return - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role creation") - return - } - - s.audit(r.Context(), ctx.UserID, "roles.create", "role", id, input) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) -} - -func (s *Server) handleRolesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Role ID is required") - return - } - - var input struct { - Name *string `json:"name"` - Description *string `json:"description"` - Enabled *bool `json:"enabled"` - PermissionCodes []string `json:"permissionCodes"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - isSystem := 0 - if err := s.Store.DB.QueryRowContext(r.Context(), `SELECT is_system FROM roles WHERE id = ?`, id).Scan(&isSystem); err != nil { - writeError(w, http.StatusNotFound, "ROLE_NOT_FOUND", "Role not found") - return - } - - now := storage.NowISO() - tx, err := s.Store.DB.BeginTx(r.Context(), nil) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to start transaction") - return - } - defer tx.Rollback() - - if input.Name != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET name = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Name), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role name") - return - } - } - if input.Description != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET description = ?, updated_at = ? WHERE id = ?`, strings.TrimSpace(*input.Description), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role description") - return - } - } - if input.Enabled != nil { - _, err = tx.ExecContext(r.Context(), `UPDATE roles SET enabled = ?, updated_at = ? WHERE id = ?`, boolToInt(*input.Enabled), now, id) - if err != nil { - writeError(w, http.StatusInternalServerError, "ROLE_UPDATE_FAILED", "Failed to update role status") - return - } - } - - if input.PermissionCodes != nil { - if isSystem == 1 { - writeError(w, http.StatusBadRequest, "SYSTEM_ROLE_LOCKED", "System role permissions are locked") - return - } - if err := s.replaceRolePermissionsTx(r.Context(), tx, id, input.PermissionCodes); err != nil { - writeError(w, http.StatusBadRequest, "PERMISSION_UPDATE_FAILED", err.Error()) - return - } - } - - if err := tx.Commit(); err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to commit role patch") - return - } - - s.audit(r.Context(), ctx.UserID, "roles.patch", "role", id, input) - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) -} - -func (s *Server) handlePermissionsList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, code, name, description FROM permissions ORDER BY code ASC`) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list permissions") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, code, name, description string - if err := rows.Scan(&id, &code, &name, &description); err != nil { - continue - } - items = append(items, map[string]any{"id": id, "code": code, "name": name, "description": description}) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleServicesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), `SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at FROM services ORDER BY created_at DESC`) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list services") - return - } - defer rows.Close() - items := []map[string]any{} - for rows.Next() { - item, err := scanService(rows) - if err == nil { - items = append(items, item) - } - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleServicesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input serviceInput - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - if strings.TrimSpace(input.Name) == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "Service name is required") - return - } - if strings.TrimSpace(input.UpstreamURL) == "" { - writeError(w, http.StatusBadRequest, "INVALID_UPSTREAM", "Service upstream URL is required") - return - } - routePrefix := normalizePathPrefix(input.RoutePrefix, "/"+slugify(input.Name, "service")) - if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix { - writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled") - return - } - if strings.HasPrefix(routePrefix, "/api/") { - writeError(w, http.StatusBadRequest, "ROUTE_CONFLICT", "Route prefix conflicts with internal API") - return - } - - now := storage.NowISO() - id, _ := auth.RandomID("svc") - slug := slugify(firstNonEmpty(input.Slug, input.Name), "service") - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO services ( - id, name, slug, upstream_url, route_prefix, health_path, - upstream_auth_header, upstream_auth_value, internal_token, - enabled, rpm_limit, monthly_quota, request_timeout_ms, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - id, - strings.TrimSpace(input.Name), - slug, - strings.TrimSpace(input.UpstreamURL), - routePrefix, - normalizePathPrefix(input.HealthPath, "/health"), - nullIfEmpty(input.UpstreamAuthHeader), - nullIfEmpty(input.UpstreamAuthValue), - nullIfEmpty(input.InternalToken), - boolToInt(defaultBool(input.Enabled, true)), - nullInt(input.RPMLimit), - nullInt(input.MonthlyQuota), - nullInt(input.RequestTimeoutMS), - now, - now, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "SERVICE_CREATE_FAILED", "Service slug or route already exists") - return - } - - s.audit(r.Context(), ctx.UserID, "services.create", "service", id, input) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) -} - -func (s *Server) handleServicesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Service ID is required") - return - } - - current, err := s.getServiceByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found") - return - } - - var patch serviceInput - if err := decodeJSON(r.Body, &patch); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - name := firstNonEmpty(patch.Name, asString(current["name"])) - routePrefix := normalizePathPrefix(firstNonEmpty(patch.RoutePrefix, asString(current["routePrefix"])), "/") - if routePrefix == "/" && !s.Cfg.AllowRootRoutePrefix { - writeError(w, http.StatusBadRequest, "ROOT_ROUTE_DISABLED", "Route prefix '/' is disabled") - return - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE services SET - name = ?, slug = ?, upstream_url = ?, route_prefix = ?, health_path = ?, - upstream_auth_header = ?, upstream_auth_value = ?, internal_token = ?, enabled = ?, - rpm_limit = ?, monthly_quota = ?, request_timeout_ms = ?, updated_at = ? - WHERE id = ? - `, - name, - slugify(firstNonEmpty(patch.Slug, asString(current["slug"])), "service"), - firstNonEmpty(patch.UpstreamURL, asString(current["upstreamUrl"])), - routePrefix, - normalizePathPrefix(firstNonEmpty(patch.HealthPath, asString(current["healthPath"])), "/health"), - nullIfEmpty(firstNonEmpty(patch.UpstreamAuthHeader, asString(current["upstreamAuthHeader"]))), - nullIfEmpty(firstNonEmpty(patch.UpstreamAuthValue, asString(current["upstreamAuthValue"]))), - nullIfEmpty(firstNonEmpty(patch.InternalToken, asString(current["internalToken"]))), - boolToInt(defaultBool(patch.Enabled, asBool(current["enabled"]))), - nullInt(patch.RPMLimitOr(current["rpmLimit"])), - nullInt(patch.MonthlyQuotaOr(current["monthlyQuota"])), - nullInt(patch.TimeoutOr(current["requestTimeoutMs"])), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "SERVICE_UPDATE_FAILED", "Failed to update service") - return - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleServicesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - service, err := s.getServiceByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "SERVICE_NOT_FOUND", "Service not found") - return - } - - timeoutMS := intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds())) - client := &http.Client{Timeout: time.Duration(timeoutMS) * time.Millisecond} - target := strings.TrimRight(asString(service["upstreamUrl"]), "/") + normalizePathPrefix(asString(service["healthPath"]), "/health") - - req, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, target, nil) - if header := asString(service["upstreamAuthHeader"]); header != "" { - req.Header.Set(header, asString(service["upstreamAuthValue"])) - } - if token := asString(service["internalToken"]); token != "" { - req.Header.Set(s.Cfg.ServiceTokenHeader, token) - } - res, err := client.Do(req) - status := 0 - message := "" - ok := false - if err != nil { - status = http.StatusBadGateway - message = err.Error() - } else { - defer res.Body.Close() - status = res.StatusCode - message = fmt.Sprintf("HTTP %d", res.StatusCode) - ok = res.StatusCode >= 200 && res.StatusCode < 300 - } - - validationStatus := "failed" - if ok { - validationStatus = "healthy" - } - - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE services - SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ? - WHERE id = ? - `, storage.NowISO(), validationStatus, message, storage.NowISO(), id) - - if !ok { - s.logIncident(r.Context(), sql.NullString{String: id, Valid: true}, sql.NullString{}, "SERVICE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true}) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}}) - _ = ctx -} - -func (s *Server) handleDatabasesList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM database_connections - ORDER BY created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list database connections") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, name, slug, provider, connURL, createdAt, updatedAt string - var enabled int - var lastAt, lastStatus, lastMessage sql.NullString - if err := rows.Scan(&id, &name, &slug, &provider, &connURL, &enabled, &lastAt, &lastStatus, &lastMessage, &createdAt, &updatedAt); err != nil { - continue - } - items = append(items, map[string]any{ - "id": id, - "name": name, - "slug": slug, - "provider": provider, - "target": extractDatabaseTarget(provider, connURL), - "maskedConnectionUrl": maskDatabaseURL(provider, connURL), - "enabled": enabled == 1, - "lastValidationAt": nullString(lastAt), - "lastValidationStatus": nullString(lastStatus), - "lastValidationMessage": nullString(lastMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleDatabasesCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Slug string `json:"slug"` - Provider string `json:"provider"` - ConnectionURL string `json:"connectionUrl"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - provider := normalizeProvider(input.Provider) - if provider == "" { - writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql") - return - } - if strings.TrimSpace(input.ConnectionURL) == "" { - writeError(w, http.StatusBadRequest, "INVALID_CONNECTION", "Connection URL is required") - return - } - - now := storage.NowISO() - id, _ := auth.RandomID("dbc") - slug := slugify(firstNonEmpty(input.Slug, input.Name+"-"+provider), "database") - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO database_connections (id, name, slug, provider, connection_url, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, id, strings.TrimSpace(input.Name), slug, provider, strings.TrimSpace(input.ConnectionURL), boolToInt(defaultBool(input.Enabled, true)), now, now) - if err != nil { - writeError(w, http.StatusBadRequest, "DATABASE_CREATE_FAILED", "Database connection slug already exists") - return - } - writeJSON(w, http.StatusCreated, map[string]any{"ok": true, "data": map[string]any{"id": id, "slug": slug}}) - _ = ctx -} - -func (s *Server) handleDatabasesPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "Database connection ID is required") - return - } - - var input struct { - Name *string `json:"name"` - Slug *string `json:"slug"` - Provider *string `json:"provider"` - ConnectionURL *string `json:"connectionUrl"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - current, err := s.getDatabaseByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found") - return - } - - provider := asString(current["provider"]) - if input.Provider != nil { - norm := normalizeProvider(*input.Provider) - if norm == "" { - writeError(w, http.StatusBadRequest, "INVALID_PROVIDER", "Provider must be sqlite, postgres, or mysql") - return - } - provider = norm - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE database_connections - SET name = ?, slug = ?, provider = ?, connection_url = ?, enabled = ?, updated_at = ? - WHERE id = ? - `, - firstNonEmptyPtr(input.Name, asString(current["name"])), - slugify(firstNonEmptyPtr(input.Slug, asString(current["slug"])), "database"), - provider, - firstNonEmptyPtr(input.ConnectionURL, asString(current["connectionUrl"])), - boolToInt(defaultBool(input.Enabled, asBool(current["enabled"]))), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "DATABASE_UPDATE_FAILED", "Failed to update database connection") - return - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleDatabasesValidate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - database, err := s.getDatabaseByID(r.Context(), id) - if err != nil { - writeError(w, http.StatusNotFound, "DATABASE_NOT_FOUND", "Database connection not found") - return - } - - provider := asString(database["provider"]) - connectionURL := asString(database["connectionUrl"]) - ok, status, message := s.validateDatabaseConnection(r.Context(), provider, connectionURL) - state := "failed" - if ok { - state = "healthy" - } - _, _ = s.Store.DB.ExecContext(r.Context(), ` - UPDATE database_connections - SET last_validation_at = ?, last_validation_status = ?, last_validation_message = ?, updated_at = ? - WHERE id = ? - `, storage.NowISO(), state, message, storage.NowISO(), id) - - if !ok { - s.logIncident(r.Context(), sql.NullString{}, sql.NullString{}, "DATABASE_VALIDATION_FAILED", message, "medium", sql.NullInt64{Int64: int64(status), Valid: true}) - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "validation": map[string]any{"ok": ok, "status": status, "message": message}}) - _ = ctx -} - -func (s *Server) handleKeysList(w http.ResponseWriter, r *http.Request, ctx *authContext) { - rows, err := s.Store.DB.QueryContext(r.Context(), ` - SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at, last_used_at - FROM api_keys - ORDER BY created_at DESC - `) - if err != nil { - writeError(w, http.StatusInternalServerError, "DB_ERROR", "Failed to list API keys") - return - } - defer rows.Close() - - items := []map[string]any{} - for rows.Next() { - var id, name, keyPrefix, plan, allowed, createdAt, updatedAt string - var enabled int - var rpm, monthly sql.NullInt64 - var lastUsed sql.NullString - if err := rows.Scan(&id, &name, &keyPrefix, &plan, &allowed, &enabled, &rpm, &monthly, &createdAt, &updatedAt, &lastUsed); err != nil { - continue - } - allowedServices := []string{} - _ = json.Unmarshal([]byte(allowed), &allowedServices) - items = append(items, map[string]any{ - "id": id, - "name": name, - "keyPrefix": keyPrefix, - "plan": plan, - "allowedServiceIds": allowedServices, - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpm), - "monthlyQuota": nullIntValue(monthly), - "createdAt": createdAt, - "updatedAt": updatedAt, - "lastUsedAt": nullString(lastUsed), - }) - } - writeJSON(w, http.StatusOK, map[string]any{"ok": true, "data": items}) - _ = ctx -} - -func (s *Server) handleKeysCreate(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Name string `json:"name"` - Plan string `json:"plan"` - AllowedServiceIDs []string `json:"allowedServiceIds"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - name := strings.TrimSpace(input.Name) - if name == "" { - writeError(w, http.StatusBadRequest, "INVALID_NAME", "API key name is required") - return - } - plan := strings.ToLower(strings.TrimSpace(firstNonEmpty(input.Plan, "pro"))) - rpmLimit, monthlyQuota := s.planDefaults(plan) - if input.RPMLimit != nil { - rpmLimit = input.RPMLimit - } - if input.MonthlyQuota != nil { - monthlyQuota = input.MonthlyQuota - } - - rawKey, _ := auth.RandomToken(28) - rawKey = "apy_" + rawKey - keyHash := auth.HashToken(rawKey) - id, _ := auth.RandomID("key") - now := storage.NowISO() - - _, err := s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO api_keys (id, name, key_hash, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `, - id, - name, - keyHash, - rawKey[:12], - plan, - mustJSON(input.AllowedServiceIDs), - boolToInt(defaultBool(input.Enabled, true)), - nullInt(rpmLimit), - nullInt(monthlyQuota), - now, - now, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "KEY_CREATE_FAILED", "Failed to create API key") - return - } - - writeJSON(w, http.StatusCreated, map[string]any{ - "ok": true, - "data": map[string]any{ - "key": rawKey, - "item": map[string]any{"id": id, "name": name, "plan": plan}, - }, - }) - _ = ctx -} - -func (s *Server) handleKeysPatch(w http.ResponseWriter, r *http.Request, ctx *authContext) { - id := r.PathValue("id") - if id == "" { - writeError(w, http.StatusBadRequest, "INVALID_ID", "API key ID is required") - return - } - var input struct { - Name *string `json:"name"` - Plan *string `json:"plan"` - AllowedServiceIDs []string `json:"allowedServiceIds"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - Enabled *bool `json:"enabled"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - - current := struct { - Name string - Plan string - Allowed string - Enabled int - RPM sql.NullInt64 - Monthly sql.NullInt64 - }{} - err := s.Store.DB.QueryRowContext(r.Context(), ` - SELECT name, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota - FROM api_keys WHERE id = ? - `, id).Scan(¤t.Name, ¤t.Plan, ¤t.Allowed, ¤t.Enabled, ¤t.RPM, ¤t.Monthly) - if err != nil { - writeError(w, http.StatusNotFound, "KEY_NOT_FOUND", "API key not found") - return - } - - plan := current.Plan - if input.Plan != nil { - plan = strings.ToLower(strings.TrimSpace(*input.Plan)) - } - - allowed := current.Allowed - if input.AllowedServiceIDs != nil { - allowed = mustJSON(input.AllowedServiceIDs) - } - - _, err = s.Store.DB.ExecContext(r.Context(), ` - UPDATE api_keys - SET name = ?, plan = ?, allowed_service_ids = ?, enabled = ?, rpm_limit = ?, monthly_quota = ?, updated_at = ? - WHERE id = ? - `, - firstNonEmptyPtr(input.Name, current.Name), - plan, - allowed, - boolToInt(defaultBool(input.Enabled, current.Enabled == 1)), - nullInt(coalesceIntPtr(input.RPMLimit, nullIntValue(current.RPM))), - nullInt(coalesceIntPtr(input.MonthlyQuota, nullIntValue(current.Monthly))), - storage.NowISO(), - id, - ) - if err != nil { - writeError(w, http.StatusBadRequest, "KEY_UPDATE_FAILED", "Failed to update API key") - return - } - - writeJSON(w, http.StatusOK, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleAnalyticsOps(w http.ResponseWriter, r *http.Request, ctx *authContext) { - counts := map[string]int{} - for _, table := range []string{"users", "services", "database_connections", "api_keys"} { - var count int - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM `+table).Scan(&count) - counts[table] = count - } - - period := time.Now().UTC().Format("2006-01") - var monthlyRequests int - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT COALESCE(SUM(request_count), 0) FROM usage_counters WHERE period_month = ?`, period).Scan(&monthlyRequests) - - severityRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT severity, COUNT(*) FROM incident_events - WHERE occurred_at >= datetime('now', '-24 hours') - GROUP BY severity - `) - incidentsBySeverity := map[string]int{} - if severityRows != nil { - defer severityRows.Close() - for severityRows.Next() { - var severity string - var count int - if err := severityRows.Scan(&severity, &count); err == nil { - incidentsBySeverity[severity] = count - } - } - } - - healthRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT COALESCE(last_validation_status, 'unknown') AS status, COUNT(*) - FROM services - GROUP BY status - `) - healthCounts := map[string]int{} - if healthRows != nil { - defer healthRows.Close() - for healthRows.Next() { - var status string - var count int - if err := healthRows.Scan(&status, &count); err == nil { - healthCounts[status] = count - } - } - } - - tsRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT strftime('%Y-%m-%dT%H:00:00Z', occurred_at) AS bucket, COUNT(*) - FROM metrics_timeseries - WHERE metric = 'request_total' AND occurred_at >= datetime('now', '-24 hours') - GROUP BY bucket - ORDER BY bucket ASC - `) - hourly := []map[string]any{} - if tsRows != nil { - defer tsRows.Close() - for tsRows.Next() { - var bucket string - var count int - if err := tsRows.Scan(&bucket, &count); err == nil { - hourly = append(hourly, map[string]any{"bucket": bucket, "value": count}) - } - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "counts": map[string]any{ - "users": counts["users"], - "services": counts["services"], - "databases": counts["database_connections"], - "keys": counts["api_keys"], - }, - "requests": map[string]any{ - "period": period, - "total": monthlyRequests, - "hourly": hourly, - }, - "incidentsBySeverity": incidentsBySeverity, - "serviceHealth": healthCounts, - }, - }) - _ = ctx -} - -func (s *Server) handleAnalyticsTraffic(w http.ResponseWriter, r *http.Request, ctx *authContext) { - from := time.Now().Add(-7 * 24 * time.Hour) - to := time.Now() - if value := strings.TrimSpace(r.URL.Query().Get("from")); value != "" { - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - from = parsed - } - } - if value := strings.TrimSpace(r.URL.Query().Get("to")); value != "" { - if parsed, err := time.Parse(time.RFC3339, value); err == nil { - to = parsed - } - } - - traffic, err := s.Umami.FetchTraffic(r.Context(), from, to) - if err == nil { - _, _ = s.Store.DB.ExecContext(r.Context(), ` - INSERT INTO umami_sync_cache (cache_key, payload_json, updated_at) - VALUES ('traffic', ?, ?) - ON CONFLICT(cache_key) DO UPDATE SET payload_json = excluded.payload_json, updated_at = excluded.updated_at - `, mustJSON(traffic), storage.NowISO()) - } else { - var payload string - _ = s.Store.DB.QueryRowContext(r.Context(), `SELECT payload_json FROM umami_sync_cache WHERE cache_key = 'traffic'`).Scan(&payload) - if payload != "" { - _ = json.Unmarshal([]byte(payload), &traffic) - } - if traffic == nil { - traffic = map[string]any{"enabled": false, "error": err.Error()} - } - } - - eventRows, _ := s.Store.DB.QueryContext(r.Context(), ` - SELECT COALESCE(json_extract(labels_json, '$.path'), 'unknown') AS path, COUNT(*) - FROM metrics_timeseries - WHERE metric = 'client_event' AND occurred_at >= datetime('now', '-7 days') - GROUP BY path - ORDER BY COUNT(*) DESC - LIMIT 20 - `) - events := []map[string]any{} - if eventRows != nil { - defer eventRows.Close() - for eventRows.Next() { - var page string - var count int - if err := eventRows.Scan(&page, &count); err == nil { - events = append(events, map[string]any{"path": page, "count": count}) - } - } - } - - writeJSON(w, http.StatusOK, map[string]any{ - "ok": true, - "data": map[string]any{ - "umami": traffic, - "clientEvents": events, - }, - }) - _ = ctx -} - -func (s *Server) handleAnalyticsEvents(w http.ResponseWriter, r *http.Request, ctx *authContext) { - var input struct { - Event string `json:"event"` - Path string `json:"path"` - Meta map[string]any `json:"meta"` - } - if err := decodeJSON(r.Body, &input); err != nil { - writeError(w, http.StatusBadRequest, "INVALID_BODY", err.Error()) - return - } - labels := map[string]any{ - "event": firstNonEmpty(input.Event, "event"), - "path": firstNonEmpty(input.Path, "unknown"), - "meta": input.Meta, - } - _ = s.logMetric("client_event", 1, labels) - writeJSON(w, http.StatusCreated, map[string]any{"ok": true}) - _ = ctx -} - -func (s *Server) handleGatewayOrUI(w http.ResponseWriter, r *http.Request) { - if strings.HasPrefix(r.URL.Path, "/api/") { - writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found") - return - } - - uiBasePath := normalizePathPrefix(s.Cfg.DashboardUIBasePath, "/") - uiRequestPath, uiPathMatched := webPathForRequest(r.URL.Path, uiBasePath) - if uiBasePath != "/" && uiPathMatched { - s.serveWeb(w, uiRequestPath) - return - } - - service, ok := s.matchService(r.Context(), r.URL.Path) - if ok { - s.handleProxy(w, r, service) - return - } - - if uiBasePath == "/" { - s.serveWeb(w, uiRequestPath) - return - } - - writeError(w, http.StatusNotFound, "NOT_FOUND", "Route not found") -} - -func (s *Server) serveWeb(w http.ResponseWriter, requestPath string) { - sub, err := fs.Sub(staticFS, "static") - if err != nil { - writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available") - return - } - - reqPath := strings.TrimPrefix(path.Clean("/"+strings.TrimSpace(requestPath)), "/") - if reqPath == "." { - reqPath = "" - } - if reqPath == "" { - reqPath = "index.html" - } - if _, err := fs.Stat(sub, reqPath); err != nil { - reqPath = "index.html" - } - - data, err := fs.ReadFile(sub, reqPath) - if err != nil { - writeError(w, http.StatusNotFound, "FRONTEND_NOT_BUILT", "Frontend build is not available") - return - } - - if contentType := mime.TypeByExtension(path.Ext(reqPath)); contentType != "" { - w.Header().Set("Content-Type", contentType) - } - _, _ = w.Write(data) -} - -func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request, service map[string]any) { - secret := strings.TrimSpace(r.Header.Get(s.Cfg.APIKeyHeader)) - if secret == "" { - writeError(w, http.StatusUnauthorized, "API_KEY_MISSING", fmt.Sprintf("Missing API key header '%s'", s.Cfg.APIKeyHeader)) - return - } - - apiKey, err := s.getAPIKeyBySecret(r.Context(), secret) - if err != nil { - writeError(w, http.StatusUnauthorized, "API_KEY_INVALID", "Invalid API key") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), sql.NullString{}, "API_KEY_INVALID", "Invalid API key", "medium", sql.NullInt64{Int64: 401, Valid: true}) - return - } - if !asBool(apiKey["enabled"]) { - writeError(w, http.StatusForbidden, "API_KEY_DISABLED", "API key is disabled") - return - } - if !asBool(service["enabled"]) { - writeError(w, http.StatusServiceUnavailable, "SERVICE_DISABLED", "Service is disabled") - return - } - - if !serviceAllowed(apiKey, asString(service["id"]), asString(service["slug"]), asString(service["routePrefix"])) { - writeError(w, http.StatusForbidden, "SERVICE_SCOPE_DENIED", "API key is not allowed for this service") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "SERVICE_SCOPE_DENIED", "API key attempted unauthorized service access", "medium", sql.NullInt64{Int64: 403, Valid: true}) - return - } - - period := time.Now().UTC().Format("2006-01") - serviceLimitRPM := nullIntValueFromAny(service["rpmLimit"]) - keyLimitRPM := nullIntValueFromAny(apiKey["rpmLimit"]) - serviceLimitMonthly := nullIntValueFromAny(service["monthlyQuota"]) - keyLimitMonthly := nullIntValueFromAny(apiKey["monthlyQuota"]) - - effectiveRPM := minPositive(serviceLimitRPM, keyLimitRPM) - effectiveMonthly := minPositive(serviceLimitMonthly, keyLimitMonthly) - - if effectiveMonthly != nil { - used := s.usageCount(r.Context(), asString(apiKey["id"]), asString(service["id"]), period) - if used >= *effectiveMonthly { - writeError(w, http.StatusTooManyRequests, "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "MONTHLY_QUOTA_EXCEEDED", "Monthly quota exceeded", "high", sql.NullInt64{Int64: 429, Valid: true}) - return - } - } - - if effectiveRPM != nil { - if ok, retryAfter := s.allowRPM(asString(apiKey["id"])+":"+asString(service["id"]), *effectiveRPM); !ok { - w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) - writeError(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded") - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "RATE_LIMIT_EXCEEDED", "Rate limit exceeded", "medium", sql.NullInt64{Int64: 429, Valid: true}) - return - } - } - - targetURL, err := gateway.BuildTargetURL(asString(service["upstreamUrl"]), asString(service["routePrefix"]), r.URL.Path, r.URL.RawQuery) - if err != nil { - writeError(w, http.StatusBadGateway, "UPSTREAM_INVALID", "Invalid upstream URL") - return - } - - headers := map[string]string{} - if upstreamHeader := asString(service["upstreamAuthHeader"]); upstreamHeader != "" { - headers[upstreamHeader] = asString(service["upstreamAuthValue"]) - } - if token := asString(service["internalToken"]); token != "" { - headers[s.Cfg.ServiceTokenHeader] = token - } - - r.Header.Del(s.Cfg.APIKeyHeader) - - timeout := time.Duration(intFromAny(service["requestTimeoutMs"], int(s.Cfg.DefaultServiceTimeout.Milliseconds()))) * time.Millisecond - client := &http.Client{Timeout: timeout} - - statusCode, _, proxyErr := gateway.ProxyRequest(client, w, r, targetURL, headers) - if proxyErr != nil { - status := http.StatusBadGateway - if errors.Is(proxyErr, context.DeadlineExceeded) || strings.Contains(strings.ToLower(proxyErr.Error()), "timeout") { - status = http.StatusGatewayTimeout - } - writeError(w, status, "UPSTREAM_ERROR", proxyErr.Error()) - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_ERROR", proxyErr.Error(), "high", sql.NullInt64{Int64: int64(status), Valid: true}) - return - } - - s.incrementUsage(r.Context(), asString(apiKey["id"]), asString(service["id"]), period) - _, _ = s.Store.DB.ExecContext(r.Context(), `UPDATE api_keys SET last_used_at = ?, updated_at = ? WHERE id = ?`, storage.NowISO(), storage.NowISO(), asString(apiKey["id"])) - _ = s.logMetric("request_total", 1, map[string]any{"service": asString(service["slug"]), "status": statusCode}) - - if statusCode >= 500 { - _ = s.logIncident(r.Context(), nullStringValue(service["id"]), nullStringValue(apiKey["id"]), "UPSTREAM_SERVER_ERROR", fmt.Sprintf("Upstream returned %d", statusCode), "high", sql.NullInt64{Int64: int64(statusCode), Valid: true}) - } -} - -func (s *Server) resolveAuth(r *http.Request) (*authContext, error) { - accessCookie, err := r.Cookie(s.Cfg.SessionAccessCookie) - if err != nil || strings.TrimSpace(accessCookie.Value) == "" { - return nil, errors.New("access cookie missing") - } - return s.resolveAuthFromTokenHash(r.Context(), auth.HashToken(accessCookie.Value)) -} - -func (s *Server) resolveAuthFromTokenHash(ctx context.Context, accessTokenHash string) (*authContext, error) { - var user struct { - ID string - Email string - Enabled int - ForcePasswordReset int - SessionID string - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT u.id, u.email, u.enabled, u.force_password_reset, sess.id - FROM sessions sess - JOIN users u ON u.id = sess.user_id - WHERE sess.access_token_hash = ? - AND sess.revoked_at IS NULL - AND sess.access_expires_at > ? - `, accessTokenHash, storage.NowISO()).Scan(&user.ID, &user.Email, &user.Enabled, &user.ForcePasswordReset, &user.SessionID) - if err != nil { - return nil, err - } - - roles, permissions, err := s.userAccess(ctx, user.ID) - if err != nil { - return nil, err - } - - return &authContext{ - UserID: user.ID, - Email: user.Email, - Enabled: user.Enabled == 1, - ForcePasswordReset: user.ForcePasswordReset == 1, - Roles: roles, - Permissions: permissions, - SessionID: user.SessionID, - }, nil -} - -func (s *Server) userAccess(ctx context.Context, userID string) ([]string, map[string]bool, error) { - roles := []string{} - roleRows, err := s.Store.DB.QueryContext(ctx, ` - SELECT r.slug - FROM user_roles ur - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ? AND r.enabled = 1 - `, userID) - if err != nil { - return nil, nil, err - } - for roleRows.Next() { - var slug string - if err := roleRows.Scan(&slug); err == nil { - roles = append(roles, slug) - } - } - roleRows.Close() - - permissions := map[string]bool{} - permRows, err := s.Store.DB.QueryContext(ctx, ` - SELECT DISTINCT p.code - FROM user_roles ur - JOIN role_permissions rp ON rp.role_id = ur.role_id - JOIN permissions p ON p.id = rp.permission_id - JOIN roles r ON r.id = ur.role_id - WHERE ur.user_id = ? AND r.enabled = 1 - `, userID) - if err != nil { - return roles, permissions, err - } - for permRows.Next() { - var code string - if err := permRows.Scan(&code); err == nil { - permissions[code] = true - } - } - permRows.Close() - - return roles, permissions, nil -} - -func (s *Server) createSession(ctx context.Context, userID string) (string, string, string, error) { - sessionID, _ := auth.RandomID("ses") - accessToken, _ := auth.RandomToken(32) - refreshToken, _ := auth.RandomToken(48) - accessHash := auth.HashToken(accessToken) - refreshHash := auth.HashToken(refreshToken) - - now := storage.NowISO() - accessExp := time.Now().Add(s.Cfg.AccessTokenTTL).UTC().Format(time.RFC3339) - refreshExp := time.Now().Add(s.Cfg.RefreshTokenTTL).UTC().Format(time.RFC3339) - - _, err := s.Store.DB.ExecContext(ctx, ` - INSERT INTO sessions (id, user_id, access_token_hash, refresh_token_hash, access_expires_at, refresh_expires_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `, sessionID, userID, accessHash, refreshHash, accessExp, refreshExp, now, now) - if err != nil { - return "", "", "", err - } - - return sessionID, accessToken, refreshToken, nil -} - -func (s *Server) setAuthCookies(w http.ResponseWriter, accessToken, refreshToken string) { - accessCookie := &http.Cookie{ - Name: s.Cfg.SessionAccessCookie, - Value: accessToken, - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.Cfg.AccessTokenTTL), - } - if s.Cfg.CookieDomain != "" { - accessCookie.Domain = s.Cfg.CookieDomain - } - - refreshCookie := &http.Cookie{ - Name: s.Cfg.SessionRefreshCookie, - Value: refreshToken, - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: time.Now().Add(s.Cfg.RefreshTokenTTL), - } - if s.Cfg.CookieDomain != "" { - refreshCookie.Domain = s.Cfg.CookieDomain - } - - http.SetCookie(w, accessCookie) - http.SetCookie(w, refreshCookie) -} - -func (s *Server) clearAuthCookies(w http.ResponseWriter) { - expired := time.Unix(0, 0) - for _, cookieName := range []string{s.Cfg.SessionAccessCookie, s.Cfg.SessionRefreshCookie} { - cookie := &http.Cookie{ - Name: cookieName, - Value: "", - Path: "/", - HttpOnly: true, - Secure: s.Cfg.CookieSecure, - SameSite: http.SameSiteLaxMode, - Expires: expired, - MaxAge: -1, - } - if s.Cfg.CookieDomain != "" { - cookie.Domain = s.Cfg.CookieDomain - } - http.SetCookie(w, cookie) - } -} - -func (s *Server) hasUsers(ctx context.Context) bool { - var count int - _ = s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) - return count > 0 -} - -func (s *Server) isLoginRateLimited(r *http.Request, email string) bool { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - entry := s.loginAttempts[key] - now := time.Now() - if now.Sub(entry.WindowStart) > 15*time.Minute { - entry = loginWindow{WindowStart: now, Count: 0} - } - return entry.Count >= 10 -} - -func (s *Server) markLoginAttempt(r *http.Request, email string) { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - entry := s.loginAttempts[key] - now := time.Now() - if now.Sub(entry.WindowStart) > 15*time.Minute { - entry = loginWindow{WindowStart: now, Count: 0} - } - entry.Count++ - s.loginAttempts[key] = entry -} - -func (s *Server) clearLoginAttempts(r *http.Request, email string) { - key := r.RemoteAddr + ":" + email - s.loginMu.Lock() - defer s.loginMu.Unlock() - delete(s.loginAttempts, key) -} - -func (s *Server) resolveRoleIDs(ctx context.Context, roleIDs []string, roleSlugs []string) ([]string, error) { - resolved := []string{} - if len(roleIDs) > 0 { - for _, roleID := range roleIDs { - id := strings.TrimSpace(roleID) - if id == "" { - continue - } - var exists int - if err := s.Store.DB.QueryRowContext(ctx, `SELECT COUNT(*) FROM roles WHERE id = ?`, id).Scan(&exists); err != nil || exists == 0 { - return nil, fmt.Errorf("role %s not found", id) - } - resolved = append(resolved, id) - } - return uniqueStrings(resolved), nil - } - for _, slug := range roleSlugs { - id, err := s.roleIDBySlug(ctx, slug) - if err != nil { - return nil, err - } - resolved = append(resolved, id) - } - return uniqueStrings(resolved), nil -} - -func (s *Server) roleIDBySlug(ctx context.Context, slug string) (string, error) { - id := "" - err := s.Store.DB.QueryRowContext(ctx, `SELECT id FROM roles WHERE slug = ?`, strings.ToLower(strings.TrimSpace(slug))).Scan(&id) - if err != nil { - return "", fmt.Errorf("role %s not found", slug) - } - return id, nil -} - -func (s *Server) replaceRolePermissionsTx(ctx context.Context, tx *sql.Tx, roleID string, permissionCodes []string) error { - if _, err := tx.ExecContext(ctx, `DELETE FROM role_permissions WHERE role_id = ?`, roleID); err != nil { - return err - } - now := storage.NowISO() - for _, code := range permissionCodes { - permCode := strings.TrimSpace(code) - if permCode == "" { - continue - } - permID := "" - if err := tx.QueryRowContext(ctx, `SELECT id FROM permissions WHERE code = ?`, permCode).Scan(&permID); err != nil { - return fmt.Errorf("permission not found: %s", permCode) - } - if _, err := tx.ExecContext(ctx, `INSERT INTO role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)`, roleID, permID, now); err != nil { - return err - } - } - return nil -} - -func (s *Server) getServiceByID(ctx context.Context, id string) (map[string]any, error) { - row := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM services WHERE id = ? - `, id) - return scanServiceRow(row) -} - -func (s *Server) getDatabaseByID(ctx context.Context, id string) (map[string]any, error) { - var item struct { - ID, Name, Slug, Provider, ConnectionURL, CreatedAt, UpdatedAt string - Enabled int - LastAt, LastStatus, LastMessage sql.NullString - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, slug, provider, connection_url, enabled, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM database_connections - WHERE id = ? - `, id).Scan(&item.ID, &item.Name, &item.Slug, &item.Provider, &item.ConnectionURL, &item.Enabled, &item.LastAt, &item.LastStatus, &item.LastMessage, &item.CreatedAt, &item.UpdatedAt) - if err != nil { - return nil, err - } - return map[string]any{ - "id": item.ID, - "name": item.Name, - "slug": item.Slug, - "provider": item.Provider, - "connectionUrl": item.ConnectionURL, - "target": extractDatabaseTarget(item.Provider, item.ConnectionURL), - "maskedConnectionUrl": maskDatabaseURL(item.Provider, item.ConnectionURL), - "enabled": item.Enabled == 1, - "lastValidationAt": nullString(item.LastAt), - "lastValidationStatus": nullString(item.LastStatus), - "lastValidationMessage": nullString(item.LastMessage), - "createdAt": item.CreatedAt, - "updatedAt": item.UpdatedAt, - }, nil -} - -func (s *Server) validateDatabaseConnection(ctx context.Context, provider, connectionURL string) (bool, int, string) { - provider = normalizeProvider(provider) - if provider == "" { - return false, http.StatusBadRequest, "Unsupported database provider" - } - - timeoutCtx, cancel := context.WithTimeout(ctx, s.Cfg.DefaultServiceTimeout) - defer cancel() - - sqlDriver := "" - driverConn := connectionURL - switch provider { - case "sqlite": - sqlDriver = "sqlite" - if !strings.HasPrefix(driverConn, "file:") && driverConn != ":memory:" { - if abs, err := pathAbs(driverConn); err == nil { - driverConn = "file:" + abs - } - } - case "postgres": - sqlDriver = "pgx" - case "mysql": - sqlDriver = "mysql" - } - - db, err := sql.Open(sqlDriver, driverConn) - if err != nil { - return false, http.StatusBadGateway, err.Error() - } - defer db.Close() - - db.SetConnMaxLifetime(2 * time.Minute) - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - - if err := db.PingContext(timeoutCtx); err != nil { - return false, http.StatusBadGateway, err.Error() - } - return true, http.StatusOK, fmt.Sprintf("%s connection validated", strings.Title(provider)) -} - -func (s *Server) getAPIKeyBySecret(ctx context.Context, secret string) (map[string]any, error) { - hash := auth.HashToken(secret) - var item struct { - ID, Name, KeyPrefix, Plan, AllowedServiceIDs, CreatedAt, UpdatedAt string - Enabled int - RPMLimit, MonthlyQuota sql.NullInt64 - } - err := s.Store.DB.QueryRowContext(ctx, ` - SELECT id, name, key_prefix, plan, allowed_service_ids, enabled, rpm_limit, monthly_quota, created_at, updated_at - FROM api_keys - WHERE key_hash = ? - `, hash).Scan(&item.ID, &item.Name, &item.KeyPrefix, &item.Plan, &item.AllowedServiceIDs, &item.Enabled, &item.RPMLimit, &item.MonthlyQuota, &item.CreatedAt, &item.UpdatedAt) - if err != nil { - return nil, err - } - allowed := []string{} - _ = json.Unmarshal([]byte(item.AllowedServiceIDs), &allowed) - return map[string]any{ - "id": item.ID, - "name": item.Name, - "keyPrefix": item.KeyPrefix, - "plan": item.Plan, - "allowedServiceIds": allowed, - "enabled": item.Enabled == 1, - "rpmLimit": nullIntValue(item.RPMLimit), - "monthlyQuota": nullIntValue(item.MonthlyQuota), - }, nil -} - -func (s *Server) planDefaults(plan string) (*int, *int) { - p := strings.ToLower(strings.TrimSpace(plan)) - switch p { - case "free": - return intPtr(s.Cfg.FreeRPM), intPtr(s.Cfg.FreeMonthlyQuota) - case "business": - return intPtr(s.Cfg.BusinessRPM), intPtr(s.Cfg.BusinessMonthlyQuota) - case "enterprise": - return nil, nil - default: - return intPtr(s.Cfg.ProRPM), intPtr(s.Cfg.ProMonthlyQuota) - } -} - -func (s *Server) matchService(ctx context.Context, requestPath string) (map[string]any, bool) { - rows, err := s.Store.DB.QueryContext(ctx, ` - SELECT id, name, slug, upstream_url, route_prefix, health_path, upstream_auth_header, upstream_auth_value, internal_token, enabled, rpm_limit, monthly_quota, request_timeout_ms, last_validation_at, last_validation_status, last_validation_message, created_at, updated_at - FROM services - WHERE enabled = 1 - ORDER BY LENGTH(route_prefix) DESC, route_prefix DESC - `) - if err != nil { - return nil, false - } - defer rows.Close() - - for rows.Next() { - item, err := scanService(rows) - if err != nil { - continue - } - prefix := asString(item["routePrefix"]) - if prefix == "/" || requestPath == prefix || strings.HasPrefix(requestPath, prefix+"/") { - return item, true - } - } - return nil, false -} - -func (s *Server) usageCount(ctx context.Context, apiKeyID, serviceID, period string) int { - var count int - _ = s.Store.DB.QueryRowContext(ctx, ` - SELECT COALESCE(request_count, 0) - FROM usage_counters - WHERE api_key_id = ? AND service_id = ? AND period_month = ? - `, apiKeyID, serviceID, period).Scan(&count) - return count -} - -func (s *Server) incrementUsage(ctx context.Context, apiKeyID, serviceID, period string) { - _, _ = s.Store.DB.ExecContext(ctx, ` - INSERT INTO usage_counters (api_key_id, service_id, period_month, request_count, updated_at) - VALUES (?, ?, ?, 1, ?) - ON CONFLICT(api_key_id, service_id, period_month) - DO UPDATE SET request_count = request_count + 1, updated_at = excluded.updated_at - `, apiKeyID, serviceID, period, storage.NowISO()) -} - -func (s *Server) allowRPM(bucket string, limit int) (bool, int) { - if limit <= 0 { - return true, 0 - } - now := time.Now().UTC() - windowStart := now.Truncate(time.Minute) - key := bucket + ":" + windowStart.Format(time.RFC3339) - - s.rpmMu.Lock() - defer s.rpmMu.Unlock() - - for k, v := range s.rpmUsage { - if now.Sub(v.WindowStart) > 2*time.Minute { - delete(s.rpmUsage, k) - } - } - - entry := s.rpmUsage[key] - if entry.WindowStart.IsZero() { - entry.WindowStart = windowStart - } - if entry.Count >= limit { - retry := int(windowStart.Add(time.Minute).Sub(now).Seconds()) - if retry < 1 { - retry = 1 - } - return false, retry - } - entry.Count++ - s.rpmUsage[key] = entry - return true, 0 -} - -func (s *Server) logIncident(ctx context.Context, serviceID, apiKeyID sql.NullString, code, message, severity string, httpStatus sql.NullInt64) error { - _, err := s.Store.DB.ExecContext(ctx, ` - INSERT INTO incident_events (service_id, api_key_id, code, message, severity, http_status, count, occurred_at) - VALUES (?, ?, ?, ?, ?, ?, 1, ?) - `, serviceID, apiKeyID, code, message, severity, httpStatus, storage.NowISO()) - return err -} - -func (s *Server) logMetric(metric string, value float64, labels map[string]any) error { - _, err := s.Store.DB.Exec(` - INSERT INTO metrics_timeseries (metric, value, labels_json, occurred_at) - VALUES (?, ?, ?, ?) - `, metric, value, mustJSON(labels), storage.NowISO()) - return err -} - -func (s *Server) audit(ctx context.Context, actorUserID, action, targetType, targetID string, payload any) { - _, _ = s.Store.DB.ExecContext(ctx, ` - INSERT INTO audit_log (actor_user_id, action, target_type, target_id, payload_json, occurred_at) - VALUES (?, ?, ?, ?, ?, ?) - `, nullIfEmpty(actorUserID), action, nullIfEmpty(targetType), nullIfEmpty(targetID), mustJSON(payload), storage.NowISO()) -} - -func scanService(rows *sql.Rows) (map[string]any, error) { - var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string - var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString - var enabled int - var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64 - if err := rows.Scan( - &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken, - &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS, - &lastValidationAt, &lastValidationStatus, &lastValidationMessage, - &createdAt, &updatedAt, - ); err != nil { - return nil, err - } - return map[string]any{ - "id": id, - "name": name, - "slug": slug, - "upstreamUrl": upstreamURL, - "routePrefix": routePrefix, - "healthPath": healthPath, - "upstreamAuthHeader": nullString(upstreamAuthHeader), - "upstreamAuthValue": nullString(upstreamAuthValue), - "internalToken": nullString(internalToken), - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpmLimit), - "monthlyQuota": nullIntValue(monthlyQuota), - "requestTimeoutMs": nullIntValue(requestTimeoutMS), - "lastValidationAt": nullString(upstreamOr(lastValidationAt, sql.NullString{})), - "lastValidationStatus": nullString(lastValidationStatus), - "lastValidationMessage": nullString(lastValidationMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }, nil -} - -func scanServiceRow(row *sql.Row) (map[string]any, error) { - var id, name, slug, upstreamURL, routePrefix, healthPath, createdAt, updatedAt string - var upstreamAuthHeader, upstreamAuthValue, internalToken, lastValidationAt, lastValidationStatus, lastValidationMessage sql.NullString - var enabled int - var rpmLimit, monthlyQuota, requestTimeoutMS sql.NullInt64 - if err := row.Scan( - &id, &name, &slug, &upstreamURL, &routePrefix, &healthPath, &upstreamAuthHeader, &upstreamAuthValue, &internalToken, - &enabled, &rpmLimit, &monthlyQuota, &requestTimeoutMS, - &lastValidationAt, &lastValidationStatus, &lastValidationMessage, - &createdAt, &updatedAt, - ); err != nil { - return nil, err - } - return map[string]any{ - "id": id, - "name": name, - "slug": slug, - "upstreamUrl": upstreamURL, - "routePrefix": routePrefix, - "healthPath": healthPath, - "upstreamAuthHeader": nullString(upstreamAuthHeader), - "upstreamAuthValue": nullString(upstreamAuthValue), - "internalToken": nullString(internalToken), - "enabled": enabled == 1, - "rpmLimit": nullIntValue(rpmLimit), - "monthlyQuota": nullIntValue(monthlyQuota), - "requestTimeoutMs": nullIntValue(requestTimeoutMS), - "lastValidationAt": nullString(lastValidationAt), - "lastValidationStatus": nullString(lastValidationStatus), - "lastValidationMessage": nullString(lastValidationMessage), - "createdAt": createdAt, - "updatedAt": updatedAt, - }, nil -} - -func serviceAllowed(apiKey map[string]any, serviceID, slug, prefix string) bool { - allowed, ok := apiKey["allowedServiceIds"].([]string) - if !ok || len(allowed) == 0 { - return true - } - for _, candidate := range allowed { - if candidate == serviceID || candidate == slug || candidate == prefix { - return true - } - } - return false -} - -func writeJSON(w http.ResponseWriter, status int, payload map[string]any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(payload) -} - -func writeError(w http.ResponseWriter, status int, code, message string) { - writeJSON(w, status, map[string]any{ - "ok": false, - "error": map[string]any{ - "code": code, - "message": message, - }, - }) -} - -func decodeJSON(body io.Reader, target any) error { - decoder := json.NewDecoder(io.LimitReader(body, 1<<20)) - decoder.DisallowUnknownFields() - if err := decoder.Decode(target); err != nil { - return err - } - return nil -} - -func mustJSON(value any) string { - bytes, _ := json.Marshal(value) - return string(bytes) -} - -func boolToInt(value bool) int { - if value { - return 1 - } - return 0 -} - -func intPtr(value int) *int { - v := value - return &v -} - -func nullInt(value *int) any { - if value == nil || *value <= 0 { - return nil - } - return *value -} - -func nullIntValue(value sql.NullInt64) *int { - if !value.Valid { - return nil - } - v := int(value.Int64) - return &v -} - -func nullIntValueFromAny(value any) *int { - if value == nil { - return nil - } - switch typed := value.(type) { - case *int: - return typed - case int: - v := typed - return &v - case int64: - v := int(typed) - return &v - case float64: - v := int(typed) - return &v - default: - return nil - } -} - -func nullString(value sql.NullString) any { - if !value.Valid { - return nil - } - return value.String -} - -func nullStringValue(value any) sql.NullString { - if value == nil { - return sql.NullString{} - } - text := fmt.Sprintf("%v", value) - if strings.TrimSpace(text) == "" { - return sql.NullString{} - } - return sql.NullString{String: text, Valid: true} -} - -func nullIfEmpty(value string) any { - v := strings.TrimSpace(value) - if v == "" { - return nil - } - return v -} - -func coalesceIntPtr(value *int, fallback *int) *int { - if value != nil { - return value - } - return fallback -} - -func normalizePathPrefix(value, fallback string) string { - v := strings.TrimSpace(value) - if v == "" { - v = fallback - } - if !strings.HasPrefix(v, "/") { - v = "/" + v - } - if len(v) > 1 && strings.HasSuffix(v, "/") { - v = strings.TrimSuffix(v, "/") - } - return v -} - -func slugify(value, fallback string) string { - v := strings.ToLower(strings.TrimSpace(value)) - if v == "" { - v = fallback - } - out := strings.Builder{} - lastDash := false - for _, r := range v { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { - out.WriteRune(r) - lastDash = false - continue - } - if !lastDash { - out.WriteRune('-') - lastDash = true - } - } - result := strings.Trim(out.String(), "-") - if result == "" { - return fallback - } - return result -} - -func firstNonEmpty(values ...string) string { - for _, value := range values { - if strings.TrimSpace(value) != "" { - return strings.TrimSpace(value) - } - } - return "" -} - -func firstNonEmptyPtr(value *string, fallback string) string { - if value != nil { - if strings.TrimSpace(*value) == "" { - return fallback - } - return strings.TrimSpace(*value) - } - return fallback -} - -func defaultBool(value *bool, fallback bool) bool { - if value == nil { - return fallback - } - return *value -} - -func asString(value any) string { - if value == nil { - return "" - } - if v, ok := value.(string); ok { - return v - } - return fmt.Sprintf("%v", value) -} - -func asBool(value any) bool { - if value == nil { - return false - } - switch v := value.(type) { - case bool: - return v - case int: - return v == 1 - case int64: - return v == 1 - case float64: - return int(v) == 1 - default: - return false - } -} - -func intFromAny(value any, fallback int) int { - if value == nil { - return fallback - } - switch v := value.(type) { - case int: - return v - case int64: - return int(v) - case float64: - return int(v) - case *int: - if v == nil { - return fallback - } - return *v - default: - return fallback - } -} - -func minPositive(a, b *int) *int { - if a == nil && b == nil { - return nil - } - if a == nil { - return b - } - if b == nil { - return a - } - if *a < *b { - return a - } - return b -} - -func keysOfMap(values map[string]bool) []string { - keys := make([]string, 0, len(values)) - for key, enabled := range values { - if enabled { - keys = append(keys, key) - } - } - sort.Strings(keys) - return keys -} - -func uniqueStrings(values []string) []string { - seen := map[string]bool{} - output := []string{} - for _, value := range values { - v := strings.TrimSpace(value) - if v == "" || seen[v] { - continue - } - seen[v] = true - output = append(output, v) - } - return output -} - -func normalizeProvider(value string) string { - v := strings.ToLower(strings.TrimSpace(value)) - switch v { - case "sqlite", "sqlite3", "file": - return "sqlite" - case "postgres", "postgresql", "pg": - return "postgres" - case "mysql", "mariadb": - return "mysql" - default: - return "" - } -} - -func maskDatabaseURL(provider, raw string) string { - if normalizeProvider(provider) == "sqlite" { - return raw - } - u, err := netUrlParse(raw) - if err != nil { - if len(raw) <= 8 { - return "***" - } - return raw[:4] + "***" + raw[len(raw)-2:] - } - if u.User != nil { - username := u.User.Username() - password, hasPassword := u.User.Password() - if username != "" { - if len(username) > 2 { - username = username[:2] + "***" - } else { - username = "***" - } - } - if hasPassword && password != "" { - u.User = url.UserPassword(username, "***") - } else { - u.User = url.User(username) - } - } - q := u.Query() - for _, key := range []string{"password", "pass", "pwd", "token", "secret"} { - if q.Has(key) { - q.Set(key, "***") - } - } - u.RawQuery = q.Encode() - return u.String() -} - -func extractDatabaseTarget(provider, raw string) string { - if normalizeProvider(provider) == "sqlite" { - return raw - } - u, err := netUrlParse(raw) - if err != nil { - return "-" - } - name := strings.TrimPrefix(u.Path, "/") - if name == "" { - name = "-" - } - return name -} - -func pathAbs(value string) (string, error) { - if strings.HasPrefix(value, "file:") { - return strings.TrimPrefix(value, "file:"), nil - } - if filepath.IsAbs(value) { - return value, nil - } - wd, err := os.Getwd() - if err != nil { - return "", err - } - return filepath.Join(wd, value), nil -} - -func netUrlParse(value string) (*url.URL, error) { - return url.Parse(value) -} - -func webPathForRequest(requestPath string, uiBasePath string) (string, bool) { - reqPath := path.Clean("/" + strings.TrimSpace(requestPath)) - if reqPath == "." { - reqPath = "/" - } - - base := normalizePathPrefix(uiBasePath, "/") - if base == "/" { - return strings.TrimPrefix(reqPath, "/"), true - } - - if reqPath == base { - return "", true - } - - prefix := base + "/" - if strings.HasPrefix(reqPath, prefix) { - return strings.TrimPrefix(reqPath, prefix), true - } - - return "", false -} - -func upstreamOr(a, b sql.NullString) sql.NullString { - if a.Valid { - return a - } - return b -} - -type serviceInput struct { - Name string `json:"name"` - Slug string `json:"slug"` - UpstreamURL string `json:"upstreamUrl"` - RoutePrefix string `json:"routePrefix"` - HealthPath string `json:"healthPath"` - UpstreamAuthHeader string `json:"upstreamAuthHeader"` - UpstreamAuthValue string `json:"upstreamAuthValue"` - InternalToken string `json:"internalToken"` - Enabled *bool `json:"enabled"` - RPMLimit *int `json:"rpmLimit"` - MonthlyQuota *int `json:"monthlyQuota"` - RequestTimeoutMS *int `json:"requestTimeoutMs"` -} - -func (s serviceInput) RPMLimitOr(value any) *int { - if s.RPMLimit != nil { - return s.RPMLimit - } - return nullIntValueFromAny(value) -} - -func (s serviceInput) MonthlyQuotaOr(value any) *int { - if s.MonthlyQuota != nil { - return s.MonthlyQuota - } - return nullIntValueFromAny(value) -} - -func (s serviceInput) TimeoutOr(value any) *int { - if s.RequestTimeoutMS != nil { - return s.RequestTimeoutMS - } - return nullIntValueFromAny(value) -} diff --git a/app/backend/internal/api/services.go b/app/backend/internal/api/services.go index 15cd647..e7d4d98 100644 --- a/app/backend/internal/api/services.go +++ b/app/backend/internal/api/services.go @@ -99,8 +99,18 @@ func handleGetServices(c *gin.Context) { // Get services for the project rows, err := db.(*database.DB).Query( - `SELECT id, project_id, name, type, status, image, command, environment, - git_repo, git_branch, build_path, cpu, memory, created_at, updated_at + `SELECT id, project_id, name, + COALESCE(type, service_type, ''), + COALESCE(status, ''), + COALESCE(image, image_name, ''), + COALESCE(command, start_command, ''), + COALESCE(environment, ''), + COALESCE(git_repo, source_url, ''), + COALESCE(git_branch, ''), + COALESCE(build_path, ''), + COALESCE(cpu, ''), + COALESCE(memory, ''), + created_at, updated_at FROM services WHERE project_id = $1 ORDER BY created_at DESC`, @@ -228,15 +238,25 @@ func handleCreateService(c *gin.Context) { service.Memory = "512Mi" } + environmentID, err := getProjectEnvironmentID(db.(*database.DB), service.ProjectID, service.Environment) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to resolve service environment"}) + return + } + + sourceType := inferServiceSourceType(service) + // Insert service into database _, err = db.(*database.DB).Exec( `INSERT INTO services - (id, project_id, name, type, status, image, command, environment, + (id, project_id, name, environment_id, service_type, source_type, source_url, image_name, + build_command, start_command, type, status, image, command, environment, git_repo, git_branch, build_path, cpu, memory, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`, - service.ID, service.ProjectID, service.Name, service.Type, service.Status, - service.Image, service.Command, service.Environment, service.GitRepo, - service.GitBranch, service.BuildPath, service.CPU, service.Memory, + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`, + service.ID, service.ProjectID, service.Name, environmentID, service.Type, + sourceType, firstNonEmpty(service.GitRepo, service.Image), service.Image, + "", service.Command, service.Type, service.Status, service.Image, service.Command, + service.Environment, service.GitRepo, service.GitBranch, service.BuildPath, service.CPU, service.Memory, service.CreatedAt, service.UpdatedAt, ) @@ -248,6 +268,26 @@ func handleCreateService(c *gin.Context) { c.JSON(http.StatusCreated, gin.H{"service": service}) } +func getProjectEnvironmentID(db *database.DB, projectID uuid.UUID, environment string) (uuid.UUID, error) { + var environmentID uuid.UUID + err := db.QueryRow( + "SELECT id FROM environments WHERE project_id = $1 AND name = $2", + projectID, + environment, + ).Scan(&environmentID) + return environmentID, err +} + +func inferServiceSourceType(service Service) string { + if service.GitRepo != "" { + return "github" + } + if service.Image != "" { + return "image" + } + return "dockerfile" +} + // handleGetService retrieves a specific service func handleGetService(c *gin.Context) { db, exists := c.Get("db") @@ -273,8 +313,17 @@ func handleGetService(c *gin.Context) { // Get service with project ownership check var service Service err = db.(*database.DB).QueryRow( - `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, - s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + `SELECT s.id, s.project_id, s.name, + COALESCE(s.type, s.service_type, ''), + COALESCE(s.status, ''), + COALESCE(s.image, s.image_name, ''), + COALESCE(s.command, s.start_command, ''), + COALESCE(s.environment, ''), + COALESCE(s.git_repo, s.source_url, ''), + COALESCE(s.git_branch, ''), + COALESCE(s.build_path, ''), + COALESCE(s.cpu, ''), + COALESCE(s.memory, ''), s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id @@ -326,8 +375,17 @@ func handleUpdateService(c *gin.Context) { // Check if service exists and user has access var existingService Service err = db.(*database.DB).QueryRow( - `SELECT s.id, s.project_id, s.name, s.type, s.status, s.image, s.command, - s.environment, s.git_repo, s.git_branch, s.build_path, s.cpu, s.memory, + `SELECT s.id, s.project_id, s.name, + COALESCE(s.type, s.service_type, ''), + COALESCE(s.status, ''), + COALESCE(s.image, s.image_name, ''), + COALESCE(s.command, s.start_command, ''), + COALESCE(s.environment, ''), + COALESCE(s.git_repo, s.source_url, ''), + COALESCE(s.git_branch, ''), + COALESCE(s.build_path, ''), + COALESCE(s.cpu, ''), + COALESCE(s.memory, ''), s.created_at, s.updated_at FROM services s JOIN projects p ON s.project_id = p.id diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 deleted file mode 100644 index 20e72b0..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff deleted file mode 100644 index 1487d93..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff deleted file mode 100644 index eff6edd..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 deleted file mode 100644 index b54e556..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff deleted file mode 100644 index 8c83fdc..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 deleted file mode 100644 index 0804aaf..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 deleted file mode 100644 index 732c64d..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff deleted file mode 100644 index 338fc76..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 b/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 deleted file mode 100644 index 21612f4..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2 and /dev/null differ diff --git a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff b/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff deleted file mode 100644 index dfa68b0..0000000 Binary files a/app/backend/internal/api/static/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff and /dev/null differ diff --git a/app/backend/internal/api/static/assets/index-DRUelTBf.css b/app/backend/internal/api/static/assets/index-DRUelTBf.css deleted file mode 100644 index 63387b5..0000000 --- a/app/backend/internal/api/static/assets/index-DRUelTBf.css +++ /dev/null @@ -1 +0,0 @@ -@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(data:font/woff2;base64,d09GMgABAAAAAAZYAA8AAAAAC8QAAAX7AAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG4F8HC4GYD9TVEFULgCBChEICokwh0kLIAABNgIkAzwEIAWEWAeBDgwHG7UJUZRRTjrBz4QKcv+QWjxhcjsVprsyM/p7jc9kFU2t525PaKAAX1gOyg6dISDJzAqQDFwnMAUYFEIW+IFRPPQ7/9W9LLSKCzCeDgimtvwvBEjFDoWDY6mfHo2VAv9KFfedVkGdKysb2MC6af4bbj5u3FK7g8trAXi8WwgIgAb9SPi4hLQ8hD1NcjXC3km2WoRhAPBN1FVZ5FqoAJQOBk6QoAKH+icDuzEPCqgAAEAY+CZqjSuR1b67rPaAb5HvJFC9/KTP5oPWf5lpmvg+C59ETNMN0PAP48BDgAJKqKABgeAHPvEUQADAQQEGghJK0HSVFNA8TGBbQB5ggHT7KIBtUGGXsvG0igAIYewkYAPcmbsqsiwSk+SUADicmrvTk3ETXTyryvvvIOA/iSsSQTsBYJLgBIjYVNMEIIw+tDOuoazcRgDAgQfAEU+rBlYXAXQ4DN/4+QwOQFtBeCjgD5GBE7eIYyfySn4lnl1QRzH7eF7OLw/xfeOmagZEWz4oQWgLMSE/BCDI0/0hQAUF1FBCAwL5CAQAgOx6cE+gdIATACg6SQOAPwe+oZGgSqlj0syo0oiXBdnUHZgvSN1JfJL+fRnrq6VQNIvTtC3aJk2zttFMgWjJnNGB78HjIe0st3ap11/jclWx+QvcVlFlC1gLM3q9FcvcbnAafrYndivNEldsoULTMoFCYfPx2zT7nU/80z56C7NaPyxY6n2HZr/tKah6fmue6y23hW/WNrVoG7kW7klr9vztFocIusq90phGbaZgTOWhquGUFOly0Owg4cIsd00pf4bHowtyN25s53+xyKG7ah1R12ZQZjri7aZ1umKd2bHw0oAD3hcvOh1pyRl9edKJo67OGm8D45t+fNmv3PoU15iDuoeyJJtmrcqSsgDNliKl0OQEKYFuM0pGf6wUW4CTJInm0Et6ujxAGoDvJ/XD95J64URJ3MPWUeoIXTfBecf/95u72DOiu8RnmMYu+XIJBjhSZjiGzYgom1Xu1L7nxSRbfJwtsVnKzJSiMmisOTHexrcO3y82exufmLsOiT88Z9usbYe7hsabEfybc+8MU8TDmOlXP1g85eftK9eNtfTeXWBfNap6/o3MD7raA9dCcOL8gjW5a4bv9fa9vjH7vfeYHCa9rX2ub8LkTM528y479NHm+pCzZ+uDYfwgDn2QeVecwZn6oI+24ERZeft50sbQN42hvzSuV69fjtC90hrz9j7mvU9Xd1m8SFlCKUHfr/u+B5/rCK1eWjcra4UupPNaCYffnZE348Tfuz5Z3UQHDrf4Pl2xY/enqxqBvdMmfLZy57KzJ76/cffkF2eatlWn3Zxx58zXMJ23Q98cH5m+3Zw5auaciCuOIReTuhvEL2b3T48a1id30F9N3Yen2qLObumRWLqhKkKuHZV1qbBenBEwY8xHHcGHV1wb0+1lz9cvOiTponXFXQOmhseOnqof8qMuN2OTkDJq/o87nPHj87dK1rnGdYWG8Lq9ffepB04BBjAAAMCKBCcCwGa3U/fVnBAY+5uKV74E4J3z++cBwHvdsnfC/5uEKuUsgAIcAICAd65AeAFApU1AiLUGCg/xsV/oLKEIiAv+WNbYP+qfRXuZcYDtlBWrgOHBTv7OOiYEIBAF/KEGDaHBr9JgBh7E1ACcFI1xCL2iLYdDAKk4PJLc5DAMtYwjoCOZo0CkMewADOUnQYAfAHzXBxWyqFCmnC0pV4x60QbvZTWFrEI9G8cPYjVUw5eqIyszWKYEadJNUktWp55FvEY2sknSOk8OlMPC/HbVxX5ZQQByp3vXqSUaapBh9LCLP1tkMsT2Tj7rGLmSpEsWM7lOdPAJganS+ibyFA2RrDZCFFGe8g66dghZGLISpjQMGtgPAOq2JirSHYbZqoNlKir67Saz9BR1ag4JyiesVP6pJhVM1gz4h+E4AAB82xAptrBAAL08Ig6xwRMjgRSkJBV4UpOGtArDZNnisKjz/4Y5zDalSWmvrdAPNRrgdvGS2l4Ba4QoWqI36DlTjt0vUm8cepF8n2TLAQAAAA==) format("woff2"),url(data:font/woff;base64,d09GRgABAAAAAAiYAA4AAAAAC0AAAQABAAAAAAAAAAAAAAAAAAAAAAAAAABHUE9TAAABRAAAAJsAAAD8joyVK0dTVUIAAAHgAAAALQAAAC4jdiSCT1MvMgAAAhAAAABNAAAAYHI5reNTVEFUAAACYAAAACoAAAAueZJs3WNtYXAAAAKMAAAAZAAAAIpNu/P9Z2FzcAAAAvAAAAAIAAAACAAAABBnbHlmAAAC+AAAA3cAAASWUYkMm2hlYWQAAAZwAAAANgAAADYgWsSPaGhlYQAABqgAAAAfAAAAJBDBBppobXR4AAAGyAAAADYAAAA8QYMDamxvY2EAAAcAAAAAIAAAACAI1wpebWF4cAAAByAAAAAcAAAAIAAmAL9uYW1lAAAHPAAAAPcAAAIEKtNM/3Bvc3QAAAg0AAAAYwAAAI7B1Xo4eNpVzqdZRFEQBeD/ZZJ8VVABCkMOEk825Bw0sQ4qWL2aInCorePu/UbtTs5zFJizaEm1ur67pz9+vbvQXxw+XOnVkJICxfnp3ZUZRKZW5qzIWrqJyR/v2piAnnSQ/gByJP1OZFuk7zQg5gbpIf1bTsNcEbMj0tAUxT5F/C1VGq0uZ3OKzAuqYBqUWrVCl7kQOIPFXqEeA9KNKLwAeNpjYGRgYOBi0AFCZhc3nxAGieTKohwGkZzEkjwGEQYQYAER//+DSACWTwc8AAAAeNpjYGGZwDiBgZWBgVWEZQMDA8M0CM20h8GIqYMBBTCxgwgYL9jD15PhAAOvwhaOkL8rGBg4QpgiFBgY54PkWCxYFwApBQYWADWRDDIAAAB42mNgBEIOIGZgEAGTMgxM5ekZJSAmAxMDM4hkZGacAKT2MDAAADlZA1QAAHjaVcg1AUIBFAXQg7vLTJVfgwzM5KEAzoJDEkYsBK7vXUcYEVRFhB4e1UZdUURcTkOgYxsKR/vRefTYmFwu/O296Cx6eO6X82VzaV2CSwjYNYXUfS8rrwhyYpLiUhLSV1I9F+YAAQAB//8AD3jalFMDlBxLFH1VrbV71qOejFfT2J6NtT5/Y9vGrDe2bfvbtm3bPPjxUdA1qY5xlGbp3vsIifA+AAfcQWBAgHhIhnTIhFywgQQeCEARyACy6hDb3eX7PqtfuMs3EtnYq1dDdXWkvLyhW7eGDh0iut5UXBwJBiN+f5PLFbHbG3JyADB4AdiPqM0CxAHISEYS42BisRedegud2tVqLGzB75OT5DR38PwoNJwcQf8x6RePAzBXkMLCy8ikm7CMlC7fAUcj5jfPv5WDHYu0GzzOyzw5N/M43W5VzrDIIY3+eP5OzrTKxp5tvfRwXVt5423c4fLGCr1XL72isRwAAb3YQdTLGABHKhWgTyr6i+Q/iJagRQ8SGx7K9DTG4n0XXzKOQTR67TzmgQUTv5bOF1F8Fp2kaqWaHLJYxFQpVSnEklPgBTpENr1q3LyXfx+31T1w07Rlo3oEtnAHjWfsFuvqs/vIbDRs/JGmzt48h1F7uUI6RP/kpnKvQiLllAA4pwunpLnkEJuWgllPiGqUOiyZFgtye9xuj5OnIvir78j3O3ci73ffI8+uXeSH7x/4MxyMu5gYW6D/iY/EBcN/PvDA73pBnPU11Ae9iPq89hp5lJSTR19bt3v1tJUrpq3agx9YMX3VbrR71fSVgMBLdjKfs7oZFXQ9Ko+Tlh2oI+q8jbSQnWgSq19cybRceJ/sNONwHwAbTxE5l+NghiHTikVRUhXTTMl5JRJl2v6hRz+xxsbgrWxMxtqFvTaEtrA6mZHn2bzaN7Qm5cQJZz/P9Fk+yfibcjHQK/onW8cOgGTIBqcZCzdWlTSTPU3MwHw65ZYuRwCFroYF1x36u23uP/sP/9Pa9u+bCdWDn2rd586PQaOfHlSVYHROqBli3YzwfQ8i2LCJRO97kEQ34tZdoxumj6oaRDJaxmzdMboJAEN3qtybZqEd1F7WpU6oCtWQaNs5qKKYYeZANtOhlco8XeGR6jBnNzymqxLdvRINWhYZ1FBZxD36HdDL77FjZiKbZIj2dItHKm7TahqDOZKwXQh5Or9obMC/Mkx+js/pGN+h83iX3cft5DsXlx0a0X+vHu4ucsJca0lA3j+0rgvHzZ9vdXjCaj+b31mm5lZ6OtQX9R7EtJ/PsvYqb9kQm81f4nL0K6pZXtK5PKwolzY13NGCn6+OAQDG7UKdAAABAAAABIEGEYo73l8PPPUAAwfQAAAAANu2poQAAAAA3R9Rn/6W/doJ1AjBAAAABgACAAAAAAAAeNpjYGRg4Aj5u4KBgcvn37R/0zivAEVQAT8AnI0GbgB42mM9wXCC5TODBhxHMDCAMCsvQw/reQYb5h9AsWiG5SzzGQIYJzAwgDDLBiCewuAMALUKDSYAAAAAAFoAdwCbAM0A5QDtARYBWAFxAZwB2QHZAdkB2QJLeNpjYGRgYOBnSGHgZYhkYAPzEICZgREAFJ0A5njafZBDekVBEEZP7MzjO4tt2/Yotm0sI+OsJyvICrKO/F+9fkbzdHUZyOaTFJJSs8Tf/DlOIp9fx8niHx/rbOHLcSqlPDtOo5BNx+mSDzjOl36uj5MQ4TjFfK5zwAlHHHOPtuY1XdRr3rHHLSd634vruBOf676S9Ej/84wxwyw7XHIr6TUH1LKk84gHae5IuqbXrVleScujRfatNOgOt/Mi7IJ63SwzIe1JUcJYEa9w3WG7X7gN1OrRRAONdIpWJFEOUTktGJ+K98xikAfr0pXV5FER6Nid69kRJ6bxwC517EnzAnVSEuVk3vd11ksaGqnyH7PZTf0AeNpjYGIAg/9zGIwYsAF+IFZhOMnIxGDDyMzIwsjKyMbIzsjBwMzIycjFyM3mmFSUWpbKGVpcUpSYWZJcyV6al2lg4uQIoV0tOEvRZJygMpZg2sDRgMk5CMQ0MnAyAQBr7hs3AA==) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-cyrillic-400-normal-BMzJvInZ.woff2) format("woff2"),url(/assets/manrope-cyrillic-400-normal-Dvx59UGC.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-greek-400-normal-CM4qok81.woff2) format("woff2"),url(/assets/manrope-greek-400-normal-DuX9RsAR.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-vietnamese-400-normal-DHb3EETF.woff2) format("woff2"),url(/assets/manrope-vietnamese-400-normal-D7E_mLGF.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-ext-400-normal-CMDvPJRp.woff2) format("woff2"),url(/assets/manrope-latin-ext-400-normal-C-X6QNXX.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/manrope-latin-400-normal-PaqtzbVb.woff2) format("woff2"),url(/assets/manrope-latin-400-normal-8tf8FM3T.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(data:font/woff2;base64,d09GMgABAAAAAAZMAA8AAAAAC/AAAAXvAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG4F8HC4GYD9TVEFUKgCBChEICokwh0oLIAABNgIkAzwEIAWFBgeBDgwHG+AJyB6FcZsTzpsm7MR0ftOC5+k96c+dTNh2BWYFuFo9QH9vM9ifft4kts6MwkozfJsn6nCS878Q7Pj7lW56BXWArVrk/NmmN7t7ZRXUYWcVYAt4VXjN1+KNB6q3zA3aqBpGnx6vT4IAHkWABNatPygESX3GICdIGqrDbpBEEMDXMGRIP+QGYeBq0SgQTIqR+Oqwm7lYOAAQ/AdBT0mQ/OTcuxjq4LaFwUmA9D0ZjHWN6sFlOwFjT6A2Hy8xF2XPx1UYmFjYOHgIQhgDAwMwAYWFRrCxkTO2KRTMqRP0vZXFqApNawBw91h5oZ6jOhIMZjp9Elif1lZXKaMW4nkqG1Cc2KYhqw9cJpLrrNP2/4NgXVF1zAWIHQDaN3cCuQhC7lSBdPjq2QiYMMCKCQCFAIipOgLWRQOUDQQ/PIjxADIQRyAWEXLRxmZuidIn9BX9Y66zNxjXZz6uL+sfrh78HNwMhqpFAODzzhBkAOA5EyYFqXj/CCYOFi42HoIESAlASgByAMgLkGYAZQEWEAYggsLAoJykMRH3QjXnSF6qgpuZsFRuVvZBRgpqXTAkaRnLToqcEpnkTQ5N7EKkZIrXJRtX32XxkycSmhmLXJKwFkUf9RciJi0MGqJ301N07TeJ0VvGHqM8Y1b8SdxbHG3sEORN6UGkBS/+8EJmvf8odvvyOTOT79rHEglxu7WPR6Nto/A9+xmTQ5OmRE5UU9STBYqibboETzCHpyEOcrtIaoyLn8T3CGN61H4Jiw42P2JmzDXA2PG47J4QbFiVMfK0Tf7Fm3qp7tqh+3QcOZY1tDNaZXDjb8trz2KuJLwBRY/skVnxisPzm6sOnszmNUs1EksXea/kCdHWg2UPR/uoY/LilpAba3A/7Negbtitxe2wnRph1ZYEQl8zTIKltKgKFpNWwCLSIphPkgdz7YIzYBbKstfbXG39+ecZ/+apkGVfnsx1r/9xl5Lra25f2G07w94+dGLhCtlaTGWxpnB3Uhr0UKj1cePOloNT6OqVJ7NBe37fJoef9bWrB1+zFxKA5H93Lp3+/lHFcadezu/5Y9eGqLBN6oq6eHK7few+litn5NMbzJ380v+1+zrV3GTt1Z2t3n7uhtOW5NNSV3eUYRImp8ZvbpTHi/v93Lev3/fHizZS676o3y/2slfu/nQRJwZY06b7b1L/XSaAN8WrDgQpd/2J45YVtNp5Z3m2pYusjvSl+R/9lFW1WZDQ2Sey9Y+WbOSNz9mHu9vuPvDd1l+XzOTgoTn8unxHqZ8VHDg0+5+ycufddRtf7ji0+fGaQbPUbTvde9c9Ab2ys1eXtRZT1UeMA3VTxufP+XDtXp+C4sJLy3qQa3PXy1jcK62sromJA3t1elK3iOtvrR70WRFx0D9YR5dDHPAPLi/TuVRR2VMT7v+QtXj2OnseCvNSqnagHh04j/RB1y0/YWIj66TQpthWv25f3fWixpLRfNYeoCw0aABA7zZ3IoCeZakvW/XRxFb91zHslwDvz++fC/AxZ6tNgE8bzWX2dsBCASDwrXUz/wQYWQHBG5nSfMhDMz9luk7vhiazPPNnUFVdrr3bBNboO7SUlUA06JP83GMaQQApaU80LrJuvH5ZmPUYiHaBndKytAgVJItFkULCFoPGXJ2rxy0mWdhtsSgHhhRY3GpJRRFZkFom1isKeRaKZt/uHTBhw4IVjACDSz5FOx6cGGGhww3CgxcTeXQxCgahYxAeC8PC4KWeWvJjGANb3YDxU0M2nGmLB4SF1OTQzSBrTi4Wrj4HiOuI1urpwU0eNMrokNI5SP8GBPTCom/m1+fzycJQuX4d6OYL583HDujSMaDxnhRIkKkjJMRKFs5bF7kl344GcKWNoXdFz30b8ih746xfVizYRmkIPQEDCF1vz1nPHRNkREfnu3b5xXICACDYShm8J0sJ5OOOKKqKIVpMscQWB0Nc8SRk1eqNTMOm8HZ/xxyEDWNCxw23kWi129Kd6sHwcb3idqzDvUi1SAF1Wut7UUi1aacl+KReBgAAAA==) format("woff2"),url(data:font/woff;base64,d09GRgABAAAAAAiQAA4AAAAACzgAAQABAAAAAAAAAAAAAAAAAAAAAAAAAABHUE9TAAABRAAAAKAAAAD8jmuU7UdTVUIAAAHkAAAALQAAAC4jdiSCT1MvMgAAAhQAAABNAAAAYHMBrgpTVEFUAAACZAAAACYAAAAqeZRq5WNtYXAAAAKMAAAAZAAAAIpNu/P9Z2FzcAAAAvAAAAAIAAAACAAAABBnbHlmAAAC+AAAA2gAAASK2TaQ7GhlYWQAAAZgAAAANgAAADYgeMTUaGhlYQAABpgAAAAfAAAAJBDfBrhobXR4AAAGuAAAADcAAAA8Q6kDYmxvY2EAAAbwAAAAIAAAACAI2QpZbWF4cAAABxAAAAAcAAAAIAAmAL9uYW1lAAAHLAAAAP4AAAIMK0FNV3Bvc3QAAAgsAAAAYwAAAI7B1Xo4eNpiYGRgYOBi0GKwYGB2cfMJYRBJrgRUOdUGCANA8OJaZh18ELzBbQN0DiZIi3V0LMAktM89mty7/2yAZNBcjJDABiHy6jL63dkIvnoqrJmMDIqJyatzjxVcxi/Ig9KQOz5QT66ZqEa9kxQE+1JZyAVFOTLz7r1RjsjhM8+bFBMWHN7zGIUwSDEzSmAerLuwmfVIxudPJegcY/sJ5n4qW3jaY2BkYGDgYtABQmYXN58QBonkyqIcBpGcxJI8BhEGEGABEf//g0gAlk8HPAAAAHjaY2Bh2c4UwcDKwMAqwrKBgYFhGoRm2sNgxNTBgAKY2EEEjBfs4evJcICBV2ELR8jfFQwMHCFMEQoMjPNBciwWrAuAlAIDCwAwMAwiAAAAeNpjYARCDiBmYBABkzIMTOXpGSUgJgMTA5hiZGWKYGAAACwHAl0AAHjaVcg1AUIBFAXQg7vLTJVfgwzM5KEAzoJDEkYsBK7vXUcYEVRFhB4e1UZdUURcTkOgYxsKR/vRefTYmFwu/O296Cx6eO6X82VzaV2CSwjYNYXUfS8rrwhyYpLiUhLSV1I9F+YAAQAB//8AD3jalFMDkBxBFP2/R3GyPt/tzCpOZhkUYp9j2zrFtm3bNkux7ULsFGJMb3riXPEG7fdef0FhOAUggLAcOJCgIBQFE9ggGuJBATeUhLLgBfD67RZnHv9TfOhrHv+MjOnJyVl162bUrJlVtWpW5coZoVBOuXIZpUpllCiR43BkJCRkRUUBEPAA8Bq7swQFALxoR4Wzc8hxHryGpfHa7e7ajq6k//MnwvIvbbAGPUTykxNaRQDuJ1Ja+gNZ5D8sKiZvbjwmrZ62+n8SPoit/vBU/sET9R+PSXa5/F6z1asGWCeKuTmfJ41KXFtb9dZa22BULmpT4shktXZtNXlkIgACe/iVzMp8AHYDepF9BnxKI99hS2z5jkaSNK6Btoq0/LZL2wLh8O/zRAQedPxkNt/A8BFsYggEA17VarUYFIOvDFFkSZTYEFv7anfM3nev7bWIGiO6DWxU0XFVWK5diDfHTPm0gnbF9LS52dXcUbFa1x8ZUjn8QJgiHIXCjFMBEGQHKWZ0eFXeWIzwbivTCNqtNqsVXW4X+0QmQh7fopdWrMByt25i+eXL6cWbC8565YLfcgrK3vOkJWvPLtBXCsUdwVQ8iqlHjtCttCrdeuQkmTQkrWPH1METybSOaYMmIpk4JL0jIHjofL4QH9L9gn/8sp32v4rxGH+V9qfzsQsf+naBU7+eovN1TywA4N0MEfXDE7ojbHHEYlH8Pv2iivzTFz1Cs1PWnonNL+E1Lr8po2vdASWu8iE6PkaZM6VE07pGSqPTPc0bueMpMC4OksMP+Ey+ERRl3pB1b7iI32fU2Y0WMxHd6k936N7QNXRntF35ZsToN8tWvhk56s2KRQ1CRbVVRSsm4omiFRIXdB1vN+eLm4m4fgPitJlhWLeBhmcc71q/Z996nfBMn3odW6eplYFAHaY7gkVBgdq6qm6C3xdUWNHZmYjFzCT17AsGAkGvyFZEdNv12V9r2arCdn96giWFWfJaSLvEBYHGrc3N+JAWZylkkuUS7TzVO3kMNvGGUDKh6gUtg5BbHGf+Pn45adlQI5sIGXFJljusZhq22zOjl1hZuwrlymkqm2xKsTNfsUJERMFQ301aUdLCVMRIyjRPNzGFWXMFC4ukpbSui5S8iomypI2ia52+X6i1mdncUCuLFQBkGTWlAAEAAAAEgQb7OTYiXw889QADB9AAAAAA27amhAAAAADdH1Gf/rz9sgnMCS4AAAAGAAIAAAAAAAB42mNgZGDgCPm7goGBy+Xfnn97OM8ARVABPwChtQaqAHjaYz3BcIJVlUEDhlkWMTCAMKspQw/rRwYbFhOgWDnDTJbDDAGMExgYQJhlAxCfYvAEAEv0C0gAAAAAWgB3AJsAzQDlAO0BFgFZAXIBnQHZAdkB2QHZAkV42mNgZGBg4GdIYeBliGRgA/MQgJmBEQAUnQDmeNqF0MVRBEEUgOEfd7nic8Pd4YK765V1354awSEAoiAUIiAUYqDr1bq2fu0CtPJJHTX1LcA3fynXYPCbci2d/KRcxxJfKdfnzGmgFzPlRgwuUu5kgX7ENdBOd8oNWnBNgAQRtlDE8XNPgAghwjiSHUzWmGYaGx8WEUwc7Sls7ThTKCxCTHPOHiec4iGJhcIkwGTR/ncEsGStIonBAlMsMsNCjta55oBTDrWq7XdFgBCutgerYLZRNHtbRl6wMm80mGOGWVa1bgjLqsJdLsRRbZ+s2MSV31HyEoORzE/Zqb8KEZEZLl6m8KFIyA+G8WDJ7n48TJPIPYnRf5pBTsMAAHjaY2BiAIP/cxiMGLABfiBWYTjJyMRgw8jMyMLIysjGyM7IwcDMyMnIxcjN5phUlFqWyhlaXFKUmFmSXMlempdpYOLkCKFdLThL0WScoDKWYNrA0YDJOQjENDJwMgEAa+4bNwA=) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-cyrillic-600-normal-DvRl3Mj-.woff2) format("woff2"),url(/assets/manrope-cyrillic-600-normal-It4mZcQk.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-greek-600-normal-BoRV6lzK.woff2) format("woff2"),url(/assets/manrope-greek-600-normal-CF2i9ZRY.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-vietnamese-600-normal-C1J5PCl_.woff2) format("woff2"),url(/assets/manrope-vietnamese-600-normal-lA7a_7Ok.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-ext-600-normal-_gBojHdJ.woff2) format("woff2"),url(/assets/manrope-latin-ext-600-normal-u5Pl7hTU.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:600;src:url(/assets/manrope-latin-600-normal-4f0koTD-.woff2) format("woff2"),url(/assets/manrope-latin-600-normal-BqgrALkZ.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(data:font/woff2;base64,d09GMgABAAAAAAYgAA8AAAAAC6wAAAXGAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG4F8HC4GYD9TVEFUKgCBChEICok0h0cLIAABNgIkAzwEIAWEQAeBDgwHG5cJCB6FcZsTTpEclTZ1ofCYsgigtf/N7skX04SpVaInlfSIVkUjP2GSIHRN3jxyP4+n/+cmKTSyb2AG30HZWweyUJo+QKzeh1mF/xdjrs6zSTMtYbWZ/NyiSXJLWxqQIFKSe1ofxF0ne+ShzqSmdMSp6nFvLQIqjYKRSxSVjeCxeyKrAY+9fZwOPFDwTdTtGWJ1IKRmSEUFRpbDQcJrb8IFyJUDxecUvR3IkY7xO77kfFAsKE4Yev2ygnvUvOJSrKX4r+JPAtpuVgnKBVEikytTrlIQVEvy15AhyqWCMmXCdFUc0CNBugWUMWh5nxiwDWrZjela4gpkZM3UCeAqVup2T4oLAMjGMiC6cPOKqjLYATX9Z4SN/x4A6JVEqxzQNUA6g7cD6EcJywvRzF/fjTg8MsohQBQg5MQVMAQkBAtJXJ1yCGjlgpSr0a/QDngdInWcukx92X9mMR167jHqEvVFdfF1caPQLNylKD7tAtQGxKrVqndSjUy5XIWyza8vqgMI2Xv8EAhrAWIC5J2ADtJRIiGaAlZHAMinJmGMcLGLfD2/i8g/+CRlkRQVKUHuMCm4SHJcNlE8STKhAznD8coOQYf8kJLNhqpmWCUX7cWcxWJzQU02SaZUyyZQkynmtd2yzGqFWEnOtIEHiUuWnBZKPFmNXGG09NFb6OzbHxeHffgGzjg+aLba7cj4Sc02iwXOWK1D5CTJxMmSCcRk4nFr3qatKQSFBixziDo8wmDJZrKbqnjQ0rw4UBiqM4PWKFBus8UFDevXeztbQFXlhRtw5mTmxRmUFa0/7i/ZYaG1JnbhiG1+p6d2vyp2824081TMoa4S9IEjPmIV5Pfjr3tpjS+wFByJesBitmjGilpci+hcOS6nswqsYHAMZvA5OIfOYozp4xJxIoOlMR2IwTEaQhgOw8ux/BDOD/tBVPfQjr7i3//he1c6zdcspTI+/OVDmDgt+/xG43ncctTsilG5X+PCutoFjVcSGSaRx6801i3wd6bP+dE24se6oN31vuW5Zbm+Qavrq8H7844ZXTsfphnPOZZ2/7ln0y+15S7LMk0TxirNuyrpwAkf/gTZdci3//DpD/+ZHSlX9tS9/76nwBGHI1Wm4AfaP1oBr872fr1tW+8X2L4CSdEvkT2Xwf2RUZ85+CeXn51dSecfk/cdBZ8tWD1lWVD1/ntrA5ZN4LegMnf+L3xvouGga81AWVepJlzz008Ybj6/2nj1yNu7flw5B+4dmv//j8u375Yh5m7qgXnwc8XOD1csvr5h69JLy/qmNGdvGt667BIc7KlYG9bdlTp8SVmWNntkzPj/pcWx+pj+MLepkXGx3/toGtxD83vSm67xk/9IjFS9w4zYlVv/u1pxdezOELjy++FfNIzSyoSGVOmvKz0iJ4wcqRpDZ9enGfjBGf/xCpMmxuU13ugwdB7I2bdXsbMxM8wU0nsRYg4pKSB9k12HgPQsn16Z/qbHOedXISl4Ae8e7JsD+NBXtx3838DbLjgKQS6CwAsnnCeAbEEAmuuyB95mcyEqZab3OSRbAUv4JMxJ63WHzYNLlMWGsAJwgnTkdz5bC6HONjIVwhBa+W2VTiGFVARcRyEyXjDsP15U7h8vscgZXmqcebxMJw0vN0YBt9YUUhkEqgF+18dtN0RpxCiuqYhYBlkSDjMZwFIy4HxCPJNBg9+MHmtEghoKlar00WHpGQwpNgGH1aey8+viMPQ0BjUbwnZXrKcjl8r3S5MotZgt93Dydvpnl61BqSplsiekxYMPD0VWyxOx/vHJEiXJJNdotIOuHUKth1TBADFUyLzRoN+ImFzUpltMcWiE0jJg1u+lB+hpDyM9qg87SGBQn2ZSO+AfRiOAYotJwNrqMOxhiDAnkIhCPMRHAiSEJIiQGEn4hf3s0LghUdPfX07iBiYKzDplYipTiPcqxiKzBZaBTF6SWJhIFNWnRyUnMqkX0P9GqKU=) format("woff2"),url(data:font/woff;base64,d09GRgABAAAAAAiAAA4AAAAACxgAAQABAAAAAAAAAAAAAAAAAAAAAAAAAABHUE9TAAABRAAAAJ0AAAD8jluUzEdTVUIAAAHkAAAALQAAAC4jdiSCT1MvMgAAAhQAAABMAAAAYHNlrf5TVEFUAAACYAAAACYAAAAqeZVrSWNtYXAAAAKIAAAAZAAAAIpNu/P9Z2FzcAAAAuwAAAAIAAAACAAAABBnbHlmAAAC9AAAA2UAAASK2CSsZ2hlYWQAAAZcAAAANgAAADYgiMT2aGhlYQAABpQAAAAfAAAAJBDuBsdobXR4AAAGtAAAADcAAAA8RLkDYGxvY2EAAAbsAAAAIAAAACAI1wpYbWF4cAAABwwAAAAcAAAAIAAmAL9uYW1lAAAHKAAAAPIAAAHsJ/5KCXBvc3QAAAgcAAAAYwAAAI7B1Xo4eNpiYGRgYOBi0GKwYGB2cfMJYRBJrizKYRDJSSzJYxABVDkHGAhEURSGvzu9Zp60ptpKEIgkLSG0jgCIARABLWJ20DJuPIM6B/ycn6NApkAc9uejikaKThWqzknB3VWvAvkhdzkx80S+f2hL3nKk7ca85MMmnznO2xf59JfZD1XoLCz1BtVKCGuLVpbo9IowGIT2s1XzQvkCzs0qKgAAAHjaY2BkYGDgYtABQmYXN58QBonkyqIcBpGcxJI8BhEGEGABEf//g0gAlk8HPAAAAHjaY2BhOc20h4GVgYFVhGUDAwPDNAgNFDNi6mBAAUzsIALGC/bw9WRYwMCrsIUj5O8KBgaOEKYIBQbG+SA5FgvWBUBKgYEFAFbgDHp42mNgBEIOIGZgEAGTMgxM5ekZJSAmAxMDmGJkY9rDwAAALTgCwgAAeNpVyDUBQgEUBdCDu8tMlV+DDMzkoQDOgkMSRiwEru9dRxgRVEWEHh7VRl1RRFxOQ6BjGwpH+9F59NiYXC787b3oLHp47pfzZXNpXYJLCNg1hdR9LyuvCHJikuJSEtJXUj0X5gABAAH//wAPeNqVU0OYHFsUPueW5kvbRnVNY9zujq3xdGzb/Wzbtm37xcbqMdlOdtnFrNu51RPvUsY9Pw7AALsABBDeBQ4k0IEJbOACH4RAgTg0QAtkADI52RG9znMXXzx7nWe5/Gx3942treUxY24cMeLGQYPKxeLNyWS5sbFcX39zbW05HL7R6wUgkGCaHUyzBP2YOpRR4WQOOS6BO3Ae7ji1QN01n5T++1t498xcLNJdxEPeVucDcH2R0lfVSONVsajYMtfGY+svr/9yNQgfwfIlnPFVHM9VOLZILJbL2J3pPLuK4rWQ/098vOfPkXNH/ll6/BpgLD02MTlyZHLiYyUABLbxO5nHGgDZwuDZYcFD1IkebMNW9FAX6eRK6hYy5NzX6vdQqVyMICLwoCE8yd7/ZAhu9mLJF/KZtNPpsCiWbDNRIpIosUe8Oz1u0bqfD8w5Zhlw27KbevLyEeFd9UDI5nv6zId0MXa3PnPj6JjXp95W7ZBBlV7hHWED6MAJMkOvJWZrbZi3mgkfdzKGgux0OZ0Yi8fYITIK4qD7PvoIs/gt5j76iO7d/9zmVEB37gtdILmZDNIFUpufe25TMqgLrscSbsDS+vX0azqCfr3+IN5/89hJk0bfdD+5b9KYG+8n1XfNVYK+ytfzRS0vLCEZZIcFv6W3HkETmg/TW+mruJQvnjvMmc7uoq9qES8wpXkW4a3mQUuDK0iqidCEKpG+TDxUfKbzk52BGokc43TWhbPGrYof4Yv0pUDo5Wfqprba6BkUXRPru1tjAWpkaBx0V3r5B/nJYGIZjgAIkRjJZa0avtVhJ2I8fSEhLB9VFpaOZe8feejRI+98cOThR468/+yYFpN6wJwai++ak2OfnndX0FQTfB7xk88Rnn2eVj75lFae6507bPqsobPxl5nDZk3pSDQBgQ7G+yqrggLjqqzMRC5bUNjQyYzEYddqkEmzcuQLGZF9ETEua2+5S37ZV4X97csFawq7lHGQdW0v52cvMUzi21TFWGMNReITlaFzY3oLf4pPBEfuVxcg+ZcQcz+3y9fZOHCC22LlqZhWhm5eO/OzgQPH6hcHo6HcH8syLevXG43+poZhnoC9kDfFbakVTUuXc/J6jnOm7fH+7kAkL7uS7hG3N0+aObiQf39iS8v68/QJLjMAAAAAAQAAAASBBgDfBT5fDzz1AAMH0AAAAADbtqaEAAAAAN0fUZ/+z/2eCcgJZAABAAYAAgAAAAAAAHjaY2BkYOAI+buCgYHL4d/5f+c5TwBFUAE/AKRJBsgAeNpjPcFwgtWWQQOGWY4zMIAwqwdDDxszgw1LEFCsjWEyyxWGAMYJDAwgzLIBiJ8y+AIAUaoLVwAAAABaAHcAmwDMAOUA7QEWAVgBcQGdAdkB2QHZAdkCRXjaY2BkYGDgZ0hh4GWIZGAD8xCAmYERABSdAOZ42nXQA25FQRSA4b924xTRDWvbtt342byo2wV0FV1G466ga+rk5FQPw28MoJEXqqiobgDe+FRX0MqHupJ63tVVDPKqrqYdW11DJ9fqWtpZVrcyTjPiCoxQ18qeV4SJEyWGK9klxyxDDOEQxCZODtd4EMc4ZeosNlGGOGKTfQ7wk8EmS44wA6wapQhxSRhb1mTJYDHOIBMMM16wwvpZ8TODOc7Y5oAdo3L7a10wvib1PfbPmyxGGWaEGaNzYnJi4Q2OxQnjoKxYwZPfyMoLLLp/fsbRv4kSlxkeAQYJkiUtPxbDjy27h/AzRPrfST1f4K1IMgAAeNpjYGIAg/9zGIwYsAF+IFZhOMnIxGDDyMzIwsjKyMbIzsjBwMzIycjFyM3mmFSUWpbKGVpcUpSYWZJcyV6al2lg4uQIoV0tOEvRZJygMpZg2sDRgMk5CMQ0MnAyAQBr7hs3AA==) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-cyrillic-700-normal-Dw_fZAg2.woff2) format("woff2"),url(/assets/manrope-cyrillic-700-normal-7JNVKxyl.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-greek-700-normal-CHUG9PD8.woff2) format("woff2"),url(/assets/manrope-greek-700-normal-DyfsrCpP.woff) format("woff");unicode-range:U+0370-0377,U+037A-037F,U+0384-038A,U+038C,U+038E-03A1,U+03A3-03FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-vietnamese-700-normal-CUqMx5-1.woff2) format("woff2"),url(/assets/manrope-vietnamese-700-normal-pt65Fn2Z.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-ext-700-normal-DYOwVNan.woff2) format("woff2"),url(/assets/manrope-latin-ext-700-normal-eVCcYqtJ.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:Manrope;font-style:normal;font-display:swap;font-weight:700;src:url(/assets/manrope-latin-700-normal-BZp_XxE4.woff2) format("woff2"),url(/assets/manrope-latin-700-normal-DGRFkw-m.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-xuaO2J-f.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-ext-400-normal-DMdlQ8Kv.woff) format("woff");unicode-range:U+0460-052F,U+1C80-1C8A,U+20B4,U+2DE0-2DFF,U+A640-A69F,U+FE2E-FE2F}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-cyrillic-400-normal-BSMlKf0J.woff2) format("woff2"),url(/assets/ibm-plex-mono-cyrillic-400-normal-CEL4l2ZJ.woff) format("woff");unicode-range:U+0301,U+0400-045F,U+0490-0491,U+04B0-04B1,U+2116}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-vietnamese-400-normal-BulugwFq.woff2) format("woff2"),url(/assets/ibm-plex-mono-vietnamese-400-normal-DDuiU_S-.woff) format("woff");unicode-range:U+0102-0103,U+0110-0111,U+0128-0129,U+0168-0169,U+01A0-01A1,U+01AF-01B0,U+0300-0301,U+0303-0304,U+0308-0309,U+0323,U+0329,U+1EA0-1EF9,U+20AB}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-ext-400-normal-BmRBH3aV.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-ext-400-normal-D3D2R8hC.woff) format("woff");unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}@font-face{font-family:IBM Plex Mono;font-style:normal;font-display:swap;font-weight:400;src:url(/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2) format("woff2"),url(/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff) format("woff");unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Manrope,ui-sans-serif,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.mx-auto{margin-left:auto;margin-right:auto}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.ml-2{margin-left:.5rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-20{margin-top:5rem}.mt-4{margin-top:1rem}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.grid{display:grid}.h-2{height:.5rem}.max-h-56{max-height:14rem}.min-h-screen{min-height:100vh}.w-full{width:100%}.min-w-full{min-width:100%}.max-w-\[1500px\]{max-width:1500px}.max-w-\[320px\]{max-width:320px}.max-w-md{max-width:28rem}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-2{gap:.5rem}.gap-4{gap:1rem}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse: 0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.space-y-1>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.25rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.25rem * var(--tw-space-y-reverse))}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.divide-y>:not([hidden])~:not([hidden]){--tw-divide-y-reverse: 0;border-top-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)));border-bottom-width:calc(1px * var(--tw-divide-y-reverse))}.divide-border>:not([hidden])~:not([hidden]){border-color:hsl(var(--border))}.overflow-auto{overflow:auto}.break-all{word-break:break-all}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-md{border-radius:.6rem}.rounded-xl{border-radius:.75rem}.border{border-width:1px}.border-t{border-top-width:1px}.border-amber-500\/45{border-color:#f59e0b73}.border-amber-500\/50{border-color:#f59e0b80}.border-border{border-color:hsl(var(--border))}.border-destructive{border-color:hsl(var(--destructive))}.border-emerald-500\/50{border-color:#10b98180}.border-input{border-color:hsl(var(--input))}.border-primary{border-color:hsl(var(--primary))}.border-primary\/40{border-color:hsl(var(--primary) / .4)}.border-primary\/50{border-color:hsl(var(--primary) / .5)}.border-rose-500\/50{border-color:#f43f5e80}.bg-amber-500\/10{background-color:#f59e0b1a}.bg-amber-500\/15{background-color:#f59e0b26}.bg-background{background-color:hsl(var(--background))}.bg-card{background-color:hsl(var(--card))}.bg-card\/90{background-color:hsl(var(--card) / .9)}.bg-destructive{background-color:hsl(var(--destructive))}.bg-emerald-500\/15{background-color:#10b98126}.bg-primary{background-color:hsl(var(--primary))}.bg-primary\/10{background-color:hsl(var(--primary) / .1)}.bg-primary\/15{background-color:hsl(var(--primary) / .15)}.bg-primary\/20{background-color:hsl(var(--primary) / .2)}.bg-rose-500\/10{background-color:#f43f5e1a}.bg-rose-500\/15{background-color:#f43f5e26}.bg-secondary{background-color:hsl(var(--secondary))}.bg-secondary\/35{background-color:hsl(var(--secondary) / .35)}.bg-secondary\/40{background-color:hsl(var(--secondary) / .4)}.bg-secondary\/60{background-color:hsl(var(--secondary) / .6)}.bg-secondary\/70{background-color:hsl(var(--secondary) / .7)}.bg-transparent{background-color:transparent}.p-10{padding:2.5rem}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-1\.5{padding-top:.375rem;padding-bottom:.375rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.align-top{vertical-align:top}.font-mono{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.uppercase{text-transform:uppercase}.tracking-\[0\.18em\]{letter-spacing:.18em}.tracking-\[0\.2em\]{letter-spacing:.2em}.tracking-wide{letter-spacing:.025em}.text-amber-100{--tw-text-opacity: 1;color:rgb(254 243 199 / var(--tw-text-opacity, 1))}.text-amber-200{--tw-text-opacity: 1;color:rgb(253 230 138 / var(--tw-text-opacity, 1))}.text-amber-200\/90{color:#fde68ae6}.text-destructive-foreground{color:hsl(var(--destructive-foreground))}.text-emerald-200{--tw-text-opacity: 1;color:rgb(167 243 208 / var(--tw-text-opacity, 1))}.text-foreground{color:hsl(var(--foreground))}.text-muted-foreground{color:hsl(var(--muted-foreground))}.text-primary{color:hsl(var(--primary))}.text-primary-foreground{color:hsl(var(--primary-foreground))}.text-rose-200{--tw-text-opacity: 1;color:rgb(254 205 211 / var(--tw-text-opacity, 1))}.text-secondary-foreground{color:hsl(var(--secondary-foreground))}.shadow-\[0_8px_24px_rgba\(0\,0\,0\,0\.24\)\]{--tw-shadow: 0 8px 24px rgba(0,0,0,.24);--tw-shadow-colored: 0 8px 24px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000),var(--tw-ring-shadow, 0 0 #0000),var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.outline{outline-style:solid}.ring-0{--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow, 0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}:root{--background: 147 32% 9%;--foreground: 152 28% 94%;--card: 148 30% 12%;--card-foreground: 152 28% 93%;--primary: 142 73% 45%;--primary-foreground: 144 35% 8%;--secondary: 145 32% 18%;--secondary-foreground: 145 28% 92%;--muted: 146 26% 17%;--muted-foreground: 148 18% 74%;--accent: 136 66% 40%;--accent-foreground: 150 38% 95%;--destructive: 1 74% 56%;--destructive-foreground: 0 0% 98%;--border: 146 24% 24%;--input: 147 25% 20%;--ring: 142 73% 45%}*{border-color:hsl(var(--border))}body{background-color:hsl(var(--background));color:hsl(var(--foreground));-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:Manrope,ui-sans-serif,system-ui,sans-serif}code,pre{font-family:IBM Plex Mono,ui-monospace,SFMono-Regular,monospace}.placeholder\:text-muted-foreground::-moz-placeholder{color:hsl(var(--muted-foreground))}.placeholder\:text-muted-foreground::placeholder{color:hsl(var(--muted-foreground))}.hover\:bg-secondary:hover{background-color:hsl(var(--secondary))}.hover\:opacity-95:hover{opacity:.95}.focus\:border-primary:focus{border-color:hsl(var(--primary))}@media(min-width:768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.md\:flex-row{flex-direction:row}.md\:items-center{align-items:center}.md\:justify-between{justify-content:space-between}.md\:p-6{padding:1.5rem}}@media(min-width:1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:p-8{padding:2rem}}@media(min-width:1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.xl\:grid-cols-\[360px_1fr\]{grid-template-columns:360px 1fr}.xl\:grid-cols-\[420px_1fr\]{grid-template-columns:420px 1fr}} diff --git a/app/backend/internal/api/static/assets/index-DwfYiTMH.js b/app/backend/internal/api/static/assets/index-DwfYiTMH.js deleted file mode 100644 index 8bab5af..0000000 --- a/app/backend/internal/api/static/assets/index-DwfYiTMH.js +++ /dev/null @@ -1 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const s of document.querySelectorAll('link[rel="modulepreload"]'))n(s);new MutationObserver(s=>{for(const i of s)if(i.type==="childList")for(const o of i.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&n(o)}).observe(document,{childList:!0,subtree:!0});function r(s){const i={};return s.integrity&&(i.integrity=s.integrity),s.referrerPolicy&&(i.referrerPolicy=s.referrerPolicy),s.crossOrigin==="use-credentials"?i.credentials="include":s.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(s){if(s.ep)return;s.ep=!0;const i=r(s);fetch(s.href,i)}})();const Ge=!1,We=(e,t)=>e===t,Se=Symbol("solid-proxy"),Qe=typeof Proxy=="function",Xe=Symbol("solid-track"),de={equals:We};let je=Fe;const J=1,fe=2,Ve={owned:null,cleanups:null,context:null,owner:null},ve={};var S=null;let xe=null,Ye=null,_=null,I=null,q=null,he=0;function ce(e,t){const r=_,n=S,s=e.length===0,i=t===void 0?n:t,o=s?Ve:{owned:null,cleanups:null,context:i?i.context:null,owner:i},a=s?e:()=>e(()=>j(()=>re(o)));S=o,_=null;try{return H(a,!0)}finally{_=r,S=n}}function y(e,t){t=t?Object.assign({},de,t):de;const r={value:e,observers:null,observerSlots:null,comparator:t.equals||void 0},n=s=>(typeof s=="function"&&(s=s(r.value)),qe(r,s));return[Be.bind(r),n]}function ze(e,t,r){const n=me(e,t,!0,J);z(n)}function L(e,t,r){const n=me(e,t,!1,J);z(n)}function Ee(e,t,r){je=it;const n=me(e,t,!1,J);n.user=!0,q?q.push(n):z(n)}function R(e,t,r){r=r?Object.assign({},de,r):de;const n=me(e,t,!0,0);return n.observers=null,n.observerSlots=null,n.comparator=r.equals||void 0,z(n),Be.bind(n)}function Ze(e){return e&&typeof e=="object"&&"then"in e}function F(e,t,r){let n,s,i;n=!0,s=e,i={};let o=null,a=ve,h=!1,m="initialValue"in i,d=typeof n=="function"&&R(n);const l=new Set,[f,g]=(i.storage||y)(i.initialValue),[b,x]=y(void 0),[V,U]=y(void 0,{equals:!1}),[T,O]=y(m?"ready":"unresolved");function k(w,$,B,N){return o===w&&(o=null,N!==void 0&&(m=!0),(w===a||$===a)&&i.onHydrated&&queueMicrotask(()=>i.onHydrated(N,{value:$})),a=ve,M($,B)),$}function M(w,$){H(()=>{$===void 0&&g(()=>w),O($!==void 0?"errored":m?"ready":"unresolved"),x($);for(const B of l.keys())B.decrement();l.clear()},!1)}function G(){const w=nt,$=f(),B=b();if(B!==void 0&&!o)throw B;return _&&_.user,$}function Z(w=!0){if(w!==!1&&h)return;h=!1;const $=d?d():n;if($==null||$===!1){k(o,j(f));return}let B;const N=a!==ve?a:j(()=>{try{return s($,{value:f(),refetching:w})}catch(te){B=te}});if(B!==void 0){k(o,void 0,ue(B),$);return}else if(!Ze(N))return k(o,N,void 0,$),N;return o=N,"v"in N?(N.s===1?k(o,N.v,void 0,$):k(o,void 0,ue(N.v),$),N):(h=!0,queueMicrotask(()=>h=!1),H(()=>{O(m?"refreshing":"pending"),U()},!1),N.then(te=>k(N,te,void 0,$),te=>k(N,void 0,ue(te),$)))}Object.defineProperties(G,{state:{get:()=>T()},error:{get:()=>b()},loading:{get(){const w=T();return w==="pending"||w==="refreshing"}},latest:{get(){if(!m)return G();const w=b();if(w&&!o)throw w;return f()}}});let ee=S;return d?ze(()=>(ee=S,Z(!1))):Z(!1),[G,{refetch:w=>tt(ee,()=>Z(w)),mutate:g}]}function j(e){if(_===null)return e();const t=_;_=null;try{return e()}finally{_=t}}function et(e){return S===null||(S.cleanups===null?S.cleanups=[e]:S.cleanups.push(e)),e}function tt(e,t){const r=S,n=_;S=e,_=null;try{return H(t,!0)}catch(s){ke(s)}finally{S=r,_=n}}const[rl,nl]=y(!1);function rt(e){const t=R(e),r=R(()=>Ce(t()));return r.toArray=()=>{const n=r();return Array.isArray(n)?n:n!=null?[n]:[]},r}let nt;function Be(){if(this.sources&&this.state)if(this.state===J)z(this);else{const e=I;I=null,H(()=>ge(this),!1),I=e}if(_){const e=this.observers?this.observers.length:0;_.sources?(_.sources.push(this),_.sourceSlots.push(e)):(_.sources=[this],_.sourceSlots=[e]),this.observers?(this.observers.push(_),this.observerSlots.push(_.sources.length-1)):(this.observers=[_],this.observerSlots=[_.sources.length-1])}return this.value}function qe(e,t,r){let n=e.value;return(!e.comparator||!e.comparator(n,t))&&(e.value=t,e.observers&&e.observers.length&&H(()=>{for(let s=0;s1e6)throw I=[],new Error},!1)),t}function z(e){if(!e.fn)return;re(e);const t=he;lt(e,e.value,t)}function lt(e,t,r){let n;const s=S,i=_;_=S=e;try{n=e.fn(t)}catch(o){return e.pure&&(e.state=J,e.owned&&e.owned.forEach(re),e.owned=null),e.updatedAt=r+1,ke(o)}finally{_=i,S=s}(!e.updatedAt||e.updatedAt<=r)&&(e.updatedAt!=null&&"observers"in e?qe(e,n):e.value=n,e.updatedAt=r)}function me(e,t,r,n=J,s){const i={fn:e,state:n,updatedAt:null,owned:null,sources:null,sourceSlots:null,cleanups:null,value:t,owner:S,context:S?S.context:null,pure:r};return S===null||S!==Ve&&(S.owned?S.owned.push(i):S.owned=[i]),i}function pe(e){if(e.state===0)return;if(e.state===fe)return ge(e);if(e.suspense&&j(e.suspense.inFallback))return e.suspense.effects.push(e);const t=[e];for(;(e=e.owner)&&(!e.updatedAt||e.updatedAt=0;r--)if(e=t[r],e.state===J)z(e);else if(e.state===fe){const n=I;I=null,H(()=>ge(e,t[0]),!1),I=n}}function H(e,t){if(I)return e();let r=!1;t||(I=[]),q?r=!0:q=[],he++;try{const n=e();return st(r),n}catch(n){r||(q=null),I=null,ke(n)}}function st(e){if(I&&(Fe(I),I=null),e)return;const t=q;q=null,t.length&&H(()=>je(t),!1)}function Fe(e){for(let t=0;t=0;t--)re(e.tOwned[t]);delete e.tOwned}if(e.owned){for(t=e.owned.length-1;t>=0;t--)re(e.owned[t]);e.owned=null}if(e.cleanups){for(t=e.cleanups.length-1;t>=0;t--)e.cleanups[t]();e.cleanups=null}e.state=0}function ue(e){return e instanceof Error?e:new Error(typeof e=="string"?e:"Unknown error",{cause:e})}function ke(e,t=S){throw ue(e)}function Ce(e){if(typeof e=="function"&&!e.length)return Ce(e());if(Array.isArray(e)){const t=[];for(let r=0;r1?[]:null;return et(()=>Ie(i)),()=>{let h=e()||[],m=h.length,d,l;return h[Xe],j(()=>{let g,b,x,V,U,T,O,k,M;if(m===0)o!==0&&(Ie(i),i=[],n=[],s=[],o=0,a&&(a=[])),r.fallback&&(n=[at],s[0]=ce(G=>(i[0]=G,r.fallback())),o=1);else if(o===0){for(s=new Array(m),l=0;l=T&&k>=T&&n[O]===h[k];O--,k--)x[k]=s[O],V[k]=i[O],a&&(U[k]=a[O]);for(g=new Map,b=new Array(k+1),l=k;l>=T;l--)M=h[l],d=g.get(M),b[l]=d===void 0?-1:d,g.set(M,l);for(d=T;d<=O;d++)M=n[d],l=g.get(M),l!==void 0&&l!==-1?(x[l]=s[d],V[l]=i[d],a&&(U[l]=a[d]),l=b[l],g.set(M,l)):i[d]();for(l=T;le(t||{}))}function ae(){return!0}const ct={get(e,t,r){return t===Se?r:e.get(t)},has(e,t){return t===Se?!0:e.has(t)},set:ae,deleteProperty:ae,getOwnPropertyDescriptor(e,t){return{configurable:!0,enumerable:!0,get(){return e.get(t)},set:ae,deleteProperty:ae}},ownKeys(e){return e.keys()}};function we(e){return(e=typeof e=="function"?e():e)?e:{}}function ut(){for(let e=0,t=this.length;e=0;a--){const h=we(e[a])[o];if(h!==void 0)return h}},has(o){for(let a=e.length-1;a>=0;a--)if(o in we(e[a]))return!0;return!1},keys(){const o=[];for(let a=0;a=0;o--){const a=e[o];if(!a)continue;const h=Object.getOwnPropertyNames(a);for(let m=h.length-1;m>=0;m--){const d=h[m];if(d==="__proto__"||d==="constructor")continue;const l=Object.getOwnPropertyDescriptor(a,d);if(!n[d])n[d]=l.get?{enumerable:!0,configurable:!0,get:ut.bind(r[d]=[l.get.bind(a)])}:l.value!==void 0?l:void 0;else{const f=r[d];f&&(l.get?f.push(l.get.bind(a)):l.value!==void 0&&f.push(()=>l.value))}}}const s={},i=Object.keys(n);for(let o=i.length-1;o>=0;o--){const a=i[o],h=n[a];h&&h.get?Object.defineProperty(s,a,h):s[a]=h?h.value:void 0}return s}const Je=e=>`Stale read from <${e}>.`;function K(e){const t="fallback"in e&&{fallback:()=>e.fallback};return R(ot(()=>e.each,e.children,t||void 0))}function ye(e){const t=e.keyed,r=R(()=>e.when,void 0,void 0),n=t?r:R(r,void 0,{equals:(s,i)=>!s==!i});return R(()=>{const s=n();if(s){const i=e.children;return typeof i=="function"&&i.length>0?j(()=>i(t?s:()=>{if(!j(n))throw Je("Show");return r()})):i}return e.fallback},void 0,void 0)}function dt(e){const t=rt(()=>e.children),r=R(()=>{const n=t(),s=Array.isArray(n)?n:[n];let i=()=>{};for(let o=0;om()?void 0:h.when,void 0,void 0),l=h.keyed?d:R(d,void 0,{equals:(f,g)=>!f==!g});i=()=>m()||(l()?[a,d,h]:void 0)}return i});return R(()=>{const n=r()();if(!n)return e.fallback;const[s,i,o]=n,a=o.children;return typeof a=="function"&&a.length>0?j(()=>a(o.keyed?i():()=>{if(j(r)()?.[0]!==s)throw Je("Match");return i()})):a},void 0,void 0)}function W(e){return e}const ft=["allowfullscreen","async","alpha","autofocus","autoplay","checked","controls","default","disabled","formnovalidate","hidden","indeterminate","inert","ismap","loop","multiple","muted","nomodule","novalidate","open","playsinline","readonly","required","reversed","seamless","selected","adauctionheaders","browsingtopics","credentialless","defaultchecked","defaultmuted","defaultselected","defer","disablepictureinpicture","disableremoteplayback","preservespitch","shadowrootclonable","shadowrootcustomelementregistry","shadowrootdelegatesfocus","shadowrootserializable","sharedstoragewritable"],pt=new Set(["className","value","readOnly","noValidate","formNoValidate","isMap","noModule","playsInline","adAuctionHeaders","allowFullscreen","browsingTopics","defaultChecked","defaultMuted","defaultSelected","disablePictureInPicture","disableRemotePlayback","preservesPitch","shadowRootClonable","shadowRootCustomElementRegistry","shadowRootDelegatesFocus","shadowRootSerializable","sharedStorageWritable",...ft]),gt=new Set(["innerHTML","textContent","innerText","children"]),ht=Object.assign(Object.create(null),{className:"class",htmlFor:"for"}),mt=Object.assign(Object.create(null),{class:"className",novalidate:{$:"noValidate",FORM:1},formnovalidate:{$:"formNoValidate",BUTTON:1,INPUT:1},ismap:{$:"isMap",IMG:1},nomodule:{$:"noModule",SCRIPT:1},playsinline:{$:"playsInline",VIDEO:1},readonly:{$:"readOnly",INPUT:1,TEXTAREA:1},adauctionheaders:{$:"adAuctionHeaders",IFRAME:1},allowfullscreen:{$:"allowFullscreen",IFRAME:1},browsingtopics:{$:"browsingTopics",IMG:1},defaultchecked:{$:"defaultChecked",INPUT:1},defaultmuted:{$:"defaultMuted",AUDIO:1,VIDEO:1},defaultselected:{$:"defaultSelected",OPTION:1},disablepictureinpicture:{$:"disablePictureInPicture",VIDEO:1},disableremoteplayback:{$:"disableRemotePlayback",AUDIO:1,VIDEO:1},preservespitch:{$:"preservesPitch",AUDIO:1,VIDEO:1},shadowrootclonable:{$:"shadowRootClonable",TEMPLATE:1},shadowrootdelegatesfocus:{$:"shadowRootDelegatesFocus",TEMPLATE:1},shadowrootserializable:{$:"shadowRootSerializable",TEMPLATE:1},sharedstoragewritable:{$:"sharedStorageWritable",IFRAME:1,IMG:1}});function bt(e,t){const r=mt[e];return typeof r=="object"?r[t]?r.$:void 0:r}const yt=new Set(["beforeinput","click","dblclick","contextmenu","focusin","focusout","input","keydown","keyup","mousedown","mousemove","mouseout","mouseover","mouseup","pointerdown","pointermove","pointerout","pointerover","pointerup","touchend","touchmove","touchstart"]),Q=e=>R(()=>e());function $t(e,t,r){let n=r.length,s=t.length,i=n,o=0,a=0,h=t[s-1].nextSibling,m=null;for(;od-a){const b=t[o];for(;a{s=i,t===document?e():u(t,e(),t.firstChild?null:void 0,r)},n.owner),()=>{s(),t.textContent=""}}function p(e,t,r,n){let s;const i=()=>{const a=document.createElement("template");return a.innerHTML=e,a.content.firstChild},o=()=>(s||(s=i())).cloneNode(!0);return o.cloneNode=o,o}function Oe(e,t=window.document){const r=t[Ne]||(t[Ne]=new Set);for(let n=0,s=e.length;ns.call(e,r[1],i))}else e.addEventListener(t,r,typeof r!="function"&&r)}function _t(e,t,r={}){const n=Object.keys(t||{}),s=Object.keys(r);let i,o;for(i=0,o=s.length;is.children=ne(e,t.children,s.children)),L(()=>typeof t.ref=="function"&&Pt(t.ref,e)),L(()=>At(e,t,r,!0,s,!0)),s}function Pt(e,t,r){return j(()=>e(t,r))}function u(e,t,r,n){if(r!==void 0&&!n&&(n=[]),typeof t!="function")return ne(e,t,n,r);L(s=>ne(e,t(),s,r),n)}function At(e,t,r,n,s={},i=!1){t||(t={});for(const o in s)if(!(o in t)){if(o==="children")continue;s[o]=Re(e,o,null,s[o],r,i,t)}for(const o in t){if(o==="children")continue;const a=t[o];s[o]=Re(e,o,a,s[o],r,i,t)}}function kt(e){return e.toLowerCase().replace(/-([a-z])/g,(t,r)=>r.toUpperCase())}function Le(e,t,r){const n=t.trim().split(/\s+/);for(let s=0,i=n.length;sObject.defineProperty(e,"target",{configurable:!0,value:h}),o=()=>{const h=t[r];if(h&&!t.disabled){const m=t[`${r}Data`];if(m!==void 0?h.call(t,m,e):h.call(t,e),e.cancelBubble)return}return t.host&&typeof t.host!="string"&&!t.host._$host&&t.contains(e.target)&&i(t.host),!0},a=()=>{for(;o()&&(t=t._$host||t.parentNode||t.host););};if(Object.defineProperty(e,"currentTarget",{configurable:!0,get(){return t||document}}),e.composedPath){const h=e.composedPath();i(h[0]);for(let m=0;m{let a=t();for(;typeof a=="function";)a=a();r=ne(e,a,r,n)}),()=>r;if(Array.isArray(t)){const a=[],h=r&&Array.isArray(r);if(Ae(a,t,r,s))return L(()=>r=ne(e,a,r,n,!0)),()=>r;if(a.length===0){if(r=Y(e,r,n),o)return r}else h?r.length===0?De(e,a,n):$t(e,r,a):(r&&Y(e),De(e,a));r=a}else if(t.nodeType){if(Array.isArray(r)){if(o)return r=Y(e,r,n,t);Y(e,r,null,t)}else r==null||r===""||!e.firstChild?e.appendChild(t):e.replaceChild(t,e.firstChild);r=t}}return r}function Ae(e,t,r,n){let s=!1;for(let i=0,o=t.length;i=0;o--){const a=t[o];if(s!==a){const h=a.parentNode===e;!i&&!o?h?e.replaceChild(s,a):e.insertBefore(s,r):h&&a.remove()}else i=!0}}else e.insertBefore(s,r);return[s]}async function v(e,t){const r=await fetch(e,{credentials:"include",headers:{"Content-Type":"application/json",...t?.headers||{}},...t}),n=await r.json().catch(()=>({}));if(!r.ok)throw new Error(n?.error?.message||`Request failed (${r.status})`);return n}async function Tt(){return(await v("/api/v1/bootstrap/status")).data}async function Et(e,t){return v("/api/v1/bootstrap/register-owner",{method:"POST",body:JSON.stringify({email:e,password:t})})}async function It(e,t){return(await v("/api/v1/auth/login",{method:"POST",body:JSON.stringify({email:e,password:t})})).data.user}async function Ue(){return(await v("/api/v1/auth/me")).data}async function Nt(){return v("/api/v1/auth/logout",{method:"POST"})}async function Lt(e){return v("/api/v1/auth/reset-password",{method:"POST",body:JSON.stringify({newPassword:e})})}async function Rt(){return(await v("/api/v1/services")).data}async function Dt(e){return v("/api/v1/services",{method:"POST",body:JSON.stringify(e)})}async function Ut(e,t){return v(`/api/v1/services/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Mt(e){return v(`/api/v1/services/${e}/validate`,{method:"POST"})}async function jt(){return(await v("/api/v1/databases")).data}async function Vt(e){return v("/api/v1/databases",{method:"POST",body:JSON.stringify(e)})}async function Bt(e,t){return v(`/api/v1/databases/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function qt(e){return v(`/api/v1/databases/${e}/validate`,{method:"POST"})}async function Ft(){return(await v("/api/v1/keys")).data}async function Kt(e){return v("/api/v1/keys",{method:"POST",body:JSON.stringify(e)})}async function Jt(e,t){return v(`/api/v1/keys/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Ht(){return(await v("/api/v1/users")).data}async function Gt(e){return v("/api/v1/users",{method:"POST",body:JSON.stringify(e)})}async function Me(e,t){return v(`/api/v1/users/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function He(){return(await v("/api/v1/roles")).data}async function Wt(e){return v("/api/v1/roles",{method:"POST",body:JSON.stringify(e)})}async function Qt(e,t){return v(`/api/v1/roles/${e}`,{method:"PATCH",body:JSON.stringify(t)})}async function Xt(){return(await v("/api/v1/permissions")).data}async function Yt(){return v("/api/v1/analytics/ops")}async function zt(){return v("/api/v1/analytics/traffic")}async function Zt(e,t,r){return v("/api/v1/analytics/events",{method:"POST",body:JSON.stringify({event:e,path:t,meta:r})})}var er=p(" + + + + + + + ); +} diff --git a/app/frontend/src/features/auth/pages.tsx b/app/frontend/src/features/auth/pages.tsx index 517eb3d..3f71c17 100644 --- a/app/frontend/src/features/auth/pages.tsx +++ b/app/frontend/src/features/auth/pages.tsx @@ -1,16 +1,14 @@ import { useMemo, useState } from 'react'; import type { FormEvent } from 'react'; import { Link, Navigate, useNavigate, useSearchParams } from 'react-router-dom'; -import { useQueryClient } from '@tanstack/react-query'; -import { AlertCircle, ArrowRight, Github, Gitlab, KeyRound, Loader2, Mail, Shield, User2, Workflow } from 'lucide-react'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { AlertCircle, ArrowRight, Github, Loader2, Mail, User2 } from 'lucide-react'; import { AuthError, - startBitbucketSignIn, - startGitLabSignIn, - requestMagicLinkInvite, + getAuthBootstrap, + startGoogleSignIn, signInWithEmail, signUpWithEmail, - startGiteaSignIn, startGitHubSignIn, } from '@/lib/auth-client'; import { useAuthSession } from '@/lib/use-auth-session'; @@ -33,45 +31,14 @@ function buildOAuthCallbackURL(path: string): string { return `${window.location.origin}${cleanPath}`; } -function AuthCanvas() { - const bars = [32, 44, 28, 60, 52, 64, 36, 48, 54, 72, 66, 78]; - +function GoogleMark() { return ( -
-
-
-
-

Containr Access

-

- Secure Sessions, -
- Better Auth -

-

- Email/password, invite magic links, and OAuth providers (GitHub, GitLab, Bitbucket, Gitea) are handled by a dedicated Better Auth service with cookie sessions. -

-
- -
-
-

Auth Health

- - - Live - -
-
- {bars.map((height, index) => ( -
- ))} -
-
-
-
+ ); } @@ -85,24 +52,35 @@ function AuthCard({ children: React.ReactNode; }) { return ( -
-
-

Containr

-

{title}

-

{subtitle}

+
+
+

{title}

+

{subtitle}

+ {children}
- {children}
); } function AuthLayout({ children }: { children: React.ReactNode }) { return ( -
+
+
-
- -
{children}
+
+
+
+ Containr + Containr + Self-hosted container platform +
+ {children} +

+ Sessions are cookie-based · first account becomes platform owner +

); @@ -132,18 +110,24 @@ export function SignInPage() { const redirectPath = useMemo(() => sanitizeRedirect(searchParams.get('redirect')), [searchParams]); const sessionQuery = useAuthSession(); + const bootstrapQuery = useQuery({ + queryKey: ['auth-bootstrap'], + queryFn: getAuthBootstrap, + }); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); - const [magicEmail, setMagicEmail] = useState(''); const [error, setError] = useState(null); const [info, setInfo] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); - const [isMagicLoading, setIsMagicLoading] = useState(false); if (sessionQuery.data) { return ; } + if (bootstrapQuery.data?.mode === 'register') { + return ; + } + const submitEmailPassword = async (event: FormEvent) => { event.preventDefault(); setError(null); @@ -162,48 +146,32 @@ export function SignInPage() { } }; - const submitMagicLink = async (event: FormEvent) => { - event.preventDefault(); + const signInWithGitHubProvider = async () => { setError(null); - setInfo(null); - setIsMagicLoading(true); - try { - await requestMagicLinkInvite(magicEmail.trim(), buildOAuthCallbackURL(redirectPath)); - setInfo('Magic invite link sent. Check your email inbox.'); + await startGitHubSignIn(buildOAuthCallbackURL(redirectPath)); } catch (exception) { - const message = exception instanceof AuthError ? exception.message : 'Failed to send magic link'; + const message = exception instanceof AuthError ? exception.message : 'GitHub sign-in failed'; setError(message); - } finally { - setIsMagicLoading(false); } }; - const signInWithGitHubProvider = async () => { + const signInWithGoogleProvider = async () => { setError(null); - await startGitHubSignIn(buildOAuthCallbackURL(redirectPath)); - }; - - const signInWithGiteaProvider = async () => { - setError(null); - await startGiteaSignIn(buildOAuthCallbackURL(redirectPath)); - }; - - const signInWithGitLabProvider = async () => { - setError(null); - await startGitLabSignIn(buildOAuthCallbackURL(redirectPath)); - }; - - const signInWithBitbucketProvider = async () => { - setError(null); - await startBitbucketSignIn(buildOAuthCallbackURL(redirectPath)); + try { + await startGoogleSignIn(buildOAuthCallbackURL(redirectPath)); + } catch (exception) { + const message = exception instanceof AuthError ? exception.message : 'Google sign-in failed'; + setError(message); + } }; return ( - + {error ? : null} {info ? : null} + {bootstrapQuery.isLoading ? : null}