package admin import ( "context" "errors" "fmt" "log" "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 deepSeek DeepSeekConfig localLLM LocalLLMConfig uploader *ImageUploader slugProvider string adminDir string staticDir 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 if db != nil { store = NewStore(db) if cfg.PostsDir != "" && cfg.SiteDir != "" { builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir) builder.Start(ctx) } } var uploader *ImageUploader if cfg.R2.Endpoint != "" || cfg.R2.Bucket != "" || cfg.R2.AccessKeyID != "" || cfg.R2.SecretAccessKey != "" { createdUploader, err := NewImageUploader(ctx, cfg.R2) if err != nil { log.Printf("R2 image uploader disabled: %v", err) } else { uploader = createdUploader } } return &Server{ db: db, store: store, builder: builder, deepSeek: cfg.DeepSeek, localLLM: cfg.LocalLLM, uploader: uploader, slugProvider: cfg.SlugProvider, adminDir: cfg.AdminDir, staticDir: cfg.StaticDir, ctx: ctx, } } func (s *Server) Router() http.Handler { gin.SetMode(gin.ReleaseMode) r := gin.New() r.Use(gin.Recovery()) r.Use(requestLogger()) 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("/slug", s.generateSlug) protected.POST("/images", s.uploadImage) protected.GET("/audit-logs", s.listAuditLogs) 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) r.NoRoute(s.siteFile) 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) siteFile(c *gin.Context) { if strings.HasPrefix(c.Request.URL.Path, "/api/") { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } if serveSiteFile(c, s.staticDir) { return } c.String(http.StatusNotFound, "not found") } 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 { s.audit(c, nil, "login_failed", "user", "", gin.H{"username": input.Username, "error": err.Error()}) writeStoreError(c, err) return } SetSessionCookie(c, result.Token, result.ExpiresAt) s.audit(c, &result.User, "login", "user", result.User.ID, gin.H{"username": result.User.Username}) 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 } user, _ := currentUser(c) token, _ := c.Cookie(SessionCookieName) if err := s.store.Logout(c.Request.Context(), token); err != nil { writeStoreError(c, err) return } ClearSessionCookie(c) s.audit(c, &user, "logout", "user", user.ID, gin.H{"username": user.Username}) 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 } s.auditCurrentUser(c, "post_create", "post", post.ID, postAuditDetails(post)) 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 } s.auditCurrentUser(c, "post_update", "post", post.ID, postAuditDetails(post)) 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) details := gin.H{} if job != nil { details["buildJobId"] = job.ID } s.auditCurrentUser(c, "post_delete", "post", c.Param("id"), details) c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job}) } 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 } s.auditCurrentUser(c, "slug_generate", "post", input.PostID, gin.H{"title": input.Title, "slug": slug}) c.JSON(http.StatusOK, gin.H{"slug": slug}) } func (s *Server) uploadImage(c *gin.Context) { if s.uploader == nil { c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 image uploader is not configured"}) return } file, err := c.FormFile("file") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) return } opened, err := file.Open() if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } defer opened.Close() uploaded, err := s.uploader.Upload(c.Request.Context(), file.Filename, opened, file.Size) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } s.auditCurrentUser(c, "image_upload", "image", uploaded.Key, gin.H{ "filename": uploaded.Filename, "url": uploaded.URL, "size": uploaded.Size, "contentType": uploaded.ContentType, }) c.JSON(http.StatusCreated, gin.H{"image": uploaded}) } func (s *Server) listAuditLogs(c *gin.Context) { if !s.requireStore(c) { return } opts := AuditLogListOptions{ Action: c.Query("action"), ResourceType: c.Query("resourceType"), Query: c.Query("query"), Limit: queryInt(c, "limit"), Offset: queryInt(c, "offset"), } logs, err := s.store.ListAuditLogs(c.Request.Context(), opts) if err != nil { writeStoreError(c, err) return } total, err := s.store.CountAuditLogs(c.Request.Context(), opts) if err != nil { writeStoreError(c, err) return } c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total}) } 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) s.auditCurrentUser(c, "build_create", "build_job", job.ID, gin.H{"postId": c.Param("id")}) 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) s.auditCurrentUser(c, "post_publish", "post", post.ID, gin.H{ "title": post.Title, "slug": post.Slug, "status": post.Status, "buildJobId": job.ID, }) 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()}) } } func currentUser(c *gin.Context) (User, bool) { user, ok := c.Request.Context().Value(userContextKey).(User) return user, ok } func (s *Server) auditCurrentUser(c *gin.Context, action string, resourceType string, resourceID string, details map[string]any) { user, ok := currentUser(c) if !ok { s.audit(c, nil, action, resourceType, resourceID, details) return } s.audit(c, &user, action, resourceType, resourceID, details) } func (s *Server) audit(c *gin.Context, user *User, action string, resourceType string, resourceID string, details map[string]any) { if s.store == nil { return } var actorID *string actorUsername := "" if user != nil { actorID = &user.ID actorUsername = user.Username } if err := s.store.CreateAuditLog(c.Request.Context(), AuditLogInput{ ActorID: actorID, ActorUsername: actorUsername, Action: action, ResourceType: resourceType, ResourceID: resourceID, IPAddress: c.ClientIP(), UserAgent: c.Request.UserAgent(), Details: details, }); err != nil { log.Printf("audit log failed: %v", err) } } func requestLogger() gin.HandlerFunc { return func(c *gin.Context) { started := time.Now() c.Next() log.Printf("%s %s %d %s %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), c.ClientIP()) } } func postAuditDetails(post Post) map[string]any { return map[string]any{ "title": post.Title, "slug": post.Slug, "status": post.Status, "version": post.Version, } }