diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..3f8dbd5f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,113 @@ +# Git +.git +.gitignore +.gitattributes + +# Documentation +README.md +*.md +docs/ + +# Development files +.env +.env.local +.env.development +.env.test +.env.production + +# IDE files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Dependencies +node_modules/ +frontend/node_modules/ + +# Build artifacts +frontend/dist/ +server/server + +# Deploy scripts +deploy_scripts/ + +# Maintenance page +maintenance_page/ + +# Kill switch +kill-sw.js + +# Temporary files +tmp/ +temp/ + +# Test files +test/ +tests/ +*_test.go +*_test.js + +# Coverage reports +coverage/ + +# Runtime files +*.pid +*.seed +*.pid.lock + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +public + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..f3d3efd0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,75 @@ +# Multi-stage build for production image +FROM node:22-alpine AS frontend-builder + +WORKDIR /app/frontend + +COPY frontend/package*.json ./ +RUN npm ci && npm install -g vue-cli-service + +COPY frontend/ ./ +RUN npm run build + + +# Go build stage +FROM golang:1.20-alpine AS go-builder + +RUN apk add --no-cache git + +WORKDIR /app/server + +# Copy go mod files +COPY server/go.mod server/go.sum ./ + +# Download dependencies +RUN go mod download + +# Copy source code +COPY server/ ./ + +# Build Go application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o server . + + +# Production stage +FROM alpine:latest + +# Install runtime dependencies +RUN apk --no-cache add ca-certificates tzdata + +# Create non-root user +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +WORKDIR /app + +# Copy built frontend from frontend-builder stage +COPY --from=frontend-builder /app/frontend/dist ./frontend/dist + +# Copy built Go binary from go-builder stage +COPY --from=go-builder /app/server/server ./server + +# Copy static files if they exist +COPY --from=go-builder /app/server/static ./static + +# Create logs directory +RUN mkdir -p logs && chown -R appuser:appgroup /app + +# Change ownership +RUN chown -R appuser:appgroup /app + +# Switch to non-root user +USER appuser + +# Expose port +EXPOSE 3002 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost:3002/api/health || exit 1 + +# Environment variables +ENV GIN_MODE=release +ENV PORT=3002 + +# Start server +CMD ["./server", "-release=true"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..65a6d7d2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,80 @@ +version: '3.8' + +services: + # MongoDB database + mongodb: + image: mongo:7.0 + container_name: timeful-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: admin + MONGO_INITDB_ROOT_PASSWORD: password + MONGO_INITDB_DATABASE: timeful + ports: + - "27017:27017" + volumes: + - mongodb_data:/data/db + - ./server/db/init:/docker-entrypoint-initdb.d:ro + networks: + - timeful-network + + + # Timeful application + timeful-app: + build: + context: . + dockerfile: Dockerfile + container_name: timeful-app + restart: unless-stopped + ports: + - "80:3002" + environment: + # Database configuration + MONGODB_URI: mongodb://admin:password@mongodb:27017/timeful?authSource=admin + + # Server configuration + GIN_MODE: release + PORT: 3002 + + # CORS origins + CORS_ORIGINS: http://localhost:8080,https://www.timeful.app,https://timeful.app + + # Session secret (change in production) + SESSION_SECRET: your-super-secret-session-key-change-in-production + + # Stripe configuration (add your own keys) + # STRIPE_API_KEY: sk_test_... + # STRIPE_WEBHOOK_SECRET: whsec_... + + # Google Cloud configuration (if you use it) + # GOOGLE_APPLICATION_CREDENTIALS: /app/credentials.json + + # Email configuration (if you use it) + # SMTP_HOST: smtp.gmail.com + # SMTP_PORT: 587 + # SMTP_USER: your-email@gmail.com + # SMTP_PASS: your-app-password + depends_on: + - mongodb + volumes: + # Mount logs directory for persistence + - ./logs:/app/logs + # Mount static files if they exist + - ./server/static:/app/static:ro + networks: + - timeful-network + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3002/api/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + +volumes: + mongodb_data: + driver: local + +networks: + timeful-network: + driver: bridge diff --git a/env.example b/env.example new file mode 100644 index 00000000..23f7a3e2 --- /dev/null +++ b/env.example @@ -0,0 +1,34 @@ +# Database Configuration +MONGO_ROOT_USERNAME=admin +MONGO_ROOT_PASSWORD=your-secure-password +MONGO_DATABASE=timeful + +# Server Configuration +GIN_MODE=release +PORT=3002 + +# CORS Configuration +CORS_ORIGINS=https://www.timeful.app,https://timeful.app,https://www.schej.it,https://schej.it + +# Session Configuration +SESSION_SECRET=your-super-secret-session-key-change-in-production + +# Stripe Configuration +STRIPE_API_KEY=sk_test_your_stripe_key_here +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here + +# Google Cloud Configuration +GOOGLE_APPLICATION_CREDENTIALS=/app/credentials.json + +# Email Configuration +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=your-email@gmail.com +SMTP_PASS=your-app-password + +# Domain Configuration +DOMAIN=timeful.app + +# Optional: Development overrides +# NODE_ENV=development +# DEBUG=true diff --git a/server/main.go b/server/main.go index 138a8f65..0826d1b5 100644 --- a/server/main.go +++ b/server/main.go @@ -1,190 +1,188 @@ -package main - -import ( - "flag" - "fmt" - "io" - "io/fs" - "log" - "net/http" - "os" - "path/filepath" - "regexp" - "time" - - "github.com/gin-contrib/cors" - "github.com/gin-contrib/sessions" - "github.com/gin-contrib/sessions/cookie" - "github.com/gin-gonic/gin" - "github.com/joho/godotenv" - "github.com/stripe/stripe-go/v82" - "schej.it/server/db" - "schej.it/server/logger" - "schej.it/server/routes" - "schej.it/server/services/gcloud" - "schej.it/server/slackbot" - "schej.it/server/utils" - - swaggerfiles "github.com/swaggo/files" - ginSwagger "github.com/swaggo/gin-swagger" - - _ "schej.it/server/docs" -) - -// @title Schej.it API -// @version 1.0 -// @description This is the API for Schej.it! - -// @host localhost:3002/api - -func main() { - // Set release flag - release := flag.Bool("release", false, "Whether this is the release version of the server") - flag.Parse() - if *release { - os.Setenv("GIN_MODE", "release") - gin.SetMode(gin.ReleaseMode) - } else { - os.Setenv("GIN_MODE", "debug") - } - - // Init logfile - logFile, err := os.OpenFile("logs.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - log.Fatal(err) - } - gin.DefaultWriter = io.MultiWriter(logFile, os.Stdout) - - // Init logger - logger.Init(logFile) - - // Load .env variables - loadDotEnv() - - // Init router - router := gin.New() - router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { - var statusColor, methodColor, resetColor string - if param.IsOutputColor() { - statusColor = param.StatusCodeColor() - methodColor = param.MethodColor() - resetColor = param.ResetColor() - } - - if param.Latency > time.Minute { - param.Latency = param.Latency.Truncate(time.Second) - } - return fmt.Sprintf("%v |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s", - param.TimeStamp.Format("2006/01/02 15:04:05"), - statusColor, param.StatusCode, resetColor, - param.Latency, - param.ClientIP, - methodColor, param.Method, resetColor, - param.Path, - param.ErrorMessage, - ) - })) - router.Use(gin.Recovery()) - - // Cors - router.Use(cors.New(cors.Config{ - AllowOrigins: []string{"http://localhost:8080", "https://www.schej.it", "https://schej.it", "https://www.timeful.app", "https://timeful.app"}, - AllowMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE"}, - AllowHeaders: []string{"Content-Type"}, - ExposeHeaders: []string{"Content-Length"}, - AllowCredentials: true, - MaxAge: 12 * time.Hour, - })) - - // Init database - closeConnection := db.Init() - defer closeConnection() - - // Init google cloud stuff - closeTasks := gcloud.InitTasks() - defer closeTasks() - - // Session - store := cookie.NewStore([]byte("secret")) - router.Use(sessions.Sessions("session", store)) - - // Init routes - apiRouter := router.Group("/api") - routes.InitAuth(apiRouter) - routes.InitUser(apiRouter) - routes.InitEvents(apiRouter) - routes.InitUsers(apiRouter) - routes.InitAnalytics(apiRouter) - routes.InitStripe(apiRouter) - routes.InitFolders(apiRouter) - slackbot.InitSlackbot(apiRouter) - - err = filepath.WalkDir("../frontend/dist", func(path string, d fs.DirEntry, err error) error { - if !d.IsDir() && d.Name() != "index.html" { - split := splitPath(path) - newPath := filepath.Join(split[3:]...) - router.StaticFile(fmt.Sprintf("/%s", newPath), path) - } - return nil - }) - if err != nil { - log.Fatalf("failed to walk directories: %s", err) - } - - router.LoadHTMLFiles("../frontend/dist/index.html") - router.NoRoute(noRouteHandler()) - - // Init swagger documentation - router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) - - // Run server - router.Run(":3002") -} - -// Load .env variables -func loadDotEnv() { - err := godotenv.Load(".env") - - // Load stripe key - stripe.Key = os.Getenv("STRIPE_API_KEY") - - if err != nil { - logger.StdErr.Panicln("Error loading .env file") - } -} - -func noRouteHandler() gin.HandlerFunc { - return func(c *gin.Context) { - params := gin.H{} - path := c.Request.URL.Path - - // Determine meta tags based off URL - if match := regexp.MustCompile(`\/e\/(\w+)`).FindStringSubmatchIndex(path); match != nil { - // /e/:eventId - eventId := path[match[2]:match[3]] - event := db.GetEventByEitherId(eventId) - - if event != nil { - title := fmt.Sprintf("%s - Timeful (formerly Schej)", event.Name) - params = gin.H{ - "title": title, - "ogTitle": title, - } - - if len(utils.Coalesce(event.When2meetHref)) > 0 { - params["ogImage"] = "/img/when2meetOgImage2.png" - } - } - } - - c.HTML(http.StatusOK, "index.html", params) - } -} - -func splitPath(path string) []string { - dir, last := filepath.Split(path) - if dir == "" { - return []string{last} - } - return append(splitPath(filepath.Clean(dir)), last) -} +package main + +import ( + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/gin-contrib/cors" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" + "github.com/joho/godotenv" + "github.com/stripe/stripe-go/v82" + "schej.it/server/db" + "schej.it/server/logger" + "schej.it/server/routes" + "schej.it/server/services/gcloud" + "schej.it/server/slackbot" + "schej.it/server/utils" + + swaggerfiles "github.com/swaggo/files" + ginSwagger "github.com/swaggo/gin-swagger" + + _ "schej.it/server/docs" +) + +// @title Schej.it API +// @version 1.0 +// @description This is the API for Schej.it! + +// @host localhost:3002/api + +func main() { + // Set release flag + release := flag.Bool("release", false, "Whether this is the release version of the server") + flag.Parse() + if *release { + os.Setenv("GIN_MODE", "release") + gin.SetMode(gin.ReleaseMode) + } else { + os.Setenv("GIN_MODE", "debug") + } + + // Init logfile + logFile, err := os.OpenFile("logs.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal(err) + } + gin.DefaultWriter = io.MultiWriter(logFile, os.Stdout) + + // Init logger + logger.Init(logFile) + + // Load .env variables + loadDotEnv() + + // Init router + router := gin.New() + router.Use(gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + var statusColor, methodColor, resetColor string + if param.IsOutputColor() { + statusColor = param.StatusCodeColor() + methodColor = param.MethodColor() + resetColor = param.ResetColor() + } + + if param.Latency > time.Minute { + param.Latency = param.Latency.Truncate(time.Second) + } + return fmt.Sprintf("%v |%s %3d %s| %13v | %15s |%s %-7s %s %#v\n%s", + param.TimeStamp.Format("2006/01/02 15:04:05"), + statusColor, param.StatusCode, resetColor, + param.Latency, + param.ClientIP, + methodColor, param.Method, resetColor, + param.Path, + param.ErrorMessage, + ) + })) + router.Use(gin.Recovery()) + + // Cors + router.Use(cors.New(cors.Config{ + AllowOrigins: []string{"http://localhost:8080", "https://www.schej.it", "https://schej.it", "https://www.timeful.app", "https://timeful.app"}, + AllowMethods: []string{"GET", "POST", "PATCH", "PUT", "DELETE"}, + AllowHeaders: []string{"Content-Type"}, + ExposeHeaders: []string{"Content-Length"}, + AllowCredentials: true, + MaxAge: 12 * time.Hour, + })) + + // Init database + closeConnection := db.Init() + defer closeConnection() + + // Init google cloud stuff + closeTasks := gcloud.InitTasks() + defer closeTasks() + + // Session + store := cookie.NewStore([]byte("secret")) + router.Use(sessions.Sessions("session", store)) + + // Load HTML template first + router.LoadHTMLFiles("./frontend/dist/index.html") + + // Serve static files from frontend/dist directory + router.Static("/js", "./frontend/dist/js") + router.Static("/css", "./frontend/dist/css") + router.Static("/img", "./frontend/dist/img") + router.Static("/media", "./frontend/dist/media") + router.StaticFile("/favicon.ico", "./frontend/dist/favicon.ico") + router.StaticFile("/robots.txt", "./frontend/dist/robots.txt") + + // Init routes + apiRouter := router.Group("/api") + routes.InitAuth(apiRouter) + routes.InitUser(apiRouter) + routes.InitEvents(apiRouter) + routes.InitUsers(apiRouter) + routes.InitAnalytics(apiRouter) + routes.InitStripe(apiRouter) + routes.InitFolders(apiRouter) + slackbot.InitSlackbot(apiRouter) + + // NoRoute handler should be last + router.NoRoute(noRouteHandler()) + + // Init swagger documentation + router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler)) + + // Run server + router.Run(":3002") +} + +// Load .env variables +func loadDotEnv() { + err := godotenv.Load(".env") + + // Load stripe key + stripe.Key = os.Getenv("STRIPE_API_KEY") + + if err != nil { + logger.StdErr.Println("Warning: Could not load .env file, using environment variables") + } +} + +func noRouteHandler() gin.HandlerFunc { + return func(c *gin.Context) { + params := gin.H{} + path := c.Request.URL.Path + + // Determine meta tags based off URL + if match := regexp.MustCompile(`\/e\/(\w+)`).FindStringSubmatchIndex(path); match != nil { + // /e/:eventId + eventId := path[match[2]:match[3]] + event := db.GetEventByEitherId(eventId) + + if event != nil { + title := fmt.Sprintf("%s - Timeful (formerly Schej)", event.Name) + params = gin.H{ + "title": title, + "ogTitle": title, + } + + if len(utils.Coalesce(event.When2meetHref)) > 0 { + params["ogImage"] = "/img/when2meetOgImage2.png" + } + } + } + + c.HTML(http.StatusOK, "index.html", params) + } +} + +func splitPath(path string) []string { + dir, last := filepath.Split(path) + if dir == "" { + return []string{last} + } + return append(splitPath(filepath.Clean(dir)), last) +}