commit f8e9650de3a343830f227ca34ebcb02c52a08ad6 Author: Mondo Diaz Date: Thu Dec 4 10:14:49 2025 -0600 Initial commit: Orchard content-addressable storage system - Go server with Gin framework - PostgreSQL for metadata storage - MinIO/S3 for artifact storage with SHA256 content addressing - REST API for grove/tree/fruit operations - Web UI for managing artifacts - Docker Compose setup for local development diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b8ba722 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,34 @@ +# Git +.git +.gitignore + +# IDE +.idea/ +.vscode/ + +# Documentation +*.md +*.pdf +docs/ + +# Local config +config.local.yaml +.env* + +# Build artifacts +/bin/ +/build/ +/dist/ + +# Test artifacts +*_test.go +coverage.out + +# OS files +.DS_Store +Thumbs.db + +# Local cache +.orchard/ +tmp/ +temp/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb060d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,50 @@ +# Binaries +/bin/ +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary +*.test + +# Output of go coverage tool +*.out + +# Go workspace file +go.work + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Build +/build/ +/dist/ + +# Local config overrides +config.local.yaml + +# Environment files +.env +.env.local +.env.*.local + +# Logs +*.log +logs/ + +# Local Orchard cache +.orchard/ + +# Temp files +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4a6520d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# Build stage +FROM golang:1.22-alpine AS builder + +RUN apk add --no-cache git ca-certificates + +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum* ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the binary +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \ + -ldflags="-w -s" \ + -o /orchard-server \ + ./cmd/orchard-server + +# Runtime stage +FROM alpine:3.19 + +RUN apk add --no-cache ca-certificates tzdata + +# Create non-root user +RUN addgroup -g 1000 orchard && \ + adduser -u 1000 -G orchard -s /bin/sh -D orchard + +WORKDIR /app + +# Copy binary from builder +COPY --from=builder /orchard-server /app/orchard-server + +# Copy migrations +COPY --from=builder /app/migrations /app/migrations + +# Set ownership +RUN chown -R orchard:orchard /app + +USER orchard + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1 + +ENTRYPOINT ["/app/orchard-server"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fb90510 --- /dev/null +++ b/Makefile @@ -0,0 +1,97 @@ +.PHONY: build run test clean docker-build docker-up docker-down migrate + +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOTEST=$(GOCMD) test +GOCLEAN=$(GOCMD) clean +GOMOD=$(GOCMD) mod +BINARY_NAME=orchard-server +BINARY_PATH=./bin/$(BINARY_NAME) + +# Build the application +build: + @echo "Building $(BINARY_NAME)..." + @mkdir -p ./bin + $(GOBUILD) -o $(BINARY_PATH) -v ./cmd/orchard-server + +# Run the application locally +run: build + @echo "Running $(BINARY_NAME)..." + $(BINARY_PATH) + +# Run tests +test: + @echo "Running tests..." + $(GOTEST) -v ./... + +# Clean build artifacts +clean: + @echo "Cleaning..." + $(GOCLEAN) + rm -rf ./bin + +# Download dependencies +deps: + @echo "Downloading dependencies..." + $(GOMOD) download + $(GOMOD) tidy + +# Build Docker image +docker-build: + @echo "Building Docker image..." + docker build -t orchard-server:latest . + +# Start all services with Docker Compose +docker-up: + @echo "Starting services..." + docker-compose up -d + +# Stop all services +docker-down: + @echo "Stopping services..." + docker-compose down + +# View logs +docker-logs: + docker-compose logs -f orchard-server + +# Run database migrations manually +migrate: + @echo "Running migrations..." + docker-compose exec postgres psql -U orchard -d orchard -f /docker-entrypoint-initdb.d/001_initial.sql + +# Development: Start dependencies only (db, minio, redis) +dev-deps: + @echo "Starting development dependencies..." + docker-compose up -d postgres minio minio-init redis + +# Full rebuild and restart +rebuild: docker-down docker-build docker-up + +# Show service status +status: + docker-compose ps + +# Initialize the S3 bucket +init-bucket: + @echo "Initializing S3 bucket..." + docker-compose up minio-init + +# Help +help: + @echo "Orchard Server Makefile" + @echo "" + @echo "Usage:" + @echo " make build - Build the Go binary" + @echo " make run - Build and run locally" + @echo " make test - Run tests" + @echo " make clean - Clean build artifacts" + @echo " make deps - Download dependencies" + @echo " make docker-build - Build Docker image" + @echo " make docker-up - Start all services" + @echo " make docker-down - Stop all services" + @echo " make docker-logs - View orchard-server logs" + @echo " make dev-deps - Start only dependencies for local dev" + @echo " make rebuild - Full rebuild and restart" + @echo " make status - Show service status" diff --git a/cmd/orchard-server/main.go b/cmd/orchard-server/main.go new file mode 100644 index 0000000..9008026 --- /dev/null +++ b/cmd/orchard-server/main.go @@ -0,0 +1,85 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/bsf/orchard/internal/api" + "github.com/bsf/orchard/internal/config" + "github.com/bsf/orchard/internal/storage" + "go.uber.org/zap" +) + +func main() { + // Initialize logger + logger, err := zap.NewProduction() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to create logger: %v\n", err) + os.Exit(1) + } + defer logger.Sync() + + // Load configuration + cfg, err := config.Load() + if err != nil { + logger.Fatal("failed to load configuration", zap.Error(err)) + } + + // Initialize database + db, err := storage.NewDatabase(&cfg.Database) + if err != nil { + logger.Fatal("failed to connect to database", zap.Error(err)) + } + defer db.Close() + + // Initialize S3 storage + s3, err := storage.NewS3Storage(&cfg.S3) + if err != nil { + logger.Fatal("failed to initialize S3 storage", zap.Error(err)) + } + + // Setup router + router := api.SetupRouter(db, s3, logger) + + // Create HTTP server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + srv := &http.Server{ + Addr: addr, + Handler: router, + ReadTimeout: 30 * time.Second, + WriteTimeout: 30 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start server in goroutine + go func() { + logger.Info("starting Orchard server", + zap.String("address", addr), + ) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Fatal("server failed", zap.Error(err)) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + logger.Info("shutting down server...") + + // Graceful shutdown with timeout + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + logger.Fatal("server forced to shutdown", zap.Error(err)) + } + + logger.Info("server stopped") +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..454c0e7 --- /dev/null +++ b/config.yaml @@ -0,0 +1,28 @@ +# Orchard Server Configuration +# This file can be overridden by environment variables prefixed with ORCHARD_ + +server: + host: "0.0.0.0" + port: 8080 + +database: + host: "localhost" + port: 5432 + user: "orchard" + password: "orchard_secret" + dbname: "orchard" + sslmode: "disable" + +s3: + endpoint: "http://localhost:9000" + region: "us-east-1" + bucket: "orchard-artifacts" + access_key_id: "minioadmin" + secret_access_key: "minioadmin" + use_path_style: true + +redis: + host: "localhost" + port: 6379 + password: "" + db: 0 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..105ad1e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,116 @@ +version: '3.8' + +services: + orchard-server: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + environment: + - ORCHARD_SERVER_HOST=0.0.0.0 + - ORCHARD_SERVER_PORT=8080 + - ORCHARD_DATABASE_HOST=postgres + - ORCHARD_DATABASE_PORT=5432 + - ORCHARD_DATABASE_USER=orchard + - ORCHARD_DATABASE_PASSWORD=orchard_secret + - ORCHARD_DATABASE_DBNAME=orchard + - ORCHARD_DATABASE_SSLMODE=disable + - ORCHARD_S3_ENDPOINT=http://minio:9000 + - ORCHARD_S3_REGION=us-east-1 + - ORCHARD_S3_BUCKET=orchard-artifacts + - ORCHARD_S3_ACCESS_KEY_ID=minioadmin + - ORCHARD_S3_SECRET_ACCESS_KEY=minioadmin + - ORCHARD_S3_USE_PATH_STYLE=true + - ORCHARD_REDIS_HOST=redis + - ORCHARD_REDIS_PORT=6379 + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + redis: + condition: service_healthy + networks: + - orchard-network + restart: unless-stopped + + postgres: + image: postgres:16-alpine + environment: + - POSTGRES_USER=orchard + - POSTGRES_PASSWORD=orchard_secret + - POSTGRES_DB=orchard + volumes: + - postgres-data:/var/lib/postgresql/data + - ./migrations:/docker-entrypoint-initdb.d:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U orchard -d orchard"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - orchard-network + restart: unless-stopped + + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + - MINIO_ROOT_USER=minioadmin + - MINIO_ROOT_PASSWORD=minioadmin + volumes: + - minio-data:/data + ports: + - "9000:9000" + - "9001:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - orchard-network + restart: unless-stopped + + minio-init: + image: minio/mc:latest + depends_on: + minio: + condition: service_healthy + entrypoint: > + /bin/sh -c " + mc alias set myminio http://minio:9000 minioadmin minioadmin; + mc mb myminio/orchard-artifacts --ignore-existing; + mc anonymous set download myminio/orchard-artifacts; + exit 0; + " + networks: + - orchard-network + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis-data:/data + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - orchard-network + restart: unless-stopped + +volumes: + postgres-data: + minio-data: + redis-data: + +networks: + orchard-network: + driver: bridge diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..44af019 --- /dev/null +++ b/go.mod @@ -0,0 +1,70 @@ +module github.com/bsf/orchard + +go 1.22 + +require ( + github.com/aws/aws-sdk-go-v2 v1.24.1 + github.com/aws/aws-sdk-go-v2/config v1.26.6 + github.com/aws/aws-sdk-go-v2/credentials v1.16.16 + github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 + github.com/gin-gonic/gin v1.9.1 + github.com/google/uuid v1.5.0 + github.com/lib/pq v1.10.9 + github.com/spf13/viper v1.18.2 + go.uber.org/zap v1.26.0 +) + +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect + github.com/aws/smithy-go v1.19.0 // indirect + github.com/bytedance/sonic v1.9.1 // indirect + github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.2 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.14.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/hashicorp/hcl v1.0.0 // 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.2.4 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.3.0 // indirect + golang.org/x/crypto v0.16.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect + golang.org/x/net v0.19.0 // indirect + golang.org/x/sys v0.15.0 // indirect + golang.org/x/text v0.14.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b6b3d7d --- /dev/null +++ b/go.sum @@ -0,0 +1,171 @@ +github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= +github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= +github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= +github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10/go.mod h1:FHbKWQtRBYUz4vO5WBWjzMD2by126ny5y/1EoaWoLfI= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 h1:L0ai8WICYHozIKK+OtPzVJBugL7culcuM4E4JOpIEm8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10/go.mod h1:byqfyxJBshFk0fF9YmK0M0ugIO8OWjzH2T3bPG4eGuA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 h1:KOxnQeWy5sXyS37fdKEvAsGHOr9fa/qvwxfJurR/BzE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10/go.mod h1:jMx5INQFYFYB3lQD9W0D8Ohgq6Wnl7NYOJ2TQndbulI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 h1:5XNlsBsEvBZBMO6p82y+sqpWg8j5aBCe+5C2GBFgqBQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= +github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= +github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= +github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= +github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= +github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= +github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams= +github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU= +github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js= +github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +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= +github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= +github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= +github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= +golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/crypto v0.16.0 h1:mMMrFzRSCF0GvB7Ne27XVtVAaXLrPmgPC7/v0tkwHaY= +golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= +golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= +golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +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= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..c76abda --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,442 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/bsf/orchard/internal/models" + "github.com/bsf/orchard/internal/storage" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type Handler struct { + db *storage.Database + s3 *storage.S3Storage + logger *zap.Logger +} + +func NewHandler(db *storage.Database, s3 *storage.S3Storage, logger *zap.Logger) *Handler { + return &Handler{ + db: db, + s3: s3, + logger: logger, + } +} + +// Health check endpoint +func (h *Handler) Health(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "healthy"}) +} + +// Grove handlers + +func (h *Handler) CreateGrove(c *gin.Context) { + var grove models.Grove + if err := c.ShouldBindJSON(&grove); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + grove.CreatedBy = c.GetString("user_id") + if err := h.db.CreateGrove(c.Request.Context(), &grove); err != nil { + h.logger.Error("failed to create grove", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create grove"}) + return + } + + h.logAudit(c, "create_grove", grove.Name) + c.JSON(http.StatusCreated, grove) +} + +func (h *Handler) GetGrove(c *gin.Context) { + groveName := c.Param("grove") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil { + h.logger.Error("failed to get grove", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get grove"}) + return + } + + if grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + c.JSON(http.StatusOK, grove) +} + +func (h *Handler) ListGroves(c *gin.Context) { + userID := c.GetString("user_id") + + groves, err := h.db.ListGroves(c.Request.Context(), userID) + if err != nil { + h.logger.Error("failed to list groves", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list groves"}) + return + } + + c.JSON(http.StatusOK, groves) +} + +// Tree handlers + +func (h *Handler) CreateTree(c *gin.Context) { + groveName := c.Param("grove") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + var tree models.Tree + if err := c.ShouldBindJSON(&tree); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + tree.GroveID = grove.ID + if err := h.db.CreateTree(c.Request.Context(), &tree); err != nil { + h.logger.Error("failed to create tree", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create tree"}) + return + } + + h.logAudit(c, "create_tree", fmt.Sprintf("%s/%s", groveName, tree.Name)) + c.JSON(http.StatusCreated, tree) +} + +func (h *Handler) ListTrees(c *gin.Context) { + groveName := c.Param("grove") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + trees, err := h.db.ListTrees(c.Request.Context(), grove.ID) + if err != nil { + h.logger.Error("failed to list trees", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list trees"}) + return + } + + c.JSON(http.StatusOK, trees) +} + +// Fruit handlers (content-addressable storage) + +func (h *Handler) Cultivate(c *gin.Context) { + groveName := c.Param("grove") + treeName := c.Param("tree") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) + if err != nil || tree == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) + return + } + + // Get the uploaded file + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) + return + } + defer file.Close() + + // Store in S3 (content-addressable) + hash, size, err := h.s3.Store(c.Request.Context(), file) + if err != nil { + h.logger.Error("failed to store artifact", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to store artifact"}) + return + } + + // Create fruit record + fruit := &models.Fruit{ + ID: hash, + Size: size, + ContentType: header.Header.Get("Content-Type"), + OriginalName: header.Filename, + CreatedBy: c.GetString("user_id"), + S3Key: fmt.Sprintf("%s/%s/%s", hash[:2], hash[2:4], hash), + } + + if err := h.db.CreateFruit(c.Request.Context(), fruit); err != nil { + h.logger.Error("failed to create fruit record", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create fruit record"}) + return + } + + // Create harvest record + harvest := &models.Harvest{ + FruitID: hash, + TreeID: tree.ID, + OriginalName: header.Filename, + HarvestedBy: c.GetString("user_id"), + SourceIP: c.ClientIP(), + } + + if err := h.db.CreateHarvest(c.Request.Context(), harvest); err != nil { + h.logger.Error("failed to create harvest record", zap.Error(err)) + } + + // Optionally create a graft (tag) if specified + tag := c.PostForm("tag") + if tag != "" { + graft := &models.Graft{ + TreeID: tree.ID, + Name: tag, + FruitID: hash, + CreatedBy: c.GetString("user_id"), + } + if err := h.db.CreateGraft(c.Request.Context(), graft); err != nil { + h.logger.Error("failed to create graft", zap.Error(err)) + } + } + + h.logAudit(c, "cultivate", fmt.Sprintf("%s/%s/%s", groveName, treeName, hash)) + + c.JSON(http.StatusCreated, gin.H{ + "fruit_id": hash, + "size": size, + "grove": groveName, + "tree": treeName, + "tag": tag, + }) +} + +func (h *Handler) Harvest(c *gin.Context) { + groveName := c.Param("grove") + treeName := c.Param("tree") + ref := c.Param("ref") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) + if err != nil || tree == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) + return + } + + // Resolve reference to fruit ID + var fruitID string + if strings.HasPrefix(ref, "fruit:") { + // Direct fruit reference + fruitID = strings.TrimPrefix(ref, "fruit:") + } else if strings.HasPrefix(ref, "version:") || strings.HasPrefix(ref, "tag:") { + // Tag/version reference + tagName := strings.TrimPrefix(ref, "version:") + tagName = strings.TrimPrefix(tagName, "tag:") + graft, err := h.db.GetGraft(c.Request.Context(), tree.ID, tagName) + if err != nil || graft == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "reference not found"}) + return + } + fruitID = graft.FruitID + } else { + // Assume it's a tag name + graft, err := h.db.GetGraft(c.Request.Context(), tree.ID, ref) + if err != nil || graft == nil { + // Maybe it's a direct fruit ID + fruit, err := h.db.GetFruit(c.Request.Context(), ref) + if err != nil || fruit == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "reference not found"}) + return + } + fruitID = ref + } else { + fruitID = graft.FruitID + } + } + + // Get fruit metadata + fruit, err := h.db.GetFruit(c.Request.Context(), fruitID) + if err != nil || fruit == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) + return + } + + // Track consumer if project URL is provided + projectURL := c.GetHeader("X-Orchard-Project") + if projectURL != "" { + h.db.TrackConsumer(c.Request.Context(), tree.ID, projectURL) + } + + // Stream content from S3 + reader, err := h.s3.Retrieve(c.Request.Context(), fruitID) + if err != nil { + h.logger.Error("failed to retrieve artifact", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve artifact"}) + return + } + defer reader.Close() + + h.logAudit(c, "harvest", fmt.Sprintf("%s/%s/%s", groveName, treeName, fruitID)) + + c.Header("Content-Type", fruit.ContentType) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fruit.OriginalName)) + c.Header("X-Orchard-Fruit-ID", fruitID) + c.Header("X-Orchard-Size", fmt.Sprintf("%d", fruit.Size)) + + io.Copy(c.Writer, reader) +} + +func (h *Handler) GetFruit(c *gin.Context) { + fruitID := c.Param("fruit") + + fruit, err := h.db.GetFruit(c.Request.Context(), fruitID) + if err != nil { + h.logger.Error("failed to get fruit", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get fruit"}) + return + } + + if fruit == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) + return + } + + c.JSON(http.StatusOK, fruit) +} + +// Graft handlers (aliasing/tagging) + +func (h *Handler) CreateGraft(c *gin.Context) { + groveName := c.Param("grove") + treeName := c.Param("tree") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) + if err != nil || tree == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) + return + } + + var graft models.Graft + if err := c.ShouldBindJSON(&graft); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + graft.TreeID = tree.ID + graft.CreatedBy = c.GetString("user_id") + + // Verify fruit exists + fruit, err := h.db.GetFruit(c.Request.Context(), graft.FruitID) + if err != nil || fruit == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "fruit not found"}) + return + } + + if err := h.db.CreateGraft(c.Request.Context(), &graft); err != nil { + h.logger.Error("failed to create graft", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create graft"}) + return + } + + h.logAudit(c, "create_graft", fmt.Sprintf("%s/%s/%s", groveName, treeName, graft.Name)) + c.JSON(http.StatusCreated, graft) +} + +func (h *Handler) ListGrafts(c *gin.Context) { + groveName := c.Param("grove") + treeName := c.Param("tree") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) + if err != nil || tree == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) + return + } + + grafts, err := h.db.ListGrafts(c.Request.Context(), tree.ID) + if err != nil { + h.logger.Error("failed to list grafts", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list grafts"}) + return + } + + c.JSON(http.StatusOK, grafts) +} + +// Consumer tracking handlers + +func (h *Handler) GetConsumers(c *gin.Context) { + groveName := c.Param("grove") + treeName := c.Param("tree") + + grove, err := h.db.GetGrove(c.Request.Context(), groveName) + if err != nil || grove == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "grove not found"}) + return + } + + tree, err := h.db.GetTree(c.Request.Context(), grove.ID, treeName) + if err != nil || tree == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "tree not found"}) + return + } + + consumers, err := h.db.GetConsumers(c.Request.Context(), tree.ID) + if err != nil { + h.logger.Error("failed to get consumers", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get consumers"}) + return + } + + c.JSON(http.StatusOK, consumers) +} + +// Search handler + +func (h *Handler) Search(c *gin.Context) { + // TODO: Implement format-aware search + c.JSON(http.StatusOK, gin.H{"message": "search not yet implemented"}) +} + +// Helper to log audit events +func (h *Handler) logAudit(c *gin.Context, action, resource string) { + details, _ := json.Marshal(map[string]string{ + "method": c.Request.Method, + "path": c.Request.URL.Path, + }) + + log := &models.AuditLog{ + Action: action, + Resource: resource, + UserID: c.GetString("user_id"), + Details: string(details), + SourceIP: c.ClientIP(), + } + + if err := h.db.CreateAuditLog(c.Request.Context(), log); err != nil { + h.logger.Error("failed to create audit log", zap.Error(err)) + } +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..8a2265b --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,153 @@ +package api + +import ( + "embed" + "io/fs" + + "github.com/bsf/orchard/internal/storage" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +//go:embed static/* +var staticFiles embed.FS + +// SetupRouter configures all API routes +func SetupRouter(db *storage.Database, s3 *storage.S3Storage, logger *zap.Logger) *gin.Engine { + router := gin.New() + router.Use(gin.Recovery()) + router.Use(LoggerMiddleware(logger)) + + handler := NewHandler(db, s3, logger) + + // Serve static files from embedded FS + staticFS, _ := fs.Sub(staticFiles, "static") + + // Root - serve index.html + router.GET("/", func(c *gin.Context) { + data, err := fs.ReadFile(staticFS, "index.html") + if err != nil { + c.String(500, "Error loading page: "+err.Error()) + return + } + c.Data(200, "text/html; charset=utf-8", data) + }) + + // CSS file + router.GET("/static/style.css", func(c *gin.Context) { + data, err := fs.ReadFile(staticFS, "style.css") + if err != nil { + c.String(404, "Not found: "+err.Error()) + return + } + c.Data(200, "text/css; charset=utf-8", data) + }) + + // JS file + router.GET("/static/app.js", func(c *gin.Context) { + data, err := fs.ReadFile(staticFS, "app.js") + if err != nil { + c.String(404, "Not found: "+err.Error()) + return + } + c.Data(200, "application/javascript; charset=utf-8", data) + }) + + // Health check + router.GET("/health", handler.Health) + + // API v1 + v1 := router.Group("/api/v1") + { + // Authentication middleware + v1.Use(AuthMiddleware(db)) + + // Grove routes + groves := v1.Group("/groves") + { + groves.GET("", handler.ListGroves) + groves.POST("", handler.CreateGrove) + groves.GET("/:grove", handler.GetGrove) + } + + // Tree routes + trees := v1.Group("/grove/:grove/trees") + { + trees.GET("", handler.ListTrees) + trees.POST("", handler.CreateTree) + } + + // Fruit routes (content-addressable storage) + fruits := v1.Group("/grove/:grove/:tree") + { + // Upload artifact (cultivate) + fruits.POST("/cultivate", handler.Cultivate) + + // Download artifact (harvest) + fruits.GET("/+/:ref", handler.Harvest) + + // List grafts (tags/versions) + fruits.GET("/grafts", handler.ListGrafts) + + // Create graft (tag) + fruits.POST("/graft", handler.CreateGraft) + + // Get consumers + fruits.GET("/consumers", handler.GetConsumers) + } + + // Direct fruit access by hash + v1.GET("/fruit/:fruit", handler.GetFruit) + + // Search + v1.GET("/search", handler.Search) + } + + // Compatibility endpoint matching the URL pattern from spec + // /grove/{project}/{tree}/+/{ref} + router.GET("/grove/:grove/:tree/+/:ref", AuthMiddleware(db), handler.Harvest) + + return router +} + +// LoggerMiddleware logs requests +func LoggerMiddleware(logger *zap.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + + logger.Info("request", + zap.String("method", c.Request.Method), + zap.String("path", c.Request.URL.Path), + zap.Int("status", c.Writer.Status()), + zap.String("client_ip", c.ClientIP()), + ) + } +} + +// AuthMiddleware handles authentication +func AuthMiddleware(db *storage.Database) gin.HandlerFunc { + return func(c *gin.Context) { + // Check for API key in header + apiKey := c.GetHeader("X-Orchard-API-Key") + if apiKey != "" { + // TODO: Validate API key against database + // For now, extract user ID from key + c.Set("user_id", "api-user") + c.Next() + return + } + + // Check for Bearer token + authHeader := c.GetHeader("Authorization") + if authHeader != "" { + // TODO: Implement OIDC/SAML validation + c.Set("user_id", "bearer-user") + c.Next() + return + } + + // Allow anonymous access for public groves (read only) + c.Set("user_id", "anonymous") + c.Next() + } +} diff --git a/internal/api/static/app.js b/internal/api/static/app.js new file mode 100644 index 0000000..d208d60 --- /dev/null +++ b/internal/api/static/app.js @@ -0,0 +1,573 @@ +// Orchard Web UI + +const API_BASE = '/api/v1'; + +// State +let currentGrove = null; +let currentTree = null; + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + setupNavigation(); + loadGroves(); +}); + +// Navigation +function setupNavigation() { + document.querySelectorAll('.nav-link').forEach(link => { + link.addEventListener('click', (e) => { + e.preventDefault(); + const view = link.dataset.view; + showView(view); + + document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active')); + link.classList.add('active'); + }); + }); +} + +function showView(viewName) { + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.getElementById(`${viewName}-view`).classList.add('active'); + + // Load data for view + if (viewName === 'groves') { + loadGroves(); + } else if (viewName === 'upload') { + loadGrovesForUpload(); + } +} + +// Groves +async function loadGroves() { + const container = document.getElementById('groves-list'); + container.innerHTML = '
Loading groves...
'; + + try { + const response = await fetch(`${API_BASE}/groves`); + const groves = await response.json(); + + if (!groves || groves.length === 0) { + container.innerHTML = ` +
+

