package admin import ( "context" "errors" "fmt" "net/http" "strconv" "strings" "time" "osaet/backend/internal/ai" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5/pgxpool" ) type Server struct { db *pgxpool.Pool store *Store builder *Builder uploader *AssetUploader deepSeek DeepSeekConfig localLLM LocalLLMConfig slugProvider string adminDir string ctx context.Context } func NewServer(db *pgxpool.Pool) *Server { return NewServerWithConfig(db, Config{}) } func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server { return NewServerWithContext(context.Background(), db, cfg) } func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server { var store *Store var builder *Builder var uploader *AssetUploader if db != nil { store = NewStore(db) if cfg.PostsDir != "" && cfg.SiteDir != "" { builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir) builder.Start(ctx) } if cfg.AssetsDir != "" { uploader = NewAssetUploader(store, cfg.AssetsDir) } } return &Server{ db: db, store: store, builder: builder, uploader: uploader, deepSeek: cfg.DeepSeek, localLLM: cfg.LocalLLM, slugProvider: cfg.SlugProvider, adminDir: cfg.AdminDir, ctx: ctx, } } func (s *Server) Router() http.Handler { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.GET("/healthz", s.health) r.GET("/readyz", s.ready) r.GET("/admin", s.adminPage) r.GET("/admin/", s.adminPage) r.GET("/admin/:filepath", s.adminFile) api := r.Group("/api/admin") api.GET("/health", s.health) api.POST("/login", s.login) protected := api.Group("") protected.Use(s.requireAuth) protected.GET("/me", s.me) protected.POST("/logout", s.logout) protected.POST("/assets", s.uploadAsset) protected.POST("/slug", s.generateSlug) protected.GET("/posts", s.listPosts) protected.POST("/posts", s.createPost) protected.GET("/posts/:id", s.getPost) protected.PUT("/posts/:id", s.updatePost) protected.DELETE("/posts/:id", s.deletePost) protected.POST("/posts/:id/build", s.buildPost) protected.POST("/posts/:id/publish", s.publishPost) protected.GET("/build-jobs/:id", s.getBuildJob) return r } func (s *Server) health(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "ok": true, "service": "osaet-admin", }) } func (s *Server) adminPage(c *gin.Context) { page, err := adminIndex(s.adminDir) if err != nil { c.String(http.StatusInternalServerError, err.Error()) return } c.Data(http.StatusOK, "text/html; charset=utf-8", page) } func (s *Server) adminFile(c *gin.Context) { if serveAdminFile(c, s.adminDir) { return } s.adminPage(c) } func (s *Server) ready(c *gin.Context) { if s.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "ok": false, "error": "database is not configured", }) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() if err := s.db.Ping(ctx); err != nil { c.JSON(http.StatusServiceUnavailable, gin.H{ "ok": false, "error": err.Error(), }) return } c.JSON(http.StatusOK, gin.H{"ok": true}) } func (s *Server) listPosts(c *gin.Context) { if !s.requireStore(c) { return } opts := PostListOptions{ Status: PostStatus(c.Query("status")), Limit: queryInt(c, "limit"), Offset: queryInt(c, "offset"), } posts, err := s.store.ListPosts(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } total, err := s.store.CountPosts(c.Request.Context(), opts.Status) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"posts": posts, "total": total}) } func queryInt(c *gin.Context, key string) int { value := c.Query(key) if value == "" { return 0 } parsed, err := strconv.Atoi(value) if err != nil { return 0 } return parsed } func (s *Server) login(c *gin.Context) { if !s.requireStore(c) { return } var input LoginInput if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } result, err := s.store.Login(c.Request.Context(), input) if err != nil { writeStoreError(c, err) return } SetSessionCookie(c, result.Token, result.ExpiresAt) c.JSON(http.StatusOK, gin.H{ "user": result.User, "expiresAt": result.ExpiresAt, }) } func (s *Server) me(c *gin.Context) { user, ok := c.Request.Context().Value(userContextKey).(User) if !ok { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } c.JSON(http.StatusOK, gin.H{"user": user}) } func (s *Server) logout(c *gin.Context) { if !s.requireStore(c) { return } token, _ := c.Cookie(SessionCookieName) if err := s.store.Logout(c.Request.Context(), token); err != nil { writeStoreError(c, err) return } ClearSessionCookie(c) c.JSON(http.StatusOK, gin.H{"ok": true}) } func (s *Server) getPost(c *gin.Context) { if !s.requireStore(c) { return } post, err := s.store.GetPost(c.Request.Context(), c.Param("id")) if err != nil { writeStoreError(c, err) return } c.JSON(http.StatusOK, gin.H{"post": post}) } func (s *Server) createPost(c *gin.Context) { if !s.requireStore(c) { return } var input PostInput if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } post, err := s.store.CreatePost(c.Request.Context(), input) if err != nil { writeStoreError(c, err) return } c.JSON(http.StatusCreated, gin.H{"post": post}) } func (s *Server) updatePost(c *gin.Context) { if !s.requireStore(c) { return } var input PostInput if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } post, err := s.store.UpdatePost(c.Request.Context(), c.Param("id"), input) if err != nil { writeStoreError(c, err) return } c.JSON(http.StatusOK, gin.H{"post": post}) } func (s *Server) deletePost(c *gin.Context) { if !s.requireStore(c) { return } job, err := s.store.DeletePost(c.Request.Context(), c.Param("id")) if err != nil { writeStoreError(c, err) return } s.enqueueBuildJob(job) c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job}) } func (s *Server) uploadAsset(c *gin.Context) { if !s.requireStore(c) { return } if s.uploader == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "asset uploader is not configured"}) return } file, header, err := c.Request.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } defer file.Close() asset, err := s.uploader.Upload(c.Request.Context(), file, header) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"asset": asset}) } type GenerateSlugInput struct { Title string `json:"title"` Summary string `json:"summary"` PostID string `json:"postId"` } func (s *Server) generateSlug(c *gin.Context) { if !s.requireStore(c) { return } var input GenerateSlugInput if err := c.ShouldBindJSON(&input); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if strings.TrimSpace(input.Title) == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "title is required"}) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 35*time.Second) defer cancel() base, err := s.generateSlugBase(ctx, input.Title, input.Summary) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } slug, err := s.store.UniqueSlug(c.Request.Context(), base, input.PostID) if err != nil { writeStoreError(c, err) return } c.JSON(http.StatusOK, gin.H{"slug": slug}) } func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) { switch strings.ToLower(strings.TrimSpace(s.slugProvider)) { case "", "deepseek": apiKey := strings.TrimSpace(s.deepSeek.APIKey) if apiKey == "" { return "", errors.New("DEEPSEEK_API_KEY is not configured") } return ai.GenerateSlug(ctx, ai.Config{ APIKey: apiKey, BaseURL: s.deepSeek.BaseURL, Model: s.deepSeek.Model, }, title, summary) case "local", "local_llm", "ollama": return ai.GenerateLocalSlug(ctx, ai.LocalConfig{ URL: s.localLLM.URL, Model: s.localLLM.Model, Temperature: s.localLLM.Temperature, TopP: s.localLLM.TopP, NumPredict: s.localLLM.NumPredict, }, title, summary) default: return "", fmt.Errorf("unsupported slug provider %q", s.slugProvider) } } func (s *Server) buildPost(c *gin.Context) { if !s.requireStore(c) { return } job, err := s.store.CreateManualBuildJob(c.Request.Context(), c.Param("id")) if err != nil { writeStoreError(c, err) return } s.enqueueBuildJob(&job) c.JSON(http.StatusAccepted, gin.H{"buildJob": job}) } func (s *Server) publishPost(c *gin.Context) { if !s.requireStore(c) { return } post, job, err := s.store.PublishPost(c.Request.Context(), c.Param("id")) if err != nil { writeStoreError(c, err) return } s.enqueueBuildJob(&job) c.JSON(http.StatusAccepted, gin.H{ "post": post, "buildJob": job, }) } func (s *Server) enqueueBuildJob(job *BuildJob) { if job == nil || s.builder == nil { return } if !s.builder.Enqueue(job.ID) { _ = s.store.MarkBuildJobFailed(context.Background(), job.ID, "", "build queue is full") } } func (s *Server) getBuildJob(c *gin.Context) { if !s.requireStore(c) { return } job, err := s.store.GetBuildJob(c.Request.Context(), c.Param("id")) if err != nil { writeStoreError(c, err) return } c.JSON(http.StatusOK, gin.H{"buildJob": job}) } func (s *Server) requireStore(c *gin.Context) bool { if s.store != nil { return true } c.JSON(http.StatusServiceUnavailable, gin.H{"error": "database is not configured"}) return false } func (s *Server) requireAuth(c *gin.Context) { if !s.requireStore(c) { c.Abort() return } token, err := c.Cookie(SessionCookieName) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.Abort() return } user, err := s.store.UserBySessionToken(c.Request.Context(), token) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) c.Abort() return } ctx := context.WithValue(c.Request.Context(), userContextKey, user) c.Request = c.Request.WithContext(ctx) c.Next() } func writeStoreError(c *gin.Context, err error) { switch err { case ErrNotFound: c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) case ErrInvalidCredentials: c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()}) default: c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } }