osaet/backend/internal/admin/router.go

560 lines
13 KiB
Go

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
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)
}
}
return &Server{
db: db,
store: store,
builder: builder,
deepSeek: cfg.DeepSeek,
localLLM: cfg.LocalLLM,
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.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) 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,
}
}