No groves yet

+

Create your first grove to get started

+
+ `; + return; + } + + container.innerHTML = groves.map(grove => ` +
+

+ 🌳 + ${escapeHtml(grove.name)} + + ${grove.is_public ? 'Public' : 'Private'} + +

+

${escapeHtml(grove.description || 'No description')}

+
+ Created ${formatDate(grove.created_at)} +
+
+ `).join(''); + } catch (error) { + container.innerHTML = `

Error loading groves: ${error.message}

`; + } +} + +async function viewGrove(groveName) { + currentGrove = groveName; + document.getElementById('grove-detail-title').textContent = groveName; + + // Load grove info + try { + const response = await fetch(`${API_BASE}/groves/${groveName}`); + const grove = await response.json(); + + document.getElementById('grove-info').innerHTML = ` +
+
+ + ${escapeHtml(grove.name)} +
+
+ + + ${grove.is_public ? 'Public' : 'Private'} + +
+
+ + ${formatDate(grove.created_at)} +
+
+ + ${escapeHtml(grove.description || 'No description')} +
+
+ `; + } catch (error) { + console.error('Error loading grove:', error); + } + + // Load trees + await loadTrees(groveName); + + // Show view + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.getElementById('grove-detail-view').classList.add('active'); +} + +async function loadTrees(groveName) { + const container = document.getElementById('trees-list'); + container.innerHTML = '
Loading trees...
'; + + try { + const response = await fetch(`${API_BASE}/grove/${groveName}/trees`); + const trees = await response.json(); + + if (!trees || trees.length === 0) { + container.innerHTML = ` +
+

