Files

172 lines
5.1 KiB
Go
Raw Permalink Normal View History

2026-05-16 00:18:22 +08:00
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,
}
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)
}