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")
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-25 00:09:44 +08:00
|
|
|
if err := handlers.EnsureResourceI18nColumns(ctx, pool); err != nil {
|
|
|
|
|
log.Fatalf("resources i18n columns: %v", err)
|
|
|
|
|
}
|
2026-05-25 16:45:33 +08:00
|
|
|
if err := handlers.EnsureCategoryI18nColumns(ctx, pool); err != nil {
|
|
|
|
|
log.Fatalf("categories i18n columns: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if err := handlers.EnsurePostsSchema(ctx, pool); err != nil {
|
|
|
|
|
log.Fatalf("posts schema: %v", err)
|
|
|
|
|
}
|
2026-05-26 12:08:39 +08:00
|
|
|
if err := handlers.EnsureTagI18nSchema(ctx, pool); err != nil {
|
|
|
|
|
log.Fatalf("tags i18n schema: %v", err)
|
|
|
|
|
}
|
2026-05-25 00:09:44 +08:00
|
|
|
|
2026-05-16 00:18:22 +08:00
|
|
|
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,
|
2026-05-19 07:37:25 +08:00
|
|
|
S3ObjectACL: cfg.S3ObjectACL,
|
2026-05-16 00:18:22 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-25 16:45:33 +08:00
|
|
|
r.Get("/posts", handlers.ListPosts)
|
|
|
|
|
r.Get("/posts/recommended", handlers.ListPostsRecommended)
|
|
|
|
|
r.Get("/posts/latest", handlers.ListPostsLatest)
|
|
|
|
|
r.Get("/posts/search", handlers.SearchPosts)
|
|
|
|
|
r.Get("/posts/{id}", handlers.GetPost)
|
2026-05-26 18:11:47 +08:00
|
|
|
r.Get("/posts/{id}/attachments/{aid}/download", handlers.GetPostAttachmentDownload)
|
2026-05-25 16:45:33 +08:00
|
|
|
r.Post("/posts/{id}/attachments/{aid}/download", handlers.PostAttachmentDownload)
|
2026-05-16 00:18:22 +08:00
|
|
|
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)
|
|
|
|
|
}
|