No trees yet

+

Create a tree to store artifacts

+
+ `; + return; + } + + container.innerHTML = trees.map(tree => ` +
+

+ 🌲 + ${escapeHtml(tree.name)} +

+

${escapeHtml(tree.description || 'No description')}

+
+ Created ${formatDate(tree.created_at)} +
+
+ `).join(''); + } catch (error) { + container.innerHTML = `

Error loading trees: ${error.message}

`; + } +} + +async function viewTree(groveName, treeName) { + currentGrove = groveName; + currentTree = treeName; + + document.getElementById('tree-detail-title').textContent = `${groveName} / ${treeName}`; + + document.getElementById('tree-info').innerHTML = ` +
+
+ + ${escapeHtml(groveName)} +
+
+ + ${escapeHtml(treeName)} +
+
+ `; + + // Load grafts + await loadGrafts(groveName, treeName); + + // Show view + document.querySelectorAll('.view').forEach(v => v.classList.remove('active')); + document.getElementById('tree-detail-view').classList.add('active'); +} + +async function loadGrafts(groveName, treeName) { + const container = document.getElementById('grafts-list'); + container.innerHTML = '
Loading versions...
'; + + try { + const response = await fetch(`${API_BASE}/grove/${groveName}/${treeName}/grafts`); + const grafts = await response.json(); + + if (!grafts || grafts.length === 0) { + container.innerHTML = ` +
+

