From 2676ee96685763fe1f3650f82a2dccac226ba5d9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 4 Dec 2025 23:27:20 +0900 Subject: refactor(backend): move cmd_*.go to cmd package --- backend/cmd/createuser.go | 62 ++++++++++++ backend/cmd/migrate.go | 17 ++++ backend/cmd/serve.go | 229 +++++++++++++++++++++++++++++++++++++++++++++ backend/cmd_createuser.go | 62 ------------ backend/cmd_migrate.go | 17 ---- backend/cmd_serve.go | 234 ---------------------------------------------- backend/main.go | 13 ++- 7 files changed, 318 insertions(+), 316 deletions(-) create mode 100644 backend/cmd/createuser.go create mode 100644 backend/cmd/migrate.go create mode 100644 backend/cmd/serve.go delete mode 100644 backend/cmd_createuser.go delete mode 100644 backend/cmd_migrate.go delete mode 100644 backend/cmd_serve.go diff --git a/backend/cmd/createuser.go b/backend/cmd/createuser.go new file mode 100644 index 0000000..8c776a7 --- /dev/null +++ b/backend/cmd/createuser.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "bufio" + "context" + "database/sql" + "fmt" + "log" + "os" + "strings" + + "golang.org/x/crypto/bcrypt" + + "undef.ninja/x/feedaka/db" +) + +func RunCreateUser(database *sql.DB) { + queries := db.New(database) + reader := bufio.NewReader(os.Stdin) + + // Read username + fmt.Print("Enter username: ") + username, err := reader.ReadString('\n') + if err != nil { + log.Fatalf("Failed to read username: %v", err) + } + username = strings.TrimSpace(username) + if username == "" { + log.Fatal("Username cannot be empty") + } + + // Read password + fmt.Print("Enter password: ") + password, err := reader.ReadString('\n') + if err != nil { + log.Fatalf("Failed to read password: %v", err) + } + password = strings.TrimSpace(password) + + // Validate password length + if len(password) < 15 { + log.Fatalf("Password must be at least 15 characters long (got %d characters)", len(password)) + } + + // Hash password + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("Failed to hash password: %v", err) + } + + // Create user + ctx := context.Background() + user, err := queries.CreateUser(ctx, db.CreateUserParams{ + Username: username, + PasswordHash: string(hashedPassword), + }) + if err != nil { + log.Fatalf("Failed to create user: %v", err) + } + + log.Printf("User created successfully: ID=%d, Username=%s", user.ID, user.Username) +} diff --git a/backend/cmd/migrate.go b/backend/cmd/migrate.go new file mode 100644 index 0000000..26804e7 --- /dev/null +++ b/backend/cmd/migrate.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "database/sql" + "log" + + "undef.ninja/x/feedaka/db" +) + +func RunMigrate(database *sql.DB) { + log.Println("Running database migrations...") + err := db.RunMigrations(database) + if err != nil { + log.Fatalf("Migration failed: %v", err) + } + log.Println("Migrations completed successfully") +} diff --git a/backend/cmd/serve.go b/backend/cmd/serve.go new file mode 100644 index 0000000..28b1282 --- /dev/null +++ b/backend/cmd/serve.go @@ -0,0 +1,229 @@ +package cmd + +import ( + "context" + "database/sql" + "embed" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/99designs/gqlgen/graphql/handler" + "github.com/99designs/gqlgen/graphql/handler/extension" + "github.com/99designs/gqlgen/graphql/handler/lru" + "github.com/99designs/gqlgen/graphql/handler/transport" + "github.com/hashicorp/go-multierror" + "github.com/labstack/echo-contrib/session" + "github.com/labstack/echo/v4" + "github.com/labstack/echo/v4/middleware" + "github.com/mmcdole/gofeed" + "github.com/vektah/gqlparser/v2/ast" + + "undef.ninja/x/feedaka/auth" + "undef.ninja/x/feedaka/config" + "undef.ninja/x/feedaka/db" + "undef.ninja/x/feedaka/graphql" + "undef.ninja/x/feedaka/graphql/resolver" +) + +func fetchOneFeed(feedID int64, url string, ctx context.Context, queries *db.Queries) error { + log.Printf("Fetching %s...\n", url) + fp := gofeed.NewParser() + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + feed, err := fp.ParseURLWithContext(url, ctx) + if err != nil { + return fmt.Errorf("Failed to fetch %s: %v\n", url, err) + } + err = queries.UpdateFeedMetadata(ctx, db.UpdateFeedMetadataParams{ + Title: feed.Title, + FetchedAt: time.Now().UTC().Format(time.RFC3339), + ID: feedID, + }) + if err != nil { + return err + } + guids, err := queries.GetArticleGUIDsByFeed(ctx, feedID) + if err != nil { + return err + } + existingArticleGUIDs := make(map[string]bool) + for _, guid := range guids { + existingArticleGUIDs[guid] = true + } + for _, item := range feed.Items { + if existingArticleGUIDs[item.GUID] { + err := queries.UpdateArticle(ctx, db.UpdateArticleParams{ + Title: item.Title, + Url: item.Link, + FeedID: feedID, + Guid: item.GUID, + }) + if err != nil { + return err + } + } else { + _, err := queries.CreateArticle(ctx, db.CreateArticleParams{ + FeedID: feedID, + Guid: item.GUID, + Title: item.Title, + Url: item.Link, + IsRead: 0, + }) + if err != nil { + return err + } + } + } + return nil +} + +func listFeedsToBeFetched(ctx context.Context, queries *db.Queries) (map[int64]string, error) { + feeds, err := queries.GetFeedsToFetch(ctx) + if err != nil { + return nil, err + } + + result := make(map[int64]string) + for _, feed := range feeds { + fetchedAtTime, err := time.Parse(time.RFC3339, feed.FetchedAt) + if err != nil { + log.Fatal(err) + } + now := time.Now().UTC() + if now.Sub(fetchedAtTime).Minutes() <= 10 { + continue + } + result[feed.ID] = feed.Url + } + return result, nil +} + +func fetchAllFeeds(ctx context.Context, queries *db.Queries) error { + feeds, err := listFeedsToBeFetched(ctx, queries) + if err != nil { + return err + } + + var result *multierror.Error + for feedID, url := range feeds { + err := fetchOneFeed(feedID, url, ctx, queries) + if err != nil { + result = multierror.Append(result, err) + } + time.Sleep(5 * time.Second) + } + return result.ErrorOrNil() +} + +func scheduled(ctx context.Context, d time.Duration, fn func()) { + ticker := time.NewTicker(d) + go func() { + for { + select { + case <-ticker.C: + fn() + case <-ctx.Done(): + return + } + } + }() +} + +func RunServe(database *sql.DB, cfg *config.Config, publicFS embed.FS) { + err := db.ValidateSchemaVersion(database) + if err != nil { + log.Fatal(err) + } + + queries := db.New(database) + + sessionConfig := auth.NewSessionConfig(cfg.SessionSecret, cfg.DevNonSecureCookie) + + e := echo.New() + + e.Use(middleware.Logger()) + e.Use(middleware.Recover()) + e.Use(middleware.CORS()) + e.Use(session.Middleware(sessionConfig.GetStore())) + + e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ + HTML5: true, + Root: "public", + Filesystem: http.FS(publicFS), + })) + + // Setup GraphQL server + srv := handler.New(graphql.NewExecutableSchema(graphql.Config{Resolvers: &resolver.Resolver{ + DB: database, + Queries: queries, + SessionConfig: sessionConfig, + }})) + + srv.AddTransport(transport.Options{}) + srv.AddTransport(transport.GET{}) + srv.AddTransport(transport.POST{}) + + srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) + + srv.Use(extension.Introspection{}) + srv.Use(extension.AutomaticPersistedQuery{ + Cache: lru.New[string](100), + }) + + // GraphQL endpoints with authentication middleware + graphqlGroup := e.Group("/graphql") + graphqlGroup.Use(auth.SessionAuthMiddleware(sessionConfig)) + graphqlGroup.POST("", func(c echo.Context) error { + // Add Echo context to GraphQL context + ctx := context.WithValue(c.Request().Context(), "echo", c) + req := c.Request().WithContext(ctx) + srv.ServeHTTP(c.Response(), req) + return nil + }) + graphqlGroup.GET("", func(c echo.Context) error { + // Add Echo context to GraphQL context + ctx := context.WithValue(c.Request().Context(), "echo", c) + req := c.Request().WithContext(ctx) + srv.ServeHTTP(c.Response(), req) + return nil + }) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + scheduled(ctx, 1*time.Hour, func() { + err := fetchAllFeeds(ctx, queries) + if err != nil { + log.Printf("Failed to fetch feeds: %v\n", err) + } + }) + + // Setup graceful shutdown + go func() { + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + <-sigChan + + log.Println("Shutting down server...") + cancel() + + // Give time for graceful shutdown + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) + defer shutdownCancel() + + if err := e.Shutdown(shutdownCtx); err != nil { + log.Printf("Error during shutdown: %v\n", err) + } + }() + + log.Printf("Server starting on port %s...\n", cfg.Port) + err = e.Start(":" + cfg.Port) + if err != nil && err != http.ErrServerClosed { + log.Printf("Server error: %v\n", err) + } + log.Println("Server stopped") +} diff --git a/backend/cmd_createuser.go b/backend/cmd_createuser.go deleted file mode 100644 index f953bf0..0000000 --- a/backend/cmd_createuser.go +++ /dev/null @@ -1,62 +0,0 @@ -package main - -import ( - "bufio" - "context" - "database/sql" - "fmt" - "log" - "os" - "strings" - - "golang.org/x/crypto/bcrypt" - - "undef.ninja/x/feedaka/db" -) - -func runCreateUser(database *sql.DB) { - queries := db.New(database) - reader := bufio.NewReader(os.Stdin) - - // Read username - fmt.Print("Enter username: ") - username, err := reader.ReadString('\n') - if err != nil { - log.Fatalf("Failed to read username: %v", err) - } - username = strings.TrimSpace(username) - if username == "" { - log.Fatal("Username cannot be empty") - } - - // Read password - fmt.Print("Enter password: ") - password, err := reader.ReadString('\n') - if err != nil { - log.Fatalf("Failed to read password: %v", err) - } - password = strings.TrimSpace(password) - - // Validate password length - if len(password) < 15 { - log.Fatalf("Password must be at least 15 characters long (got %d characters)", len(password)) - } - - // Hash password - hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - log.Fatalf("Failed to hash password: %v", err) - } - - // Create user - ctx := context.Background() - user, err := queries.CreateUser(ctx, db.CreateUserParams{ - Username: username, - PasswordHash: string(hashedPassword), - }) - if err != nil { - log.Fatalf("Failed to create user: %v", err) - } - - log.Printf("User created successfully: ID=%d, Username=%s", user.ID, user.Username) -} diff --git a/backend/cmd_migrate.go b/backend/cmd_migrate.go deleted file mode 100644 index 1a2f9f6..0000000 --- a/backend/cmd_migrate.go +++ /dev/null @@ -1,17 +0,0 @@ -package main - -import ( - "database/sql" - "log" - - "undef.ninja/x/feedaka/db" -) - -func runMigrate(database *sql.DB) { - log.Println("Running database migrations...") - err := db.RunMigrations(database) - if err != nil { - log.Fatalf("Migration failed: %v", err) - } - log.Println("Migrations completed successfully") -} diff --git a/backend/cmd_serve.go b/backend/cmd_serve.go deleted file mode 100644 index f7b99ca..0000000 --- a/backend/cmd_serve.go +++ /dev/null @@ -1,234 +0,0 @@ -package main - -import ( - "context" - "database/sql" - "embed" - "fmt" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "github.com/99designs/gqlgen/graphql/handler" - "github.com/99designs/gqlgen/graphql/handler/extension" - "github.com/99designs/gqlgen/graphql/handler/lru" - "github.com/99designs/gqlgen/graphql/handler/transport" - "github.com/hashicorp/go-multierror" - "github.com/labstack/echo-contrib/session" - "github.com/labstack/echo/v4" - "github.com/labstack/echo/v4/middleware" - "github.com/mmcdole/gofeed" - "github.com/vektah/gqlparser/v2/ast" - - "undef.ninja/x/feedaka/auth" - "undef.ninja/x/feedaka/config" - "undef.ninja/x/feedaka/db" - "undef.ninja/x/feedaka/graphql" - "undef.ninja/x/feedaka/graphql/resolver" -) - -var ( - //go:embed public/* - publicFS embed.FS -) - -func fetchOneFeed(feedID int64, url string, ctx context.Context, queries *db.Queries) error { - log.Printf("Fetching %s...\n", url) - fp := gofeed.NewParser() - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - feed, err := fp.ParseURLWithContext(url, ctx) - if err != nil { - return fmt.Errorf("Failed to fetch %s: %v\n", url, err) - } - err = queries.UpdateFeedMetadata(ctx, db.UpdateFeedMetadataParams{ - Title: feed.Title, - FetchedAt: time.Now().UTC().Format(time.RFC3339), - ID: feedID, - }) - if err != nil { - return err - } - guids, err := queries.GetArticleGUIDsByFeed(ctx, feedID) - if err != nil { - return err - } - existingArticleGUIDs := make(map[string]bool) - for _, guid := range guids { - existingArticleGUIDs[guid] = true - } - for _, item := range feed.Items { - if existingArticleGUIDs[item.GUID] { - err := queries.UpdateArticle(ctx, db.UpdateArticleParams{ - Title: item.Title, - Url: item.Link, - FeedID: feedID, - Guid: item.GUID, - }) - if err != nil { - return err - } - } else { - _, err := queries.CreateArticle(ctx, db.CreateArticleParams{ - FeedID: feedID, - Guid: item.GUID, - Title: item.Title, - Url: item.Link, - IsRead: 0, - }) - if err != nil { - return err - } - } - } - return nil -} - -func listFeedsToBeFetched(ctx context.Context, queries *db.Queries) (map[int64]string, error) { - feeds, err := queries.GetFeedsToFetch(ctx) - if err != nil { - return nil, err - } - - result := make(map[int64]string) - for _, feed := range feeds { - fetchedAtTime, err := time.Parse(time.RFC3339, feed.FetchedAt) - if err != nil { - log.Fatal(err) - } - now := time.Now().UTC() - if now.Sub(fetchedAtTime).Minutes() <= 10 { - continue - } - result[feed.ID] = feed.Url - } - return result, nil -} - -func fetchAllFeeds(ctx context.Context, queries *db.Queries) error { - feeds, err := listFeedsToBeFetched(ctx, queries) - if err != nil { - return err - } - - var result *multierror.Error - for feedID, url := range feeds { - err := fetchOneFeed(feedID, url, ctx, queries) - if err != nil { - result = multierror.Append(result, err) - } - time.Sleep(5 * time.Second) - } - return result.ErrorOrNil() -} - -func scheduled(ctx context.Context, d time.Duration, fn func()) { - ticker := time.NewTicker(d) - go func() { - for { - select { - case <-ticker.C: - fn() - case <-ctx.Done(): - return - } - } - }() -} - -func runServe(database *sql.DB, cfg *config.Config) { - err := db.ValidateSchemaVersion(database) - if err != nil { - log.Fatal(err) - } - - queries := db.New(database) - - sessionConfig := auth.NewSessionConfig(cfg.SessionSecret, cfg.DevNonSecureCookie) - - e := echo.New() - - e.Use(middleware.Logger()) - e.Use(middleware.Recover()) - e.Use(middleware.CORS()) - e.Use(session.Middleware(sessionConfig.GetStore())) - - e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ - HTML5: true, - Root: "public", - Filesystem: http.FS(publicFS), - })) - - // Setup GraphQL server - srv := handler.New(graphql.NewExecutableSchema(graphql.Config{Resolvers: &resolver.Resolver{ - DB: database, - Queries: queries, - SessionConfig: sessionConfig, - }})) - - srv.AddTransport(transport.Options{}) - srv.AddTransport(transport.GET{}) - srv.AddTransport(transport.POST{}) - - srv.SetQueryCache(lru.New[*ast.QueryDocument](1000)) - - srv.Use(extension.Introspection{}) - srv.Use(extension.AutomaticPersistedQuery{ - Cache: lru.New[string](100), - }) - - // GraphQL endpoints with authentication middleware - graphqlGroup := e.Group("/graphql") - graphqlGroup.Use(auth.SessionAuthMiddleware(sessionConfig)) - graphqlGroup.POST("", func(c echo.Context) error { - // Add Echo context to GraphQL context - ctx := context.WithValue(c.Request().Context(), "echo", c) - req := c.Request().WithContext(ctx) - srv.ServeHTTP(c.Response(), req) - return nil - }) - graphqlGroup.GET("", func(c echo.Context) error { - // Add Echo context to GraphQL context - ctx := context.WithValue(c.Request().Context(), "echo", c) - req := c.Request().WithContext(ctx) - srv.ServeHTTP(c.Response(), req) - return nil - }) - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - scheduled(ctx, 1*time.Hour, func() { - err := fetchAllFeeds(ctx, queries) - if err != nil { - log.Printf("Failed to fetch feeds: %v\n", err) - } - }) - - // Setup graceful shutdown - go func() { - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - <-sigChan - - log.Println("Shutting down server...") - cancel() - - // Give time for graceful shutdown - shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 10*time.Second) - defer shutdownCancel() - - if err := e.Shutdown(shutdownCtx); err != nil { - log.Printf("Error during shutdown: %v\n", err) - } - }() - - log.Printf("Server starting on port %s...\n", cfg.Port) - err = e.Start(":" + cfg.Port) - if err != nil && err != http.ErrServerClosed { - log.Printf("Server error: %v\n", err) - } - log.Println("Server stopped") -} diff --git a/backend/main.go b/backend/main.go index e392a33..1d09685 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,17 +2,24 @@ package main import ( "database/sql" + "embed" "flag" "log" _ "github.com/mattn/go-sqlite3" + "undef.ninja/x/feedaka/cmd" "undef.ninja/x/feedaka/config" ) //go:generate go tool sqlc generate //go:generate go tool gqlgen generate +var ( + //go:embed public/* + publicFS embed.FS +) + func main() { cfg, err := config.LoadConfig() if err != nil { @@ -30,10 +37,10 @@ func main() { defer database.Close() if *migrate { - runMigrate(database) + cmd.RunMigrate(database) } else if *createUser { - runCreateUser(database) + cmd.RunCreateUser(database) } else { - runServe(database, cfg) + cmd.RunServe(database, cfg, publicFS) } } -- cgit v1.2.3-70-g09d2