diff --git a/cmd/rminder-local/main.go b/cmd/rminder-local/main.go new file mode 100644 index 0000000..3356790 --- /dev/null +++ b/cmd/rminder-local/main.go @@ -0,0 +1,101 @@ +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + "os/signal" + "path/filepath" + "syscall" + "time" + + "rminder/internal/pkg/config" + "rminder/internal/pkg/logger" + "rminder/internal/router" +) + +func main() { + log := slog.Default() + log.Info("Starting Platform (local mode)") + + configDir, err := os.UserConfigDir() + if err != nil { + log.Error("Failed to get user config dir", "error", err) + os.Exit(1) + } + dbDir := filepath.Join(configDir, "rminder") + if err := os.MkdirAll(dbDir, 0755); err != nil { + log.Error("Failed to create rminder config dir", "error", err) + os.Exit(1) + } + dbPath := filepath.Join(dbDir, "rminder.db") + + cfg := &config.Config{ + Server: config.ServerConfig{ + AuthPort: 4002, + ReadTimeout: "15s", + WriteTimeout: "15s", + }, + Logging: config.LoggingConfig{ + Level: "info", + Format: "text", + Output: "stdout", + }, + } + + appLogger, err := logger.New(logger.Config{ + Level: cfg.Logging.Level, + Format: cfg.Logging.Format, + Output: cfg.Logging.Output, + }) + if err != nil { + log.Error("Failed to initialize logger", "error", err) + os.Exit(1) + } + + rtr := router.NewLocal(appLogger, cfg, dbPath) + + addr := fmt.Sprintf(":%d", cfg.Server.AuthPort) + appLogger.Info("Rminder (local) starting", "addr", addr, "db", dbPath) + + readTimeout, _ := config.ParseDuration(cfg.Server.ReadTimeout) + writeTimeout, _ := config.ParseDuration(cfg.Server.WriteTimeout) + + srv := &http.Server{ + Addr: addr, + Handler: rtr, + ReadTimeout: readTimeout, + WriteTimeout: writeTimeout, + } + + serverErrors := make(chan error, 1) + go func() { + serverErrors <- srv.ListenAndServe() + }() + + shutdown := make(chan os.Signal, 1) + signal.Notify(shutdown, os.Interrupt, syscall.SIGTERM) + + select { + case err := <-serverErrors: + appLogger.Error("Server error", "error", err) + os.Exit(1) + case sig := <-shutdown: + appLogger.Info("Shutdown signal received", "signal", sig) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + if err := srv.Shutdown(ctx); err != nil { + appLogger.Error("Graceful shutdown failed", "error", err) + if err := srv.Close(); err != nil { + appLogger.Error("Server close failed", "error", err) + } + } + + appLogger.Info("Rminder (local) stopped") + appLogger.Close() + } +} diff --git a/internal/middleware/local_user.go b/internal/middleware/local_user.go new file mode 100644 index 0000000..1add704 --- /dev/null +++ b/internal/middleware/local_user.go @@ -0,0 +1,19 @@ +package middleware + +import ( + "rminder/internal/app" + "rminder/internal/app/database" + + "github.com/gin-gonic/gin" +) + +// LocalMiddleware injects a database directly from a path, bypassing user/session auth. +func LocalMiddleware(dbPath string, s *app.App) gin.HandlerFunc { + db := database.New(dbPath) + return func(ctx *gin.Context) { + log := s.Logger().WithRequestID(app.GetRequestID(ctx)) + app.SetUserDatabase(ctx, db) + app.SetLogger(ctx, log) + ctx.Next() + } +} diff --git a/internal/router/local.go b/internal/router/local.go new file mode 100644 index 0000000..29613ab --- /dev/null +++ b/internal/router/local.go @@ -0,0 +1,38 @@ +package router + +import ( + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" + + "rminder/internal/app" + "rminder/internal/middleware" + "rminder/internal/pkg/config" + "rminder/internal/pkg/logger" + "rminder/web" +) + +// NewLocal registers routes without authentication, for local development use. +func NewLocal(log *logger.Logger, cfg *config.Config, dbPath string) *gin.Engine { + application := app.New(log, cfg) + + router := gin.Default() + + router.Use(middleware.RequestIDMiddleware()) + router.Use(middleware.SecurityHeadersMiddleware()) + + router.GET("/", func(ctx *gin.Context) { + ctx.Redirect(http.StatusFound, "/tasks") + }) + + TasksRoutesLocal(router, application, dbPath) + + staticFiles, err := fs.Sub(web.Files, "assets") + if err != nil { + panic(err) + } + router.StaticFS("/assets", http.FS(staticFiles)) + + return router +} diff --git a/internal/router/tasks_local.go b/internal/router/tasks_local.go new file mode 100644 index 0000000..0e829a5 --- /dev/null +++ b/internal/router/tasks_local.go @@ -0,0 +1,46 @@ +package router + +import ( + "rminder/internal/app" + taskhandlers "rminder/internal/handlers/tasks" + "rminder/internal/middleware" + + "github.com/gin-gonic/gin" +) + +func TasksRoutesLocal(router *gin.Engine, application *app.App, dbPath string) { + local := middleware.LocalMiddleware(dbPath, application) + + router.GET("/tasks", local, taskhandlers.Load) + + partials := router.Group("/partials", local) + + tasks := partials.Group("/tasks") + tasks.GET("/all", taskhandlers.GetTasks) + tasks.GET("/my-day", taskhandlers.GetTasks) + tasks.GET("/important", taskhandlers.GetTasks) + tasks.GET("/completed", taskhandlers.GetTasks) + tasks.POST("/create", taskhandlers.CreateTask) + tasks.GET("/:taskID", taskhandlers.GetTask) + tasks.DELETE("/:taskID", taskhandlers.DeleteTask) + tasks.PUT("/:taskID/:slug", taskhandlers.UpdateTask) + tasks.POST("/:taskID/subtask", taskhandlers.CreateSubtask) + + lists := partials.Group("/lists") + lists.GET("/all", taskhandlers.GetLists) + lists.POST("/create", taskhandlers.CreateList) + lists.POST("/search", taskhandlers.SearchLists) + lists.GET("/:listID", taskhandlers.GetList) + lists.DELETE("/:listID", taskhandlers.DeleteList) + lists.PUT("/:listID", taskhandlers.UpdateList) + + api := router.Group("/api", local) + + apiTasks := api.Group("/tasks") + apiTasks.GET("/export", taskhandlers.ExportLists) + apiTasks.POST("/import", taskhandlers.ImportLists) + apiTasks.POST("/reorder", taskhandlers.ReorderTasks) + + apiLists := api.Group("/lists") + apiLists.POST("/reorder", taskhandlers.ReorderLists) +}