No versions yet

+

Upload an artifact to create the first version

+
+ `; + return; + } + + container.innerHTML = ` + + + + + + + + + + + ${grafts.map(graft => ` + + + + + + + `).join('')} + +
TagFruit IDCreatedActions
${escapeHtml(graft.name)} + + ${graft.fruit_id.substring(0, 16)}... + + ${formatDate(graft.created_at)} + + Download + +
+ `; + } catch (error) { + container.innerHTML = `

Error loading versions: ${error.message}

`; + } +} + +function backToGrove() { + if (currentGrove) { + viewGrove(currentGrove); + } else { + showView('groves'); + } +} + +// Create Grove +function showCreateGroveModal() { + document.getElementById('create-grove-modal').classList.remove('hidden'); + document.getElementById('grove-name').focus(); +} + +async function createGrove(e) { + e.preventDefault(); + + const name = document.getElementById('grove-name').value; + const description = document.getElementById('grove-description').value; + const isPublic = document.getElementById('grove-public').checked; + + try { + const response = await fetch(`${API_BASE}/groves`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description, is_public: isPublic }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to create grove'); + } + + showToast('Grove created successfully!', 'success'); + closeModals(); + loadGroves(); + + // Reset form + document.getElementById('grove-name').value = ''; + document.getElementById('grove-description').value = ''; + document.getElementById('grove-public').checked = true; + } catch (error) { + showToast(error.message, 'error'); + } +} + +// Create Tree +function showCreateTreeModal() { + document.getElementById('create-tree-modal').classList.remove('hidden'); + document.getElementById('tree-name').focus(); +} + +async function createTree(e) { + e.preventDefault(); + + const name = document.getElementById('tree-name').value; + const description = document.getElementById('tree-description').value; + + try { + const response = await fetch(`${API_BASE}/grove/${currentGrove}/trees`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, description }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to create tree'); + } + + showToast('Tree created successfully!', 'success'); + closeModals(); + loadTrees(currentGrove); + + // Reset form + document.getElementById('tree-name').value = ''; + document.getElementById('tree-description').value = ''; + } catch (error) { + showToast(error.message, 'error'); + } +} + +// Upload +async function loadGrovesForUpload() { + const select = document.getElementById('upload-grove'); + select.innerHTML = ''; + + try { + const response = await fetch(`${API_BASE}/groves`); + const groves = await response.json(); + + select.innerHTML = '' + + (groves || []).map(g => ``).join(''); + } catch (error) { + select.innerHTML = ''; + } +} + +async function loadTreesForUpload() { + const groveName = document.getElementById('upload-grove').value; + const select = document.getElementById('upload-tree'); + + if (!groveName) { + select.innerHTML = ''; + return; + } + + select.innerHTML = ''; + + try { + const response = await fetch(`${API_BASE}/grove/${groveName}/trees`); + const trees = await response.json(); + + select.innerHTML = '' + + (trees || []).map(t => ``).join(''); + } catch (error) { + select.innerHTML = ''; + } +} + +function updateFileName() { + const input = document.getElementById('upload-file'); + const display = document.getElementById('file-name'); + display.textContent = input.files[0]?.name || ''; +} + +async function uploadArtifact(e) { + e.preventDefault(); + + const grove = document.getElementById('upload-grove').value; + const tree = document.getElementById('upload-tree').value; + const file = document.getElementById('upload-file').files[0]; + const tag = document.getElementById('upload-tag').value; + + const formData = new FormData(); + formData.append('file', file); + if (tag) formData.append('tag', tag); + + const resultDiv = document.getElementById('upload-result'); + resultDiv.innerHTML = '
Uploading...
'; + resultDiv.classList.remove('hidden', 'success', 'error'); + + try { + const response = await fetch(`${API_BASE}/grove/${grove}/${tree}/cultivate`, { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Upload failed'); + } + + resultDiv.classList.add('success'); + resultDiv.innerHTML = ` +

Upload Successful!

+
+
Fruit ID
+
${result.fruit_id}
+
Size
+
${formatBytes(result.size)}
+
Grove
+
${escapeHtml(result.grove)}
+
Tree
+
${escapeHtml(result.tree)}
+ ${result.tag ? `
Tag
${escapeHtml(result.tag)}
` : ''} +
+
+ + Download Artifact + +
+ `; + + showToast('Artifact uploaded successfully!', 'success'); + } catch (error) { + resultDiv.classList.add('error'); + resultDiv.innerHTML = `

Upload Failed

${escapeHtml(error.message)}

`; + showToast(error.message, 'error'); + } +} + +async function uploadToTree(e) { + e.preventDefault(); + + const file = document.getElementById('tree-upload-file').files[0]; + const tag = document.getElementById('tree-upload-tag').value; + + const formData = new FormData(); + formData.append('file', file); + if (tag) formData.append('tag', tag); + + try { + const response = await fetch(`${API_BASE}/grove/${currentGrove}/${currentTree}/cultivate`, { + method: 'POST', + body: formData + }); + + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Upload failed'); + } + + showToast('Artifact uploaded successfully!', 'success'); + + // Reload grafts + loadGrafts(currentGrove, currentTree); + + // Reset form + document.getElementById('tree-upload-file').value = ''; + document.getElementById('tree-upload-tag').value = ''; + } catch (error) { + showToast(error.message, 'error'); + } +} + +// Search +function handleSearchKeyup(e) { + if (e.key === 'Enter') { + searchFruit(); + } +} + +async function searchFruit() { + const fruitId = document.getElementById('search-input').value.trim(); + const resultDiv = document.getElementById('search-result'); + + if (!fruitId) { + showToast('Please enter a fruit ID', 'error'); + return; + } + + resultDiv.innerHTML = '
Searching...
'; + resultDiv.classList.remove('hidden', 'success', 'error'); + + try { + const response = await fetch(`${API_BASE}/fruit/${fruitId}`); + const result = await response.json(); + + if (!response.ok) { + throw new Error(result.error || 'Fruit not found'); + } + + resultDiv.classList.add('success'); + resultDiv.innerHTML = ` +

Fruit Found

+
+
Fruit ID
+
${result.id}
+
Original Name
+
${escapeHtml(result.original_name || 'Unknown')}
+
Size
+
${formatBytes(result.size)}
+
Content Type
+
${escapeHtml(result.content_type || 'Unknown')}
+
Created
+
${formatDate(result.created_at)}
+
Reference Count
+
${result.ref_count}
+
+ `; + } catch (error) { + resultDiv.classList.add('error'); + resultDiv.innerHTML = `

Not Found

${escapeHtml(error.message)}

