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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-12-04 10:14:49 -06:00
commit e24c2c6803
20 changed files with 3531 additions and 0 deletions

34
.dockerignore Normal file
View File

@@ -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/

50
.gitignore vendored Normal file
View File

@@ -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/

48
Dockerfile Normal file
View File

@@ -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"]

97
Makefile Normal file
View File

@@ -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"

Binary file not shown.

View File

@@ -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")
}

28
config.yaml Normal file
View File

@@ -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

116
docker-compose.yml Normal file
View File

@@ -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

70
go.mod Normal file
View File

@@ -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
)

171
go.sum Normal file
View File

@@ -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=

442
internal/api/handlers.go Normal file
View File

@@ -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))
}
}

153
internal/api/router.go Normal file
View File

@@ -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()
}
}

573
internal/api/static/app.js Normal file
View File

@@ -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 = '<div class="loading">Loading groves...</div>';
try {
const response = await fetch(`${API_BASE}/groves`);
const groves = await response.json();
if (!groves || groves.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No groves yet</h3>
<p>Create your first grove to get started</p>
</div>
`;
return;
}
container.innerHTML = groves.map(grove => `
<div class="grove-card" onclick="viewGrove('${grove.name}')">
<h3>
<span>&#127795;</span>
${escapeHtml(grove.name)}
<span class="badge ${grove.is_public ? 'badge-public' : 'badge-private'}">
${grove.is_public ? 'Public' : 'Private'}
</span>
</h3>
<p>${escapeHtml(grove.description || 'No description')}</p>
<div class="meta">
Created ${formatDate(grove.created_at)}
</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<div class="empty-state"><p>Error loading groves: ${error.message}</p></div>`;
}
}
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 = `
<div class="info-grid">
<div class="info-item">
<label>Name</label>
<span>${escapeHtml(grove.name)}</span>
</div>
<div class="info-item">
<label>Visibility</label>
<span class="badge ${grove.is_public ? 'badge-public' : 'badge-private'}">
${grove.is_public ? 'Public' : 'Private'}
</span>
</div>
<div class="info-item">
<label>Created</label>
<span>${formatDate(grove.created_at)}</span>
</div>
<div class="info-item">
<label>Description</label>
<span>${escapeHtml(grove.description || 'No description')}</span>
</div>
</div>
`;
} 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 = '<div class="loading">Loading trees...</div>';
try {
const response = await fetch(`${API_BASE}/grove/${groveName}/trees`);
const trees = await response.json();
if (!trees || trees.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No trees yet</h3>
<p>Create a tree to store artifacts</p>
</div>
`;
return;
}
container.innerHTML = trees.map(tree => `
<div class="tree-card" onclick="viewTree('${groveName}', '${tree.name}')">
<h3>
<span>&#127794;</span>
${escapeHtml(tree.name)}
</h3>
<p>${escapeHtml(tree.description || 'No description')}</p>
<div class="meta">
Created ${formatDate(tree.created_at)}
</div>
</div>
`).join('');
} catch (error) {
container.innerHTML = `<div class="empty-state"><p>Error loading trees: ${error.message}</p></div>`;
}
}
async function viewTree(groveName, treeName) {
currentGrove = groveName;
currentTree = treeName;
document.getElementById('tree-detail-title').textContent = `${groveName} / ${treeName}`;
document.getElementById('tree-info').innerHTML = `
<div class="info-grid">
<div class="info-item">
<label>Grove</label>
<span>${escapeHtml(groveName)}</span>
</div>
<div class="info-item">
<label>Tree</label>
<span>${escapeHtml(treeName)}</span>
</div>
</div>
`;
// 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 = '<div class="loading">Loading versions...</div>';
try {
const response = await fetch(`${API_BASE}/grove/${groveName}/${treeName}/grafts`);
const grafts = await response.json();
if (!grafts || grafts.length === 0) {
container.innerHTML = `
<div class="empty-state">
<h3>No versions yet</h3>
<p>Upload an artifact to create the first version</p>
</div>
`;
return;
}
container.innerHTML = `
<table>
<thead>
<tr>
<th>Tag</th>
<th>Fruit ID</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${grafts.map(graft => `
<tr>
<td><strong>${escapeHtml(graft.name)}</strong></td>
<td>
<span class="hash hash-short" onclick="copyToClipboard('${graft.fruit_id}')" title="Click to copy">
${graft.fruit_id.substring(0, 16)}...
</span>
</td>
<td>${formatDate(graft.created_at)}</td>
<td>
<a href="/api/v1/grove/${groveName}/${treeName}/+/${graft.name}"
class="btn btn-secondary" download>
Download
</a>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (error) {
container.innerHTML = `<div class="empty-state"><p>Error loading versions: ${error.message}</p></div>`;
}
}
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 = '<option value="">Loading...</option>';
try {
const response = await fetch(`${API_BASE}/groves`);
const groves = await response.json();
select.innerHTML = '<option value="">Select grove...</option>' +
(groves || []).map(g => `<option value="${g.name}">${g.name}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Error loading groves</option>';
}
}
async function loadTreesForUpload() {
const groveName = document.getElementById('upload-grove').value;
const select = document.getElementById('upload-tree');
if (!groveName) {
select.innerHTML = '<option value="">Select tree...</option>';
return;
}
select.innerHTML = '<option value="">Loading...</option>';
try {
const response = await fetch(`${API_BASE}/grove/${groveName}/trees`);
const trees = await response.json();
select.innerHTML = '<option value="">Select tree...</option>' +
(trees || []).map(t => `<option value="${t.name}">${t.name}</option>`).join('');
} catch (error) {
select.innerHTML = '<option value="">Error loading trees</option>';
}
}
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 = '<div class="loading">Uploading...</div>';
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 = `
<h3>Upload Successful!</h3>
<dl>
<dt>Fruit ID</dt>
<dd class="hash">${result.fruit_id}</dd>
<dt>Size</dt>
<dd>${formatBytes(result.size)}</dd>
<dt>Grove</dt>
<dd>${escapeHtml(result.grove)}</dd>
<dt>Tree</dt>
<dd>${escapeHtml(result.tree)}</dd>
${result.tag ? `<dt>Tag</dt><dd>${escapeHtml(result.tag)}</dd>` : ''}
</dl>
<div class="actions">
<a href="/api/v1/grove/${grove}/${tree}/+/${result.tag || 'fruit:' + result.fruit_id}"
class="btn btn-primary" download>
Download Artifact
</a>
</div>
`;
showToast('Artifact uploaded successfully!', 'success');
} catch (error) {
resultDiv.classList.add('error');
resultDiv.innerHTML = `<h3>Upload Failed</h3><p>${escapeHtml(error.message)}</p>`;
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 = '<div class="loading">Searching...</div>';
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 = `
<h3>Fruit Found</h3>
<dl>
<dt>Fruit ID</dt>
<dd class="hash">${result.id}</dd>
<dt>Original Name</dt>
<dd>${escapeHtml(result.original_name || 'Unknown')}</dd>
<dt>Size</dt>
<dd>${formatBytes(result.size)}</dd>
<dt>Content Type</dt>
<dd>${escapeHtml(result.content_type || 'Unknown')}</dd>
<dt>Created</dt>
<dd>${formatDate(result.created_at)}</dd>
<dt>Reference Count</dt>
<dd>${result.ref_count}</dd>
</dl>
`;
} catch (error) {
resultDiv.classList.add('error');
resultDiv.innerHTML = `<h3>Not Found</h3><p>${escapeHtml(error.message)}</p>`;
}
}
// 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');
});
}

View File

@@ -0,0 +1,183 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Orchard - Content Addressable Storage</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="navbar">
<div class="nav-brand">
<span class="logo">&#127822;</span> Orchard
</div>
<div class="nav-links">
<a href="#" class="nav-link active" data-view="groves">Groves</a>
<a href="#" class="nav-link" data-view="upload">Upload</a>
<a href="#" class="nav-link" data-view="search">Search</a>
</div>
</nav>
<main class="container">
<!-- Groves View -->
<div id="groves-view" class="view active">
<div class="view-header">
<h1>Groves</h1>
<button class="btn btn-primary" onclick="showCreateGroveModal()">+ New Grove</button>
</div>
<div id="groves-list" class="card-grid">
<div class="loading">Loading groves...</div>
</div>
</div>
<!-- Grove Detail View -->
<div id="grove-detail-view" class="view">
<div class="view-header">
<button class="btn btn-secondary" onclick="showView('groves')">&larr; Back</button>
<h1 id="grove-detail-title">Grove</h1>
</div>
<div class="grove-info" id="grove-info"></div>
<div class="section">
<div class="section-header">
<h2>Trees</h2>
<button class="btn btn-primary" onclick="showCreateTreeModal()">+ New Tree</button>
</div>
<div id="trees-list" class="card-grid"></div>
</div>
</div>
<!-- Tree Detail View -->
<div id="tree-detail-view" class="view">
<div class="view-header">
<button class="btn btn-secondary" onclick="backToGrove()">&larr; Back</button>
<h1 id="tree-detail-title">Tree</h1>
</div>
<div class="tree-info" id="tree-info"></div>
<div class="section">
<div class="section-header">
<h2>Versions (Grafts)</h2>
</div>
<div id="grafts-list" class="table-container"></div>
</div>
<div class="section">
<h2>Upload Artifact</h2>
<form id="tree-upload-form" class="upload-form" onsubmit="uploadToTree(event)">
<div class="form-group">
<label>File</label>
<input type="file" id="tree-upload-file" required>
</div>
<div class="form-group">
<label>Tag (optional)</label>
<input type="text" id="tree-upload-tag" placeholder="e.g., v1.0.0, latest">
</div>
<button type="submit" class="btn btn-primary">Upload</button>
</form>
</div>
</div>
<!-- Upload View -->
<div id="upload-view" class="view">
<h1>Upload Artifact</h1>
<form id="upload-form" class="upload-form card" onsubmit="uploadArtifact(event)">
<div class="form-row">
<div class="form-group">
<label>Grove</label>
<select id="upload-grove" required onchange="loadTreesForUpload()">
<option value="">Select grove...</option>
</select>
</div>
<div class="form-group">
<label>Tree</label>
<select id="upload-tree" required>
<option value="">Select tree...</option>
</select>
</div>
</div>
<div class="form-group">
<label>File</label>
<div class="file-drop" id="file-drop">
<input type="file" id="upload-file" required onchange="updateFileName()">
<p>Drop file here or click to browse</p>
<span id="file-name"></span>
</div>
</div>
<div class="form-group">
<label>Tag (optional)</label>
<input type="text" id="upload-tag" placeholder="e.g., v1.0.0, latest, stable">
</div>
<button type="submit" class="btn btn-primary btn-lg">Upload Artifact</button>
</form>
<div id="upload-result" class="result-card hidden"></div>
</div>
<!-- Search View -->
<div id="search-view" class="view">
<h1>Search Artifacts</h1>
<div class="search-box">
<input type="text" id="search-input" placeholder="Enter fruit ID (SHA256 hash)..." onkeyup="handleSearchKeyup(event)">
<button class="btn btn-primary" onclick="searchFruit()">Search</button>
</div>
<div id="search-result" class="result-card hidden"></div>
</div>
</main>
<!-- Create Grove Modal -->
<div id="create-grove-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Create New Grove</h2>
<button class="modal-close" onclick="closeModals()">&times;</button>
</div>
<form onsubmit="createGrove(event)">
<div class="form-group">
<label>Name</label>
<input type="text" id="grove-name" required placeholder="e.g., blinx-core">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="grove-description" placeholder="Optional description..."></textarea>
</div>
<div class="form-group checkbox">
<label>
<input type="checkbox" id="grove-public" checked>
Public grove
</label>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModals()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Grove</button>
</div>
</form>
</div>
</div>
<!-- Create Tree Modal -->
<div id="create-tree-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h2>Create New Tree</h2>
<button class="modal-close" onclick="closeModals()">&times;</button>
</div>
<form onsubmit="createTree(event)">
<div class="form-group">
<label>Name</label>
<input type="text" id="tree-name" required placeholder="e.g., kernel">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="tree-description" placeholder="Optional description..."></textarea>
</div>
<div class="modal-actions">
<button type="button" class="btn btn-secondary" onclick="closeModals()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Tree</button>
</div>
</form>
</div>
</div>
<!-- Toast Notifications -->
<div id="toast-container"></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -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;
}
}

104
internal/config/config.go Normal file
View File

@@ -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
}

110
internal/models/models.go Normal file
View File

@@ -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"`
}

View File

@@ -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
}

158
internal/storage/s3.go Normal file
View File

@@ -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)
}

160
migrations/001_initial.sql Normal file
View File

@@ -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();