package main import ( "context" "log" "net/http" "os" "path/filepath" "time" "github.com/arkie/ark-database/internal/config" "github.com/arkie/ark-database/internal/db" "github.com/arkie/ark-database/internal/handlers" "github.com/arkie/ark-database/internal/seed" awsconfig "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/cors" ) func main() { cfg := config.Load() ctx := context.Background() dbCtx, dbCancel := context.WithTimeout(ctx, 30*time.Second) pool, err := db.Connect(dbCtx, cfg.DatabaseURL) dbCancel() if err != nil { log.Fatal(err) } defer pool.Close() if cfg.RunWalletAuthSchema { if err := handlers.EnsureWalletAuthSchema(ctx, pool); err != nil { log.Fatalf("wallet auth schema: %v", err) } } else { log.Print("RUN_WALLET_AUTH_SCHEMA=false: skipping wallet_auth_* DDL at startup") } if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil { log.Fatal(err) } // Placeholder asset for demo DB rows _ = copyIfMissing("static/placeholder-cover.svg", filepath.Join(cfg.UploadDir, "placeholder-cover.svg")) if cfg.SeedAdmin { if err := seed.EnsureAdmin(ctx, pool, cfg.AdminEmail, cfg.AdminPass); err != nil { log.Fatal(err) } } var s3Client *s3.Client if cfg.S3Bucket != "" { // Avoid blocking forever when IMDS is unreachable from the container (e.g. IMDSv2 hop limit 1 on EC2). s3Ctx, cancel := context.WithTimeout(ctx, 8*time.Second) awscfg, err := awsconfig.LoadDefaultConfig(s3Ctx, awsconfig.WithRegion(cfg.AWSRegion)) cancel() if err != nil { log.Printf("S3 disabled: aws config failed (uploads stay local): %v", err) } else { s3Client = s3.NewFromConfig(awscfg) log.Printf("S3 uploads enabled bucket=%s region=%s prefix=%s", cfg.S3Bucket, cfg.AWSRegion, cfg.S3UploadPrefix) } } uploadDeps := handlers.UploadDeps{ LocalDir: cfg.UploadDir, MaxMultipartMem: cfg.UploadMaxMem, S3: s3Client, S3Bucket: cfg.S3Bucket, AWSRegion: cfg.AWSRegion, S3Prefix: cfg.S3UploadPrefix, S3PublicBase: cfg.S3PublicBase, S3ObjectACL: cfg.S3ObjectACL, } r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Minute)) if len(cfg.CORSOrigins) > 0 { r.Use(cors.Handler(cors.Options{ AllowedOrigins: cfg.CORSOrigins, AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"}, AllowCredentials: false, MaxAge: 300, })) } fs := http.FileServer(http.Dir(cfg.UploadDir)) r.Handle("/uploads/*", http.StripPrefix("/uploads/", fs)) healthOK := func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } r.Get("/healthz", healthOK) r.Get("/health", healthOK) r.Route("/api", func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return handlers.WithPool(next, pool) }) r.Get("/categories", handlers.ListCategories) r.Get("/resources", handlers.ListResources) r.Get("/resources/recommended", handlers.ListRecommended) r.Get("/resources/latest", handlers.ListLatest) r.Get("/resources/popular", handlers.ListPopular) r.Get("/resources/{id}", handlers.GetResource) r.Get("/resources/{id}/related", handlers.RelatedResources) r.Post("/search-log", handlers.LogSearch) r.Post("/auth/wallet/nonce", handlers.WalletNonce()) r.Post("/auth/wallet/verify", handlers.WalletVerify(cfg.JWTSecret)) r.Get("/auth/wallet/me", handlers.WalletMe(cfg.JWTSecret)) r.Post("/resources/{id}/view", handlers.IncrView) r.Post("/resources/{id}/download", handlers.IncrDownload) r.Post("/resources/{id}/share", handlers.IncrShare) r.Post("/resources/{id}/favorite", handlers.FavoriteDelta) r.Post("/admin/login", handlers.AdminLogin(cfg.JWTSecret)) r.Group(func(r chi.Router) { r.Use(handlers.AdminAuth(cfg.JWTSecret)) r.Get("/admin/dashboard", handlers.AdminDashboard) r.Get("/admin/search-logs", handlers.AdminSearchLogs) r.Get("/admin/resources", handlers.AdminListResources) r.Get("/admin/resources/{id}", handlers.AdminGetResource) r.Post("/admin/resources", handlers.AdminCreateResource) r.Put("/admin/resources/{id}", handlers.AdminUpdateResource) r.Delete("/admin/resources/{id}", handlers.AdminDeleteResource) r.Post("/admin/upload", handlers.UploadFile(uploadDeps)) r.Get("/admin/categories", handlers.ListCategories) }) }) log.Printf("listening on %s", cfg.Addr) if err := http.ListenAndServe(cfg.Addr, r); err != nil { log.Fatal(err) } } func copyIfMissing(srcRel, dst string) error { if _, err := os.Stat(dst); err == nil { return nil } exe, _ := os.Executable() base := filepath.Dir(exe) candidates := []string{ filepath.Join(base, srcRel), filepath.Join("..", srcRel), filepath.Join(".", srcRel), } var data []byte var err error for _, c := range candidates { data, err = os.ReadFile(c) if err == nil { break } } if len(data) == 0 { return err } return os.WriteFile(dst, data, 0o644) }