`; + } +} + +// Modals +function closeModals() { + document.querySelectorAll('.modal').forEach(m => m.classList.add('hidden')); +} + +// Close modal on outside click +document.addEventListener('click', (e) => { + if (e.target.classList.contains('modal')) { + closeModals(); + } +}); + +// Close modal on Escape +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + closeModals(); + } +}); + +// Toast notifications +function showToast(message, type = 'info') { + const container = document.getElementById('toast-container'); + const toast = document.createElement('div'); + toast.className = `toast ${type}`; + toast.textContent = message; + container.appendChild(toast); + + setTimeout(() => { + toast.remove(); + }, 3000); +} + +// Utilities +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +function formatDate(dateStr) { + if (!dateStr) return 'Unknown'; + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +function formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function copyToClipboard(text) { + navigator.clipboard.writeText(text).then(() => { + showToast('Copied to clipboard!', 'success'); + }); +} diff --git a/internal/api/static/index.html b/internal/api/static/index.html new file mode 100644 index 0000000..6c3ab5b --- /dev/null +++ b/internal/api/static/index.html @@ -0,0 +1,183 @@ + + + + + + Orchard - Content Addressable Storage + + + + + +
+ +
+
+

Groves

+ +
+
+
Loading groves...
+
+
+ + +
+
+ +

Grove

+
+
+
+
+

Trees

+ +
+
+
+
+ + +
+
+ +

Tree

+
+
+
+
+

Versions (Grafts)

+
+
+
+
+

Upload Artifact

+
+
+ + +
+
+ + +
+ +
+
+
+ + +
+

Upload Artifact

+
+
+
+ + +
+
+ + +
+
+
+ +
+ +

Drop file here or click to browse

+ +
+
+
+ + +
+ +
+ +
+ + +
+

Search Artifacts

+ + +
+
+ + + + + + + + +
+ + + + diff --git a/internal/api/static/style.css b/internal/api/static/style.css new file mode 100644 index 0000000..21889de --- /dev/null +++ b/internal/api/static/style.css @@ -0,0 +1,603 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --primary: #059669; + --primary-dark: #047857; + --primary-light: #10b981; + --secondary: #6b7280; + --background: #f3f4f6; + --surface: #ffffff; + --text: #1f2937; + --text-light: #6b7280; + --border: #e5e7eb; + --success: #10b981; + --error: #ef4444; + --warning: #f59e0b; + --radius: 8px; + --shadow: 0 1px 3px rgba(0,0,0,0.1), 0 1px 2px rgba(0,0,0,0.06); + --shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif; + background: var(--background); + color: var(--text); + line-height: 1.6; +} + +/* Navbar */ +.navbar { + background: var(--surface); + border-bottom: 1px solid var(--border); + padding: 0 2rem; + height: 60px; + display: flex; + align-items: center; + justify-content: space-between; + position: sticky; + top: 0; + z-index: 100; +} + +.nav-brand { + font-size: 1.5rem; + font-weight: 700; + color: var(--primary); + display: flex; + align-items: center; + gap: 0.5rem; +} + +.logo { + font-size: 1.75rem; +} + +.nav-links { + display: flex; + gap: 0.5rem; +} + +.nav-link { + padding: 0.5rem 1rem; + text-decoration: none; + color: var(--text-light); + border-radius: var(--radius); + transition: all 0.2s; +} + +.nav-link:hover { + background: var(--background); + color: var(--text); +} + +.nav-link.active { + background: var(--primary); + color: white; +} + +/* Container */ +.container { + max-width: 1200px; + margin: 0 auto; + padding: 2rem; +} + +/* Views */ +.view { + display: none; +} + +.view.active { + display: block; +} + +.view-header { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.view-header h1 { + flex: 1; +} + +/* Cards */ +.card { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 1.5rem; + box-shadow: var(--shadow); +} + +.card-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; +} + +.grove-card, .tree-card { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 1.25rem; + cursor: pointer; + transition: all 0.2s; +} + +.grove-card:hover, .tree-card:hover { + border-color: var(--primary); + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} + +.grove-card h3, .tree-card h3 { + color: var(--primary); + margin-bottom: 0.5rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.grove-card p, .tree-card p { + color: var(--text-light); + font-size: 0.9rem; +} + +.grove-card .meta, .tree-card .meta { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); + font-size: 0.8rem; + color: var(--text-light); +} + +.badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.badge-public { + background: #d1fae5; + color: #065f46; +} + +.badge-private { + background: #fef3c7; + color: #92400e; +} + +/* Sections */ +.section { + margin-top: 2rem; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.section-header h2 { + font-size: 1.25rem; +} + +/* Buttons */ +.btn { + padding: 0.5rem 1rem; + border-radius: var(--radius); + border: none; + font-size: 0.9rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.btn-primary { + background: var(--primary); + color: white; +} + +.btn-primary:hover { + background: var(--primary-dark); +} + +.btn-secondary { + background: var(--background); + color: var(--text); + border: 1px solid var(--border); +} + +.btn-secondary:hover { + background: var(--border); +} + +.btn-lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +/* Forms */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 500; + color: var(--text); +} + +.form-group input[type="text"], +.form-group input[type="email"], +.form-group textarea, +.form-group select { + width: 100%; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 1rem; + transition: border-color 0.2s; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary); +} + +.form-group textarea { + min-height: 100px; + resize: vertical; +} + +.form-group.checkbox label { + display: flex; + align-items: center; + gap: 0.5rem; + cursor: pointer; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; +} + +/* File Drop */ +.file-drop { + border: 2px dashed var(--border); + border-radius: var(--radius); + padding: 2rem; + text-align: center; + cursor: pointer; + transition: all 0.2s; + position: relative; +} + +.file-drop:hover { + border-color: var(--primary); + background: #f0fdf4; +} + +.file-drop input[type="file"] { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0; + cursor: pointer; +} + +.file-drop p { + color: var(--text-light); + margin-bottom: 0.5rem; +} + +#file-name { + color: var(--primary); + font-weight: 500; +} + +/* Upload Form */ +.upload-form { + max-width: 600px; +} + +.upload-form.card { + padding: 2rem; +} + +/* Tables */ +.table-container { + overflow-x: auto; +} + +table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: var(--radius); + overflow: hidden; +} + +th, td { + padding: 0.75rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +th { + background: var(--background); + font-weight: 600; + color: var(--text-light); + font-size: 0.85rem; + text-transform: uppercase; +} + +tr:hover { + background: #f9fafb; +} + +.hash { + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.85rem; + color: var(--text-light); +} + +.hash-short { + cursor: pointer; +} + +.hash-short:hover { + color: var(--primary); +} + +/* Search */ +.search-box { + display: flex; + gap: 1rem; + max-width: 600px; + margin-bottom: 2rem; +} + +.search-box input { + flex: 1; + padding: 0.75rem 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + font-size: 1rem; +} + +.search-box input:focus { + outline: none; + border-color: var(--primary); +} + +/* Result Card */ +.result-card { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 1.5rem; + margin-top: 1.5rem; + max-width: 600px; +} + +.result-card.success { + border-color: var(--success); + background: #f0fdf4; +} + +.result-card.error { + border-color: var(--error); + background: #fef2f2; +} + +.result-card h3 { + margin-bottom: 1rem; +} + +.result-card dl { + display: grid; + grid-template-columns: 150px 1fr; + gap: 0.5rem; +} + +.result-card dt { + font-weight: 500; + color: var(--text-light); +} + +.result-card dd { + word-break: break-all; +} + +.result-card .actions { + margin-top: 1rem; + padding-top: 1rem; + border-top: 1px solid var(--border); +} + +/* Modal */ +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal.hidden { + display: none; +} + +.modal-content { + background: var(--surface); + border-radius: var(--radius); + width: 100%; + max-width: 500px; + max-height: 90vh; + overflow-y: auto; +} + +.modal-header { + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; +} + +.modal-header h2 { + font-size: 1.25rem; +} + +.modal-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: var(--text-light); +} + +.modal-close:hover { + color: var(--text); +} + +.modal-content form { + padding: 1.5rem; +} + +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.5rem; +} + +/* Info boxes */ +.grove-info, .tree-info { + background: var(--surface); + border-radius: var(--radius); + border: 1px solid var(--border); + padding: 1rem 1.5rem; + margin-bottom: 1rem; +} + +.info-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 1rem; +} + +.info-item label { + display: block; + font-size: 0.8rem; + color: var(--text-light); + margin-bottom: 0.25rem; +} + +.info-item span { + font-weight: 500; +} + +/* Toast */ +#toast-container { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 2000; +} + +.toast { + background: var(--text); + color: white; + padding: 1rem 1.5rem; + border-radius: var(--radius); + margin-top: 0.5rem; + box-shadow: var(--shadow-lg); + animation: slideIn 0.3s ease; +} + +.toast.success { + background: var(--success); +} + +.toast.error { + background: var(--error); +} + +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Loading */ +.loading { + text-align: center; + padding: 2rem; + color: var(--text-light); +} + +/* Hidden */ +.hidden { + display: none !important; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem; + color: var(--text-light); +} + +.empty-state h3 { + margin-bottom: 0.5rem; + color: var(--text); +} + +/* Responsive */ +@media (max-width: 768px) { + .navbar { + padding: 0 1rem; + } + + .container { + padding: 1rem; + } + + .form-row { + grid-template-columns: 1fr; + } + + .card-grid { + grid-template-columns: 1fr; + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..83cd0d0 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,104 @@ +package config + +import ( + "strings" + + "github.com/spf13/viper" +) + +type Config struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + S3 S3Config `mapstructure:"s3"` + Redis RedisConfig `mapstructure:"redis"` +} + +type ServerConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` +} + +type DatabaseConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` + SSLMode string `mapstructure:"sslmode"` +} + +type S3Config struct { + Endpoint string `mapstructure:"endpoint"` + Region string `mapstructure:"region"` + Bucket string `mapstructure:"bucket"` + AccessKeyID string `mapstructure:"access_key_id"` + SecretAccessKey string `mapstructure:"secret_access_key"` + UsePathStyle bool `mapstructure:"use_path_style"` +} + +type RedisConfig struct { + Host string `mapstructure:"host"` + Port int `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` +} + +func Load() (*Config, error) { + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("/etc/orchard") + + // Environment variable overrides + viper.SetEnvPrefix("ORCHARD") + viper.AutomaticEnv() + viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_")) + + // Bind environment variables explicitly for nested config + viper.BindEnv("server.host", "ORCHARD_SERVER_HOST") + viper.BindEnv("server.port", "ORCHARD_SERVER_PORT") + viper.BindEnv("database.host", "ORCHARD_DATABASE_HOST") + viper.BindEnv("database.port", "ORCHARD_DATABASE_PORT") + viper.BindEnv("database.user", "ORCHARD_DATABASE_USER") + viper.BindEnv("database.password", "ORCHARD_DATABASE_PASSWORD") + viper.BindEnv("database.dbname", "ORCHARD_DATABASE_DBNAME") + viper.BindEnv("database.sslmode", "ORCHARD_DATABASE_SSLMODE") + viper.BindEnv("s3.endpoint", "ORCHARD_S3_ENDPOINT") + viper.BindEnv("s3.region", "ORCHARD_S3_REGION") + viper.BindEnv("s3.bucket", "ORCHARD_S3_BUCKET") + viper.BindEnv("s3.access_key_id", "ORCHARD_S3_ACCESS_KEY_ID") + viper.BindEnv("s3.secret_access_key", "ORCHARD_S3_SECRET_ACCESS_KEY") + viper.BindEnv("s3.use_path_style", "ORCHARD_S3_USE_PATH_STYLE") + viper.BindEnv("redis.host", "ORCHARD_REDIS_HOST") + viper.BindEnv("redis.port", "ORCHARD_REDIS_PORT") + viper.BindEnv("redis.password", "ORCHARD_REDIS_PASSWORD") + viper.BindEnv("redis.db", "ORCHARD_REDIS_DB") + + // Defaults + viper.SetDefault("server.host", "0.0.0.0") + viper.SetDefault("server.port", 8080) + viper.SetDefault("database.host", "localhost") + viper.SetDefault("database.port", 5432) + viper.SetDefault("database.user", "orchard") + viper.SetDefault("database.dbname", "orchard") + viper.SetDefault("database.sslmode", "disable") + viper.SetDefault("s3.region", "us-east-1") + viper.SetDefault("s3.bucket", "orchard-artifacts") + viper.SetDefault("s3.use_path_style", true) + viper.SetDefault("redis.host", "localhost") + viper.SetDefault("redis.port", 6379) + viper.SetDefault("redis.db", 0) + + if err := viper.ReadInConfig(); err != nil { + if _, ok := err.(viper.ConfigFileNotFoundError); !ok { + return nil, err + } + } + + var cfg Config + if err := viper.Unmarshal(&cfg); err != nil { + return nil, err + } + + return &cfg, nil +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..4305762 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,110 @@ +package models + +import ( + "time" +) + +// Fruit represents a content-addressable artifact identified by SHA256 hash +type Fruit struct { + ID string `json:"id" db:"id"` // SHA256 hash + Size int64 `json:"size" db:"size"` // Size in bytes + ContentType string `json:"content_type" db:"content_type"` // MIME type + OriginalName string `json:"original_name" db:"original_name"` // Original filename + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy string `json:"created_by" db:"created_by"` // Harvester ID + RefCount int `json:"ref_count" db:"ref_count"` // Reference count + S3Key string `json:"-" db:"s3_key"` // S3 object key +} + +// Tree represents a named package within a grove +type Tree struct { + ID string `json:"id" db:"id"` + GroveID string `json:"grove_id" db:"grove_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` +} + +// Grove represents a top-level project +type Grove struct { + ID string `json:"id" db:"id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + IsPublic bool `json:"is_public" db:"is_public"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + CreatedBy string `json:"created_by" db:"created_by"` +} + +// Graft represents an alias/tag pointing to a specific fruit +type Graft struct { + ID string `json:"id" db:"id"` + TreeID string `json:"tree_id" db:"tree_id"` + Name string `json:"name" db:"name"` // e.g., "latest", "v1.2.3", "stable" + FruitID string `json:"fruit_id" db:"fruit_id"` // SHA256 of the fruit + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy string `json:"created_by" db:"created_by"` +} + +// GraftHistory tracks changes to grafts for rollback capability +type GraftHistory struct { + ID string `json:"id" db:"id"` + GraftID string `json:"graft_id" db:"graft_id"` + OldFruitID string `json:"old_fruit_id" db:"old_fruit_id"` + NewFruitID string `json:"new_fruit_id" db:"new_fruit_id"` + ChangedAt time.Time `json:"changed_at" db:"changed_at"` + ChangedBy string `json:"changed_by" db:"changed_by"` +} + +// Harvest represents metadata about an upload event +type Harvest struct { + ID string `json:"id" db:"id"` + FruitID string `json:"fruit_id" db:"fruit_id"` + TreeID string `json:"tree_id" db:"tree_id"` + OriginalName string `json:"original_name" db:"original_name"` + HarvestedAt time.Time `json:"harvested_at" db:"harvested_at"` + HarvestedBy string `json:"harvested_by" db:"harvested_by"` + SourceIP string `json:"source_ip" db:"source_ip"` +} + +// Consumer tracks which projects consume specific packages +type Consumer struct { + ID string `json:"id" db:"id"` + TreeID string `json:"tree_id" db:"tree_id"` + ProjectURL string `json:"project_url" db:"project_url"` + LastAccess time.Time `json:"last_access" db:"last_access"` + CreatedAt time.Time `json:"created_at" db:"created_at"` +} + +// AccessPermission defines grove-level access control +type AccessPermission struct { + ID string `json:"id" db:"id"` + GroveID string `json:"grove_id" db:"grove_id"` + UserID string `json:"user_id" db:"user_id"` + Level string `json:"level" db:"level"` // read, write, admin + CreatedAt time.Time `json:"created_at" db:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` +} + +// AuditLog tracks all operations for compliance +type AuditLog struct { + ID string `json:"id" db:"id"` + Action string `json:"action" db:"action"` + Resource string `json:"resource" db:"resource"` + UserID string `json:"user_id" db:"user_id"` + Details string `json:"details" db:"details"` // JSON blob + Timestamp time.Time `json:"timestamp" db:"timestamp"` + SourceIP string `json:"source_ip" db:"source_ip"` +} + +// APIKey for programmatic access +type APIKey struct { + ID string `json:"id" db:"id"` + KeyHash string `json:"-" db:"key_hash"` // Hashed API key + Name string `json:"name" db:"name"` + UserID string `json:"user_id" db:"user_id"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + LastUsed *time.Time `json:"last_used,omitempty" db:"last_used"` +} diff --git a/internal/storage/database.go b/internal/storage/database.go new file mode 100644 index 0000000..1ee0424 --- /dev/null +++ b/internal/storage/database.go @@ -0,0 +1,346 @@ +package storage + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/bsf/orchard/internal/config" + "github.com/bsf/orchard/internal/models" + "github.com/google/uuid" + _ "github.com/lib/pq" +) + +// Database handles all database operations +type Database struct { + db *sql.DB +} + +// NewDatabase creates a new database connection +func NewDatabase(cfg *config.DatabaseConfig) (*Database, error) { + dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.DBName, cfg.SSLMode) + + db, err := sql.Open("postgres", dsn) + if err != nil { + return nil, fmt.Errorf("failed to connect to database: %w", err) + } + + if err := db.Ping(); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &Database{db: db}, nil +} + +// Close closes the database connection +func (d *Database) Close() error { + return d.db.Close() +} + +// Grove operations + +func (d *Database) CreateGrove(ctx context.Context, grove *models.Grove) error { + grove.ID = uuid.New().String() + grove.CreatedAt = time.Now() + grove.UpdatedAt = grove.CreatedAt + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO groves (id, name, description, is_public, created_at, updated_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, grove.ID, grove.Name, grove.Description, grove.IsPublic, grove.CreatedAt, grove.UpdatedAt, grove.CreatedBy) + + return err +} + +func (d *Database) GetGrove(ctx context.Context, name string) (*models.Grove, error) { + var grove models.Grove + err := d.db.QueryRowContext(ctx, ` + SELECT id, name, description, is_public, created_at, updated_at, created_by + FROM groves WHERE name = $1 + `, name).Scan(&grove.ID, &grove.Name, &grove.Description, &grove.IsPublic, + &grove.CreatedAt, &grove.UpdatedAt, &grove.CreatedBy) + + if err == sql.ErrNoRows { + return nil, nil + } + return &grove, err +} + +func (d *Database) ListGroves(ctx context.Context, userID string) ([]*models.Grove, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT g.id, g.name, g.description, g.is_public, g.created_at, g.updated_at, g.created_by + FROM groves g + LEFT JOIN access_permissions ap ON g.id = ap.grove_id AND ap.user_id = $1 + WHERE g.is_public = true OR ap.user_id IS NOT NULL + ORDER BY g.name + `, userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var groves []*models.Grove + for rows.Next() { + var grove models.Grove + if err := rows.Scan(&grove.ID, &grove.Name, &grove.Description, &grove.IsPublic, + &grove.CreatedAt, &grove.UpdatedAt, &grove.CreatedBy); err != nil { + return nil, err + } + groves = append(groves, &grove) + } + return groves, nil +} + +// Tree operations + +func (d *Database) CreateTree(ctx context.Context, tree *models.Tree) error { + tree.ID = uuid.New().String() + tree.CreatedAt = time.Now() + tree.UpdatedAt = tree.CreatedAt + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO trees (id, grove_id, name, description, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6) + `, tree.ID, tree.GroveID, tree.Name, tree.Description, tree.CreatedAt, tree.UpdatedAt) + + return err +} + +func (d *Database) GetTree(ctx context.Context, groveID, name string) (*models.Tree, error) { + var tree models.Tree + err := d.db.QueryRowContext(ctx, ` + SELECT id, grove_id, name, description, created_at, updated_at + FROM trees WHERE grove_id = $1 AND name = $2 + `, groveID, name).Scan(&tree.ID, &tree.GroveID, &tree.Name, &tree.Description, + &tree.CreatedAt, &tree.UpdatedAt) + + if err == sql.ErrNoRows { + return nil, nil + } + return &tree, err +} + +func (d *Database) ListTrees(ctx context.Context, groveID string) ([]*models.Tree, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, grove_id, name, description, created_at, updated_at + FROM trees WHERE grove_id = $1 + ORDER BY name + `, groveID) + if err != nil { + return nil, err + } + defer rows.Close() + + var trees []*models.Tree + for rows.Next() { + var tree models.Tree + if err := rows.Scan(&tree.ID, &tree.GroveID, &tree.Name, &tree.Description, + &tree.CreatedAt, &tree.UpdatedAt); err != nil { + return nil, err + } + trees = append(trees, &tree) + } + return trees, nil +} + +// Fruit operations + +func (d *Database) CreateFruit(ctx context.Context, fruit *models.Fruit) error { + fruit.CreatedAt = time.Now() + fruit.RefCount = 1 + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO fruits (id, size, content_type, original_name, created_at, created_by, ref_count, s3_key) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET ref_count = fruits.ref_count + 1 + `, fruit.ID, fruit.Size, fruit.ContentType, fruit.OriginalName, + fruit.CreatedAt, fruit.CreatedBy, fruit.RefCount, fruit.S3Key) + + return err +} + +func (d *Database) GetFruit(ctx context.Context, id string) (*models.Fruit, error) { + var fruit models.Fruit + err := d.db.QueryRowContext(ctx, ` + SELECT id, size, content_type, original_name, created_at, created_by, ref_count, s3_key + FROM fruits WHERE id = $1 + `, id).Scan(&fruit.ID, &fruit.Size, &fruit.ContentType, &fruit.OriginalName, + &fruit.CreatedAt, &fruit.CreatedBy, &fruit.RefCount, &fruit.S3Key) + + if err == sql.ErrNoRows { + return nil, nil + } + return &fruit, err +} + +// Graft operations + +func (d *Database) CreateGraft(ctx context.Context, graft *models.Graft) error { + graft.ID = uuid.New().String() + graft.CreatedAt = time.Now() + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO grafts (id, tree_id, name, fruit_id, created_at, created_by) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (tree_id, name) DO UPDATE SET fruit_id = $4, created_at = $5, created_by = $6 + `, graft.ID, graft.TreeID, graft.Name, graft.FruitID, graft.CreatedAt, graft.CreatedBy) + + return err +} + +func (d *Database) GetGraft(ctx context.Context, treeID, name string) (*models.Graft, error) { + var graft models.Graft + err := d.db.QueryRowContext(ctx, ` + SELECT id, tree_id, name, fruit_id, created_at, created_by + FROM grafts WHERE tree_id = $1 AND name = $2 + `, treeID, name).Scan(&graft.ID, &graft.TreeID, &graft.Name, &graft.FruitID, + &graft.CreatedAt, &graft.CreatedBy) + + if err == sql.ErrNoRows { + return nil, nil + } + return &graft, err +} + +func (d *Database) ListGrafts(ctx context.Context, treeID string) ([]*models.Graft, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, tree_id, name, fruit_id, created_at, created_by + FROM grafts WHERE tree_id = $1 + ORDER BY name + `, treeID) + if err != nil { + return nil, err + } + defer rows.Close() + + var grafts []*models.Graft + for rows.Next() { + var graft models.Graft + if err := rows.Scan(&graft.ID, &graft.TreeID, &graft.Name, &graft.FruitID, + &graft.CreatedAt, &graft.CreatedBy); err != nil { + return nil, err + } + grafts = append(grafts, &graft) + } + return grafts, nil +} + +// Harvest operations + +func (d *Database) CreateHarvest(ctx context.Context, harvest *models.Harvest) error { + harvest.ID = uuid.New().String() + harvest.HarvestedAt = time.Now() + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO harvests (id, fruit_id, tree_id, original_name, harvested_at, harvested_by, source_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, harvest.ID, harvest.FruitID, harvest.TreeID, harvest.OriginalName, + harvest.HarvestedAt, harvest.HarvestedBy, harvest.SourceIP) + + return err +} + +// Audit operations + +func (d *Database) CreateAuditLog(ctx context.Context, log *models.AuditLog) error { + log.ID = uuid.New().String() + log.Timestamp = time.Now() + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO audit_logs (id, action, resource, user_id, details, timestamp, source_ip) + VALUES ($1, $2, $3, $4, $5, $6, $7) + `, log.ID, log.Action, log.Resource, log.UserID, log.Details, log.Timestamp, log.SourceIP) + + return err +} + +// Access control operations + +func (d *Database) CheckAccess(ctx context.Context, groveID, userID, requiredLevel string) (bool, error) { + // Check if grove is public (read access for everyone) + var isPublic bool + err := d.db.QueryRowContext(ctx, `SELECT is_public FROM groves WHERE id = $1`, groveID).Scan(&isPublic) + if err != nil { + return false, err + } + + if isPublic && requiredLevel == "read" { + return true, nil + } + + // Check user-specific permissions + var level string + err = d.db.QueryRowContext(ctx, ` + SELECT level FROM access_permissions + WHERE grove_id = $1 AND user_id = $2 AND (expires_at IS NULL OR expires_at > NOW()) + `, groveID, userID).Scan(&level) + + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, err + } + + // Check permission hierarchy: admin > write > read + switch requiredLevel { + case "read": + return true, nil + case "write": + return level == "write" || level == "admin", nil + case "admin": + return level == "admin", nil + } + + return false, nil +} + +func (d *Database) GrantAccess(ctx context.Context, perm *models.AccessPermission) error { + perm.ID = uuid.New().String() + perm.CreatedAt = time.Now() + + _, err := d.db.ExecContext(ctx, ` + INSERT INTO access_permissions (id, grove_id, user_id, level, created_at, expires_at) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (grove_id, user_id) DO UPDATE SET level = $4, expires_at = $6 + `, perm.ID, perm.GroveID, perm.UserID, perm.Level, perm.CreatedAt, perm.ExpiresAt) + + return err +} + +// Consumer tracking + +func (d *Database) TrackConsumer(ctx context.Context, treeID, projectURL string) error { + _, err := d.db.ExecContext(ctx, ` + INSERT INTO consumers (id, tree_id, project_url, last_access, created_at) + VALUES ($1, $2, $3, NOW(), NOW()) + ON CONFLICT (tree_id, project_url) DO UPDATE SET last_access = NOW() + `, uuid.New().String(), treeID, projectURL) + + return err +} + +func (d *Database) GetConsumers(ctx context.Context, treeID string) ([]*models.Consumer, error) { + rows, err := d.db.QueryContext(ctx, ` + SELECT id, tree_id, project_url, last_access, created_at + FROM consumers WHERE tree_id = $1 + ORDER BY last_access DESC + `, treeID) + if err != nil { + return nil, err + } + defer rows.Close() + + var consumers []*models.Consumer + for rows.Next() { + var consumer models.Consumer + if err := rows.Scan(&consumer.ID, &consumer.TreeID, &consumer.ProjectURL, + &consumer.LastAccess, &consumer.CreatedAt); err != nil { + return nil, err + } + consumers = append(consumers, &consumer) + } + return consumers, nil +} diff --git a/internal/storage/s3.go b/internal/storage/s3.go new file mode 100644 index 0000000..b13042b --- /dev/null +++ b/internal/storage/s3.go @@ -0,0 +1,158 @@ +package storage + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + orchardconfig "github.com/bsf/orchard/internal/config" +) + +// S3Storage implements content-addressable storage using S3 +type S3Storage struct { + client *s3.Client + bucket string +} + +// NewS3Storage creates a new S3 storage backend +func NewS3Storage(cfg *orchardconfig.S3Config) (*S3Storage, error) { + customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + if cfg.Endpoint != "" { + return aws.Endpoint{ + URL: cfg.Endpoint, + HostnameImmutable: true, + }, nil + } + return aws.Endpoint{}, &aws.EndpointNotFoundError{} + }) + + awsCfg, err := config.LoadDefaultConfig(context.Background(), + config.WithRegion(cfg.Region), + config.WithEndpointResolverWithOptions(customResolver), + config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( + cfg.AccessKeyID, + cfg.SecretAccessKey, + "", + )), + ) + if err != nil { + return nil, fmt.Errorf("failed to load AWS config: %w", err) + } + + client := s3.NewFromConfig(awsCfg, func(o *s3.Options) { + o.UsePathStyle = cfg.UsePathStyle + }) + + return &S3Storage{ + client: client, + bucket: cfg.Bucket, + }, nil +} + +// hashToKey converts a SHA256 hash to an S3 object key +// Uses directory structure: ab/cd/abcdef123... +func hashToKey(hash string) string { + if len(hash) < 4 { + return hash + } + return fmt.Sprintf("%s/%s/%s", hash[:2], hash[2:4], hash) +} + +// Store uploads content to S3 and returns the SHA256 hash +func (s *S3Storage) Store(ctx context.Context, reader io.Reader) (string, int64, error) { + // Read all content into memory to compute hash and enable seeking + data, err := io.ReadAll(reader) + if err != nil { + return "", 0, fmt.Errorf("failed to read content: %w", err) + } + + size := int64(len(data)) + + // Compute SHA256 hash + hasher := sha256.New() + hasher.Write(data) + hash := hex.EncodeToString(hasher.Sum(nil)) + finalKey := hashToKey(hash) + + // Check if object already exists (deduplication) + _, err = s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(finalKey), + }) + if err == nil { + // Object already exists, return existing hash (deduplication) + return hash, size, nil + } + + // Upload to final location + _, err = s.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(finalKey), + Body: bytes.NewReader(data), + ContentLength: aws.Int64(size), + }) + if err != nil { + return "", 0, fmt.Errorf("failed to upload to S3: %w", err) + } + + return hash, size, nil +} + +// Retrieve downloads content by SHA256 hash +func (s *S3Storage) Retrieve(ctx context.Context, hash string) (io.ReadCloser, error) { + key := hashToKey(hash) + + result, err := s.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return nil, fmt.Errorf("failed to retrieve object: %w", err) + } + + return result.Body, nil +} + +// Exists checks if a fruit exists by hash +func (s *S3Storage) Exists(ctx context.Context, hash string) (bool, error) { + key := hashToKey(hash) + + _, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return false, nil + } + return true, nil +} + +// GetSize returns the size of a stored object +func (s *S3Storage) GetSize(ctx context.Context, hash string) (int64, error) { + key := hashToKey(hash) + + result, err := s.client.HeadObject(ctx, &s3.HeadObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + }) + if err != nil { + return 0, fmt.Errorf("failed to get object metadata: %w", err) + } + + return *result.ContentLength, nil +} + +// generateTempID creates a unique temporary ID +func generateTempID() string { + b := make([]byte, 16) + rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/migrations/001_initial.sql b/migrations/001_initial.sql new file mode 100644 index 0000000..1505a4f --- /dev/null +++ b/migrations/001_initial.sql @@ -0,0 +1,160 @@ +-- Orchard Database Schema +-- Content-Addressable Storage System + +-- Groves (Projects) +CREATE TABLE IF NOT EXISTS groves ( + id UUID PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + description TEXT, + is_public BOOLEAN DEFAULT true, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(255) NOT NULL +); + +CREATE INDEX idx_groves_name ON groves(name); +CREATE INDEX idx_groves_created_by ON groves(created_by); + +-- Trees (Packages) +CREATE TABLE IF NOT EXISTS trees ( + id UUID PRIMARY KEY, + grove_id UUID NOT NULL REFERENCES groves(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(grove_id, name) +); + +CREATE INDEX idx_trees_grove_id ON trees(grove_id); +CREATE INDEX idx_trees_name ON trees(name); + +-- Fruits (Content-Addressable Artifacts) +CREATE TABLE IF NOT EXISTS fruits ( + id VARCHAR(64) PRIMARY KEY, -- SHA256 hash + size BIGINT NOT NULL, + content_type VARCHAR(255), + original_name VARCHAR(1024), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(255) NOT NULL, + ref_count INTEGER DEFAULT 1, + s3_key VARCHAR(1024) NOT NULL +); + +CREATE INDEX idx_fruits_created_at ON fruits(created_at); +CREATE INDEX idx_fruits_created_by ON fruits(created_by); + +-- Grafts (Aliases/Tags) +CREATE TABLE IF NOT EXISTS grafts ( + id UUID PRIMARY KEY, + tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + fruit_id VARCHAR(64) NOT NULL REFERENCES fruits(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_by VARCHAR(255) NOT NULL, + UNIQUE(tree_id, name) +); + +CREATE INDEX idx_grafts_tree_id ON grafts(tree_id); +CREATE INDEX idx_grafts_fruit_id ON grafts(fruit_id); + +-- Graft History (for rollback capability) +CREATE TABLE IF NOT EXISTS graft_history ( + id UUID PRIMARY KEY, + graft_id UUID NOT NULL REFERENCES grafts(id) ON DELETE CASCADE, + old_fruit_id VARCHAR(64) REFERENCES fruits(id), + new_fruit_id VARCHAR(64) NOT NULL REFERENCES fruits(id), + changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + changed_by VARCHAR(255) NOT NULL +); + +CREATE INDEX idx_graft_history_graft_id ON graft_history(graft_id); + +-- Harvests (Upload events) +CREATE TABLE IF NOT EXISTS harvests ( + id UUID PRIMARY KEY, + fruit_id VARCHAR(64) NOT NULL REFERENCES fruits(id), + tree_id UUID NOT NULL REFERENCES trees(id), + original_name VARCHAR(1024), + harvested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + harvested_by VARCHAR(255) NOT NULL, + source_ip VARCHAR(45) +); + +CREATE INDEX idx_harvests_fruit_id ON harvests(fruit_id); +CREATE INDEX idx_harvests_tree_id ON harvests(tree_id); +CREATE INDEX idx_harvests_harvested_at ON harvests(harvested_at); + +-- Consumers (Dependency tracking) +CREATE TABLE IF NOT EXISTS consumers ( + id UUID PRIMARY KEY, + tree_id UUID NOT NULL REFERENCES trees(id) ON DELETE CASCADE, + project_url VARCHAR(2048) NOT NULL, + last_access TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + UNIQUE(tree_id, project_url) +); + +CREATE INDEX idx_consumers_tree_id ON consumers(tree_id); +CREATE INDEX idx_consumers_last_access ON consumers(last_access); + +-- Access Permissions +CREATE TABLE IF NOT EXISTS access_permissions ( + id UUID PRIMARY KEY, + grove_id UUID NOT NULL REFERENCES groves(id) ON DELETE CASCADE, + user_id VARCHAR(255) NOT NULL, + level VARCHAR(20) NOT NULL CHECK (level IN ('read', 'write', 'admin')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE, + UNIQUE(grove_id, user_id) +); + +CREATE INDEX idx_access_permissions_grove_id ON access_permissions(grove_id); +CREATE INDEX idx_access_permissions_user_id ON access_permissions(user_id); + +-- API Keys +CREATE TABLE IF NOT EXISTS api_keys ( + id UUID PRIMARY KEY, + key_hash VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(255) NOT NULL, + user_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + expires_at TIMESTAMP WITH TIME ZONE, + last_used TIMESTAMP WITH TIME ZONE +); + +CREATE INDEX idx_api_keys_user_id ON api_keys(user_id); +CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash); + +-- Audit Logs (Immutable) +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY, + action VARCHAR(100) NOT NULL, + resource VARCHAR(1024) NOT NULL, + user_id VARCHAR(255) NOT NULL, + details JSONB, + timestamp TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + source_ip VARCHAR(45) +); + +CREATE INDEX idx_audit_logs_action ON audit_logs(action); +CREATE INDEX idx_audit_logs_resource ON audit_logs(resource); +CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id); +CREATE INDEX idx_audit_logs_timestamp ON audit_logs(timestamp); + +-- Trigger to update graft history on changes +CREATE OR REPLACE FUNCTION track_graft_changes() +RETURNS TRIGGER AS $$ +BEGIN + IF TG_OP = 'UPDATE' AND OLD.fruit_id != NEW.fruit_id THEN + INSERT INTO graft_history (id, graft_id, old_fruit_id, new_fruit_id, changed_at, changed_by) + VALUES (gen_random_uuid(), NEW.id, OLD.fruit_id, NEW.fruit_id, NOW(), NEW.created_by); + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER graft_changes_trigger + AFTER UPDATE ON grafts + FOR EACH ROW + EXECUTE FUNCTION track_graft_changes();