feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
474
backend/internal/admin/router.go
Normal file
474
backend/internal/admin/router.go
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
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()})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue