Simplify admin publishing pipeline
This commit is contained in:
parent
13e7e4026d
commit
9186801c7f
37 changed files with 750 additions and 3367 deletions
|
|
@ -1,80 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAssetSizeBytes = 12 * 1024 * 1024
|
||||
|
||||
var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
type AssetUploader struct {
|
||||
store *Store
|
||||
assetsDir string
|
||||
}
|
||||
|
||||
func NewAssetUploader(store *Store, assetsDir string) *AssetUploader {
|
||||
return &AssetUploader{store: store, assetsDir: assetsDir}
|
||||
}
|
||||
|
||||
func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) {
|
||||
if header.Size > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("read asset: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(data)
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
name := assetFilename(header.Filename, sha)
|
||||
if err := os.MkdirAll(u.assetsDir, 0o755); err != nil {
|
||||
return Asset{}, fmt.Errorf("create assets dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil {
|
||||
return Asset{}, fmt.Errorf("write asset: %w", err)
|
||||
}
|
||||
|
||||
asset := Asset{
|
||||
Path: "/assets/" + name,
|
||||
OriginalName: header.Filename,
|
||||
MimeType: mimeType,
|
||||
SizeBytes: int64(len(data)),
|
||||
SHA256: sha,
|
||||
}
|
||||
return u.store.CreateAsset(ctx, asset)
|
||||
}
|
||||
|
||||
func assetFilename(original string, sha string) string {
|
||||
ext := strings.ToLower(filepath.Ext(original))
|
||||
base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original))
|
||||
base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._")
|
||||
if base == "" {
|
||||
base = "image"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s%s", base, sha[:12], ext)
|
||||
}
|
||||
|
|
@ -20,8 +20,11 @@ type Config struct {
|
|||
RepoRoot string
|
||||
PostsDir string
|
||||
SiteDir string
|
||||
AssetsDir string
|
||||
StaticDir string
|
||||
AdminDir string
|
||||
LogFile string
|
||||
LogMaxBytes int64
|
||||
LogMaxBackups int
|
||||
DeepSeek DeepSeekConfig
|
||||
LocalLLM LocalLLMConfig
|
||||
SlugProvider string
|
||||
|
|
@ -89,9 +92,9 @@ func LoadConfig() Config {
|
|||
siteDir = filepath.Join(repoRoot, "frontend", "site")
|
||||
}
|
||||
|
||||
assetsDir := os.Getenv("OSAET_ASSETS_DIR")
|
||||
if assetsDir == "" {
|
||||
assetsDir = filepath.Join(siteDir, "public", "assets")
|
||||
staticDir := os.Getenv("OSAET_STATIC_DIR")
|
||||
if staticDir == "" {
|
||||
staticDir = filepath.Join(repoRoot, "dist", "site")
|
||||
}
|
||||
|
||||
adminDir := os.Getenv("OSAET_ADMIN_DIR")
|
||||
|
|
@ -99,6 +102,11 @@ func LoadConfig() Config {
|
|||
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
|
||||
}
|
||||
|
||||
logFile := os.Getenv("OSAET_LOG_FILE")
|
||||
if logFile == "" {
|
||||
logFile = filepath.Join(repoRoot, ".osaet", "logs", "osaet-admin.log")
|
||||
}
|
||||
|
||||
local := loadLocalConfig(repoRoot)
|
||||
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
|
||||
|
||||
|
|
@ -118,8 +126,11 @@ func LoadConfig() Config {
|
|||
RepoRoot: repoRoot,
|
||||
PostsDir: postsDir,
|
||||
SiteDir: siteDir,
|
||||
AssetsDir: assetsDir,
|
||||
StaticDir: staticDir,
|
||||
AdminDir: adminDir,
|
||||
LogFile: logFile,
|
||||
LogMaxBytes: int64(firstNonZeroInt(envInt("OSAET_LOG_MAX_BYTES"), 10*1024*1024)),
|
||||
LogMaxBackups: firstNonZeroInt(envInt("OSAET_LOG_MAX_BACKUPS"), 5),
|
||||
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
|
||||
DeepSeek: DeepSeekConfig{
|
||||
APIKey: deepSeekAPIKey,
|
||||
|
|
@ -236,8 +247,8 @@ func (c Config) Validate() error {
|
|||
if c.SiteDir == "" {
|
||||
return errors.New("site dir is required")
|
||||
}
|
||||
if c.AssetsDir == "" {
|
||||
return errors.New("assets dir is required")
|
||||
if c.StaticDir == "" {
|
||||
return errors.New("static dir is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
139
backend/internal/admin/logging.go
Normal file
139
backend/internal/admin/logging.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type rotatingLogWriter struct {
|
||||
path string
|
||||
maxBytes int64
|
||||
maxBackups int
|
||||
file *os.File
|
||||
size int64
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func ConfigureLogging(cfg Config) (io.Closer, error) {
|
||||
if cfg.LogFile == "" {
|
||||
return nil, nil
|
||||
}
|
||||
writer, err := newRotatingLogWriter(cfg.LogFile, cfg.LogMaxBytes, cfg.LogMaxBackups)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
multi := io.MultiWriter(os.Stdout, writer)
|
||||
log.SetOutput(multi)
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
|
||||
return writer, nil
|
||||
}
|
||||
|
||||
func newRotatingLogWriter(path string, maxBytes int64, maxBackups int) (*rotatingLogWriter, error) {
|
||||
if maxBytes <= 0 {
|
||||
maxBytes = 10 * 1024 * 1024
|
||||
}
|
||||
if maxBackups <= 0 {
|
||||
maxBackups = 5
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create log dir: %w", err)
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file: %w", err)
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
file.Close()
|
||||
return nil, fmt.Errorf("stat log file: %w", err)
|
||||
}
|
||||
return &rotatingLogWriter{
|
||||
path: path,
|
||||
maxBytes: maxBytes,
|
||||
maxBackups: maxBackups,
|
||||
file: file,
|
||||
size: info.Size(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) Write(p []byte) (int, error) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
if w.size+int64(len(p)) > w.maxBytes {
|
||||
if err := w.rotateLocked(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
n, err := w.file.Write(p)
|
||||
w.size += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) Close() error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if w.file == nil {
|
||||
return nil
|
||||
}
|
||||
err := w.file.Close()
|
||||
w.file = nil
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) rotateLocked() error {
|
||||
if w.file != nil {
|
||||
if err := w.file.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
stamp := time.Now().UTC().Format("20060102T150405")
|
||||
rotated := fmt.Sprintf("%s.%s", w.path, stamp)
|
||||
if err := os.Rename(w.path, rotated); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("rotate log: %w", err)
|
||||
}
|
||||
if err := w.pruneLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open rotated log file: %w", err)
|
||||
}
|
||||
w.file = file
|
||||
w.size = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *rotatingLogWriter) pruneLocked() error {
|
||||
pattern := w.path + ".*"
|
||||
matches, err := filepath.Glob(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list rotated logs: %w", err)
|
||||
}
|
||||
if len(matches) <= w.maxBackups {
|
||||
return nil
|
||||
}
|
||||
sortStrings(matches)
|
||||
for _, path := range matches[:len(matches)-w.maxBackups] {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove old log %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortStrings(values []string) {
|
||||
for i := 1; i < len(values); i++ {
|
||||
value := values[i]
|
||||
j := i - 1
|
||||
for ; j >= 0 && values[j] > value; j-- {
|
||||
values[j+1] = values[j]
|
||||
}
|
||||
values[j+1] = value
|
||||
}
|
||||
}
|
||||
|
|
@ -1,239 +0,0 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type MarkdownImportResult struct {
|
||||
Imported int
|
||||
Skipped int
|
||||
}
|
||||
|
||||
type markdownFrontmatter struct {
|
||||
Slug string `yaml:"slug"`
|
||||
Title string `yaml:"title"`
|
||||
Summary string `yaml:"summary"`
|
||||
Status string `yaml:"status"`
|
||||
Tags []string `yaml:"tags"`
|
||||
Cover string `yaml:"cover"`
|
||||
Version int `yaml:"version"`
|
||||
SlugSource string `yaml:"slug_source"`
|
||||
SlugLocked bool `yaml:"slug_locked"`
|
||||
PublishedAt string `yaml:"published_at"`
|
||||
CreatedAt string `yaml:"created_at"`
|
||||
UpdatedAt string `yaml:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) {
|
||||
entries, err := os.ReadDir(postsDir)
|
||||
if err != nil {
|
||||
return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err)
|
||||
}
|
||||
|
||||
var result MarkdownImportResult
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name()))
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
if post.Title == "" || post.Slug == "" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
if err := s.upsertImportedPost(ctx, post); err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func readMarkdownPost(path string) (Post, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("read markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
frontmatterData, body, err := splitMarkdownFrontmatter(data)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err)
|
||||
}
|
||||
|
||||
var meta markdownFrontmatter
|
||||
if err := yaml.Unmarshal(frontmatterData, &meta); err != nil {
|
||||
return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err)
|
||||
}
|
||||
|
||||
slug := strings.TrimSpace(meta.Slug)
|
||||
if slug == "" {
|
||||
slug = strings.TrimSuffix(filepath.Base(path), ".md")
|
||||
}
|
||||
status := PostStatus(strings.TrimSpace(meta.Status))
|
||||
if !ValidPostStatus(status) || status == PostStatusDeleted {
|
||||
status = PostStatusDraft
|
||||
}
|
||||
version := meta.Version
|
||||
if version < 1 {
|
||||
version = 1
|
||||
}
|
||||
slugSource := strings.TrimSpace(meta.SlugSource)
|
||||
if slugSource == "" {
|
||||
slugSource = "manual"
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
createdAt := parseFrontmatterTime(meta.CreatedAt, now)
|
||||
updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt)
|
||||
var publishedAt *time.Time
|
||||
if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok {
|
||||
publishedAt = &parsed
|
||||
}
|
||||
|
||||
return Post{
|
||||
Slug: slug,
|
||||
Title: strings.TrimSpace(meta.Title),
|
||||
Summary: meta.Summary,
|
||||
BodyMarkdown: strings.TrimLeft(string(body), "\n"),
|
||||
Status: status,
|
||||
Tags: normalizeTagNames(meta.Tags),
|
||||
Cover: meta.Cover,
|
||||
Version: version,
|
||||
SlugSource: slugSource,
|
||||
SlugLocked: meta.SlugLocked,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) upsertImportedPost(ctx context.Context, post Post) error {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin markdown import: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
imported, err := scanPost(tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (
|
||||
slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET
|
||||
title = excluded.title,
|
||||
summary = excluded.summary,
|
||||
body_markdown = excluded.body_markdown,
|
||||
status = excluded.status,
|
||||
cover = excluded.cover,
|
||||
version = GREATEST(posts.version, excluded.version),
|
||||
slug_source = excluded.slug_source,
|
||||
slug_locked = excluded.slug_locked,
|
||||
published_at = excluded.published_at,
|
||||
updated_at = excluded.updated_at,
|
||||
deleted_at = NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
post.Slug,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.BodyMarkdown,
|
||||
post.Status,
|
||||
post.Cover,
|
||||
post.Version,
|
||||
post.SlugSource,
|
||||
post.SlugLocked,
|
||||
post.PublishedAt,
|
||||
post.CreatedAt,
|
||||
post.UpdatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err)
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (post_id, version) DO NOTHING`,
|
||||
imported.ID,
|
||||
imported.Version,
|
||||
imported.Title,
|
||||
imported.Summary,
|
||||
imported.BodyMarkdown,
|
||||
imported.Status,
|
||||
VersionReasonImport,
|
||||
); err != nil {
|
||||
return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit markdown import: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) {
|
||||
if !bytes.HasPrefix(data, []byte("---\n")) {
|
||||
return nil, nil, errors.New("missing frontmatter opening marker")
|
||||
}
|
||||
|
||||
rest := data[len("---\n"):]
|
||||
idx := bytes.Index(rest, []byte("\n---"))
|
||||
if idx < 0 {
|
||||
return nil, nil, errors.New("missing frontmatter closing marker")
|
||||
}
|
||||
|
||||
frontmatter := rest[:idx]
|
||||
body := rest[idx+len("\n---"):]
|
||||
if bytes.HasPrefix(body, []byte("\r\n")) {
|
||||
body = body[2:]
|
||||
} else if bytes.HasPrefix(body, []byte("\n")) {
|
||||
body = body[1:]
|
||||
}
|
||||
return frontmatter, body, nil
|
||||
}
|
||||
|
||||
func parseFrontmatterTime(value string, fallback time.Time) time.Time {
|
||||
if parsed, ok := parseOptionalFrontmatterTime(value); ok {
|
||||
return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func parseOptionalFrontmatterTime(value string) (time.Time, bool) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
"2006-01-02 15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, value); err == nil {
|
||||
return parsed, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -19,11 +20,11 @@ type Server struct {
|
|||
db *pgxpool.Pool
|
||||
store *Store
|
||||
builder *Builder
|
||||
uploader *AssetUploader
|
||||
deepSeek DeepSeekConfig
|
||||
localLLM LocalLLMConfig
|
||||
slugProvider string
|
||||
adminDir string
|
||||
staticDir string
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
|
|
@ -38,26 +39,22 @@ func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server {
|
|||
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,
|
||||
staticDir: cfg.StaticDir,
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +64,7 @@ func (s *Server) Router() http.Handler {
|
|||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(requestLogger())
|
||||
|
||||
r.GET("/healthz", s.health)
|
||||
r.GET("/readyz", s.ready)
|
||||
|
|
@ -82,8 +80,8 @@ func (s *Server) Router() http.Handler {
|
|||
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("/audit-logs", s.listAuditLogs)
|
||||
protected.GET("/posts", s.listPosts)
|
||||
protected.POST("/posts", s.createPost)
|
||||
protected.GET("/posts/:id", s.getPost)
|
||||
|
|
@ -93,6 +91,8 @@ func (s *Server) Router() http.Handler {
|
|||
protected.POST("/posts/:id/publish", s.publishPost)
|
||||
protected.GET("/build-jobs/:id", s.getBuildJob)
|
||||
|
||||
r.NoRoute(s.siteFile)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +119,17 @@ func (s *Server) adminFile(c *gin.Context) {
|
|||
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{
|
||||
|
|
@ -190,10 +201,12 @@ func (s *Server) login(c *gin.Context) {
|
|||
|
||||
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,
|
||||
|
|
@ -214,12 +227,14 @@ func (s *Server) logout(c *gin.Context) {
|
|||
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})
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +267,7 @@ func (s *Server) createPost(c *gin.Context) {
|
|||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
s.auditCurrentUser(c, "post_create", "post", post.ID, postAuditDetails(post))
|
||||
c.JSON(http.StatusCreated, gin.H{"post": post})
|
||||
}
|
||||
|
||||
|
|
@ -271,6 +287,7 @@ func (s *Server) updatePost(c *gin.Context) {
|
|||
writeStoreError(c, err)
|
||||
return
|
||||
}
|
||||
s.auditCurrentUser(c, "post_update", "post", post.ID, postAuditDetails(post))
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
|
|
@ -285,33 +302,14 @@ func (s *Server) deletePost(c *gin.Context) {
|
|||
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})
|
||||
}
|
||||
|
||||
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"`
|
||||
|
|
@ -345,9 +343,35 @@ func (s *Server) generateSlug(c *gin.Context) {
|
|||
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":
|
||||
|
|
@ -385,6 +409,7 @@ func (s *Server) buildPost(c *gin.Context) {
|
|||
}
|
||||
|
||||
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})
|
||||
}
|
||||
|
||||
|
|
@ -400,6 +425,12 @@ func (s *Server) publishPost(c *gin.Context) {
|
|||
}
|
||||
|
||||
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,
|
||||
|
|
@ -472,3 +503,58 @@ func writeStoreError(c *gin.Context, err error) {
|
|||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,33 @@ func serveAdminFile(c *gin.Context, adminDir string) bool {
|
|||
return true
|
||||
}
|
||||
|
||||
func serveSiteFile(c *gin.Context, staticDir string) bool {
|
||||
if strings.TrimSpace(staticDir) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
requested := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if requested == "" {
|
||||
requested = "index.html"
|
||||
}
|
||||
cleaned := filepath.Clean(requested)
|
||||
if cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) {
|
||||
return false
|
||||
}
|
||||
|
||||
path := filepath.Join(staticDir, cleaned)
|
||||
info, err := os.Stat(path)
|
||||
if err == nil && info.IsDir() {
|
||||
path = filepath.Join(path, "index.html")
|
||||
info, err = os.Stat(path)
|
||||
}
|
||||
if err != nil || info.IsDir() {
|
||||
return false
|
||||
}
|
||||
http.ServeFile(c.Writer, c.Request, path)
|
||||
return true
|
||||
}
|
||||
|
||||
func rewriteAdminBase(page []byte) []byte {
|
||||
return []byte(strings.Replace(string(page), `<base href="/">`, `<base href="/admin/">`, 1))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package admin
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
|
@ -25,6 +26,25 @@ type PostListOptions struct {
|
|||
Offset int
|
||||
}
|
||||
|
||||
type AuditLogInput struct {
|
||||
ActorID *string
|
||||
ActorUsername string
|
||||
Action string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
IPAddress string
|
||||
UserAgent string
|
||||
Details map[string]any
|
||||
}
|
||||
|
||||
type AuditLogListOptions struct {
|
||||
Action string
|
||||
ResourceType string
|
||||
Query string
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
|
|
@ -389,28 +409,6 @@ WHERE id = $1`, id))
|
|||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) {
|
||||
created, err := scanAsset(s.db.QueryRow(ctx, `
|
||||
INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (path)
|
||||
DO UPDATE SET original_name = excluded.original_name,
|
||||
mime_type = excluded.mime_type,
|
||||
size_bytes = excluded.size_bytes,
|
||||
sha256 = excluded.sha256
|
||||
RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`,
|
||||
asset.Path,
|
||||
asset.OriginalName,
|
||||
asset.MimeType,
|
||||
asset.SizeBytes,
|
||||
asset.SHA256,
|
||||
))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("create asset: %w", err)
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
|
|
@ -473,6 +471,114 @@ WHERE id = $1`, id, log, message)
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error {
|
||||
action := strings.TrimSpace(input.Action)
|
||||
if action == "" {
|
||||
return errors.New("audit action is required")
|
||||
}
|
||||
details := input.Details
|
||||
if details == nil {
|
||||
details = map[string]any{}
|
||||
}
|
||||
detailsJSON, err := json.Marshal(details)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode audit details: %w", err)
|
||||
}
|
||||
_, err = s.db.Exec(ctx, `
|
||||
INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
|
||||
input.ActorID,
|
||||
input.ActorUsername,
|
||||
action,
|
||||
strings.TrimSpace(input.ResourceType),
|
||||
strings.TrimSpace(input.ResourceID),
|
||||
strings.TrimSpace(input.IPAddress),
|
||||
strings.TrimSpace(input.UserAgent),
|
||||
string(detailsJSON),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create audit log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 200 {
|
||||
limit = 50
|
||||
}
|
||||
offset := opts.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args := []any{limit, offset}
|
||||
where := "true"
|
||||
if strings.TrimSpace(opts.Action) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.Action))
|
||||
where += fmt.Sprintf(" AND action = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.ResourceType) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.ResourceType))
|
||||
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.Query) != "" {
|
||||
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
|
||||
where += fmt.Sprintf(` AND (
|
||||
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
|
||||
)`, len(args), len(args), len(args), len(args), len(args))
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at
|
||||
FROM audit_logs
|
||||
WHERE `+where+`
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list audit logs: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var logs []AuditLog
|
||||
for rows.Next() {
|
||||
log, err := scanAuditLog(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logs = append(logs, log)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("audit log rows: %w", err)
|
||||
}
|
||||
return logs, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) {
|
||||
args := []any{}
|
||||
where := "true"
|
||||
if strings.TrimSpace(opts.Action) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.Action))
|
||||
where += fmt.Sprintf(" AND action = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.ResourceType) != "" {
|
||||
args = append(args, strings.TrimSpace(opts.ResourceType))
|
||||
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
|
||||
}
|
||||
if strings.TrimSpace(opts.Query) != "" {
|
||||
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
|
||||
where += fmt.Sprintf(` AND (
|
||||
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
|
||||
)`, len(args), len(args), len(args), len(args), len(args))
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("count audit logs: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func validatePostInput(input PostInput, creating bool) error {
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
return errors.New("title is required")
|
||||
|
|
@ -780,24 +886,35 @@ func scanBuildJob(row postScanner) (BuildJob, error) {
|
|||
return job, nil
|
||||
}
|
||||
|
||||
func scanAsset(row postScanner) (Asset, error) {
|
||||
var asset Asset
|
||||
var createdBy sql.NullString
|
||||
func scanAuditLog(row postScanner) (AuditLog, error) {
|
||||
var log AuditLog
|
||||
var actorID sql.NullString
|
||||
var details []byte
|
||||
err := row.Scan(
|
||||
&asset.ID,
|
||||
&asset.Path,
|
||||
&asset.OriginalName,
|
||||
&asset.MimeType,
|
||||
&asset.SizeBytes,
|
||||
&asset.SHA256,
|
||||
&asset.CreatedAt,
|
||||
&createdBy,
|
||||
&log.ID,
|
||||
&actorID,
|
||||
&log.ActorUsername,
|
||||
&log.Action,
|
||||
&log.ResourceType,
|
||||
&log.ResourceID,
|
||||
&log.IPAddress,
|
||||
&log.UserAgent,
|
||||
&details,
|
||||
&log.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
return AuditLog{}, err
|
||||
}
|
||||
asset.CreatedBy = nullStringPtr(createdBy)
|
||||
return asset, nil
|
||||
log.ActorID = nullStringPtr(actorID)
|
||||
if len(details) > 0 {
|
||||
if err := json.Unmarshal(details, &log.Details); err != nil {
|
||||
return AuditLog{}, fmt.Errorf("decode audit details: %w", err)
|
||||
}
|
||||
}
|
||||
if log.Details == nil {
|
||||
log.Details = map[string]any{}
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
func nullTimePtr(value sql.NullTime) *time.Time {
|
||||
|
|
|
|||
|
|
@ -76,17 +76,6 @@ type Tag struct {
|
|||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Asset struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
OriginalName string `json:"originalName"`
|
||||
MimeType string `json:"mimeType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
SHA256 string `json:"sha256"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
|
||||
type BuildJob struct {
|
||||
ID string `json:"id"`
|
||||
Trigger BuildJobTrigger `json:"trigger"`
|
||||
|
|
@ -99,3 +88,16 @@ type BuildJob struct {
|
|||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
ActorID *string `json:"actorId"`
|
||||
ActorUsername string `json:"actorUsername"`
|
||||
Action string `json:"action"`
|
||||
ResourceType string `json:"resourceType"`
|
||||
ResourceID string `json:"resourceId"`
|
||||
IPAddress string `json:"ipAddress"`
|
||||
UserAgent string `json:"userAgent"`
|
||||
Details map[string]any `json:"details"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue