aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-10-27 01:53:31 +0900
committernsfisis <nsfisis@gmail.com>2025-10-27 01:53:31 +0900
commitc2465b4733e888a3817f717312f5e332a72e3ba5 (patch)
tree25d736c09c173eb2701d7d84fa87936475d302a0 /backend
parentaf87dd1de0c1dd0e6284170aef807224d5c8ccb5 (diff)
downloadfeedaka-c2465b4733e888a3817f717312f5e332a72e3ba5.tar.gz
feedaka-c2465b4733e888a3817f717312f5e332a72e3ba5.tar.zst
feedaka-c2465b4733e888a3817f717312f5e332a72e3ba5.zip
refactor(backend): split main.gov0.3.4
Diffstat (limited to 'backend')
-rw-r--r--backend/cmd_createuser.go62
-rw-r--r--backend/cmd_migrate.go17
-rw-r--r--backend/cmd_serve.go215
-rw-r--r--backend/main.go277
4 files changed, 299 insertions, 272 deletions
diff --git a/backend/cmd_createuser.go b/backend/cmd_createuser.go
new file mode 100644
index 0000000..f953bf0
--- /dev/null
+++ b/backend/cmd_createuser.go
@@ -0,0 +1,62 @@
+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
new file mode 100644
index 0000000..1a2f9f6
--- /dev/null
+++ b/backend/cmd_migrate.go
@@ -0,0 +1,17 @@
+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
new file mode 100644
index 0000000..66ceaf9
--- /dev/null
+++ b/backend/cmd_serve.go
@@ -0,0 +1,215 @@
+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/v4"
+ "github.com/labstack/echo/v4/middleware"
+ "github.com/mmcdole/gofeed"
+ "github.com/vektah/gqlparser/v2/ast"
+
+ "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) {
+ port := os.Getenv("FEEDAKA_PORT")
+ if port == "" {
+ port = "8080"
+ }
+
+ err := db.ValidateSchemaVersion(database)
+ if err != nil {
+ log.Fatal(err)
+ }
+
+ queries := db.New(database)
+
+ e := echo.New()
+
+ e.Use(middleware.Logger())
+ e.Use(middleware.Recover())
+ e.Use(middleware.CORS())
+
+ 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}}))
+
+ 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
+ e.POST("/graphql", echo.WrapHandler(srv))
+ e.GET("/graphql", echo.WrapHandler(srv))
+
+ 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", port)
+ err = e.Start(":" + 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 117cfb9..aa5dc75 100644
--- a/backend/main.go
+++ b/backend/main.go
@@ -1,301 +1,34 @@
package main
import (
- "bufio"
- "context"
"database/sql"
- "embed"
"flag"
- "fmt"
"log"
- "net/http"
- "os"
- "os/signal"
- "strings"
- "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/v4"
- "github.com/labstack/echo/v4/middleware"
_ "github.com/mattn/go-sqlite3"
- "github.com/mmcdole/gofeed"
- "github.com/vektah/gqlparser/v2/ast"
- "golang.org/x/crypto/bcrypt"
-
- "undef.ninja/x/feedaka/db"
- "undef.ninja/x/feedaka/graphql"
- "undef.ninja/x/feedaka/graphql/resolver"
)
//go:generate go tool sqlc generate
//go:generate go tool gqlgen generate
-var (
- database *sql.DB
- queries *db.Queries
- //go:embed public/*
- publicFS embed.FS
-)
-
-func fetchOneFeed(feedID int64, url string, ctx context.Context) 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) (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) error {
- feeds, err := listFeedsToBeFetched(ctx)
- if err != nil {
- return err
- }
-
- var result *multierror.Error
- for feedID, url := range feeds {
- err := fetchOneFeed(feedID, url, ctx)
- if err != nil {
- result = multierror.Append(result, err)
- }
- time.Sleep(5 * time.Second)
- }
- return result.ErrorOrNil()
-}
-
-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)
-}
-
-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 main() {
// Parse command line flags
var migrate = flag.Bool("migrate", false, "Run database migrations")
var createUser = flag.Bool("create-user", false, "Create a new user")
flag.Parse()
- port := os.Getenv("FEEDAKA_PORT")
- if port == "" {
- port = "8080"
- }
-
var err error
- database, err = sql.Open("sqlite3", "data/feedaka.db")
+ database, err := sql.Open("sqlite3", "data/feedaka.db")
if err != nil {
log.Fatal(err)
}
defer database.Close()
- // Migration mode
if *migrate {
- log.Println("Running database migrations...")
- err = db.RunMigrations(database)
- if err != nil {
- log.Fatalf("Migration failed: %v", err)
- }
- log.Println("Migrations completed successfully")
- return
- }
-
- // Create user mode
- if *createUser {
+ runMigrate(database)
+ } else if *createUser {
runCreateUser(database)
- return
- }
-
- err = db.ValidateSchemaVersion(database)
- if err != nil {
- log.Fatal(err)
- }
-
- queries = db.New(database)
-
- e := echo.New()
-
- e.Use(middleware.Logger())
- e.Use(middleware.Recover())
- e.Use(middleware.CORS())
-
- 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}}))
-
- 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
- e.POST("/graphql", echo.WrapHandler(srv))
- e.GET("/graphql", echo.WrapHandler(srv))
-
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
- scheduled(ctx, 1*time.Hour, func() {
- err := fetchAllFeeds(ctx)
- 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", port)
- err = e.Start(":" + port)
- if err != nil && err != http.ErrServerClosed {
- log.Printf("Server error: %v\n", err)
+ } else {
+ runServe(database)
}
- log.Println("Server stopped")
}