aboutsummaryrefslogtreecommitdiffhomepage
path: root/backend/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'backend/main.go')
-rw-r--r--backend/main.go277
1 files changed, 5 insertions, 272 deletions
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")
}