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
80
backend/internal/admin/assets.go
Normal file
80
backend/internal/admin/assets.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
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)
|
||||
}
|
||||
75
backend/internal/admin/auth.go
Normal file
75
backend/internal/admin/auth.go
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
const SessionCookieName = "osaet_admin_session"
|
||||
|
||||
var ErrInvalidCredentials = errors.New("invalid username or password")
|
||||
|
||||
type contextKey string
|
||||
|
||||
const userContextKey contextKey = "adminUser"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
if password == "" {
|
||||
return "", errors.New("password is required")
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("hash password: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
}
|
||||
|
||||
func CheckPassword(hash string, password string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
|
||||
func NewSessionToken() (string, error) {
|
||||
token := make([]byte, 32)
|
||||
if _, err := rand.Read(token); err != nil {
|
||||
return "", fmt.Errorf("generate session token: %w", err)
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(token), nil
|
||||
}
|
||||
|
||||
func SessionTokenHash(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func SetSessionCookie(c *gin.Context, token string, expiresAt time.Time) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expiresAt,
|
||||
MaxAge: int(time.Until(expiresAt).Seconds()),
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func ClearSessionCookie(c *gin.Context) {
|
||||
http.SetCookie(c.Writer, &http.Cookie{
|
||||
Name: SessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Expires: time.Unix(0, 0),
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
166
backend/internal/admin/auth_store.go
Normal file
166
backend/internal/admin/auth_store.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
const sessionLifetime = 30 * 24 * time.Hour
|
||||
|
||||
type LoginInput struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type LoginResult struct {
|
||||
User User `json:"user"`
|
||||
ExpiresAt time.Time `json:"expiresAt"`
|
||||
Token string `json:"-"`
|
||||
}
|
||||
|
||||
func (s *Store) CreateOrUpdateUser(ctx context.Context, username string, password string) (User, error) {
|
||||
if username == "" {
|
||||
return User{}, errors.New("username is required")
|
||||
}
|
||||
passwordHash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
user, err := scanUser(s.db.QueryRow(ctx, `
|
||||
INSERT INTO users (username, password_hash)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (username)
|
||||
DO UPDATE SET password_hash = excluded.password_hash, updated_at = now()
|
||||
RETURNING id, username, created_at, updated_at, last_login_at`, username, passwordHash))
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("create or update user: %w", err)
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Login(ctx context.Context, input LoginInput) (LoginResult, error) {
|
||||
if input.Username == "" || input.Password == "" {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
var user User
|
||||
var passwordHash string
|
||||
var lastLoginAt sql.NullTime
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, created_at, updated_at, last_login_at
|
||||
FROM users
|
||||
WHERE username = $1`, input.Username).Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&passwordHash,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&lastLoginAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("find user: %w", err)
|
||||
}
|
||||
user.LastLoginAt = nullTimePtr(lastLoginAt)
|
||||
|
||||
if !CheckPassword(passwordHash, input.Password) {
|
||||
return LoginResult{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
token, err := NewSessionToken()
|
||||
if err != nil {
|
||||
return LoginResult{}, err
|
||||
}
|
||||
loginAt := time.Now()
|
||||
expiresAt := loginAt.Add(sessionLifetime)
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return LoginResult{}, fmt.Errorf("begin login: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE users
|
||||
SET last_login_at = now(), updated_at = now()
|
||||
WHERE id = $1`, user.ID); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("update last login: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_sessions (user_id, token_hash, expires_at)
|
||||
VALUES ($1, $2, $3)`, user.ID, SessionTokenHash(token), expiresAt); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return LoginResult{}, fmt.Errorf("commit login: %w", err)
|
||||
}
|
||||
|
||||
user.LastLoginAt = &loginAt
|
||||
return LoginResult{
|
||||
User: user,
|
||||
ExpiresAt: expiresAt,
|
||||
Token: token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Store) UserBySessionToken(ctx context.Context, token string) (User, error) {
|
||||
if token == "" {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
user, err := scanUser(s.db.QueryRow(ctx, `
|
||||
SELECT u.id, u.username, u.created_at, u.updated_at, u.last_login_at
|
||||
FROM admin_sessions s
|
||||
JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token_hash = $1 AND s.expires_at > now()`, SessionTokenHash(token)))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrInvalidCredentials
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("find session: %w", err)
|
||||
}
|
||||
|
||||
_, _ = s.db.Exec(ctx, `
|
||||
UPDATE admin_sessions
|
||||
SET last_seen_at = now()
|
||||
WHERE token_hash = $1`, SessionTokenHash(token))
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *Store) Logout(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
DELETE FROM admin_sessions
|
||||
WHERE token_hash = $1`, SessionTokenHash(token)); err != nil {
|
||||
return fmt.Errorf("delete session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanUser(row postScanner) (User, error) {
|
||||
var user User
|
||||
var lastLoginAt sql.NullTime
|
||||
err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Username,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
&lastLoginAt,
|
||||
)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
user.LastLoginAt = nullTimePtr(lastLoginAt)
|
||||
return user, nil
|
||||
}
|
||||
91
backend/internal/admin/builder.go
Normal file
91
backend/internal/admin/builder.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Builder struct {
|
||||
store *Store
|
||||
exporter *Exporter
|
||||
siteDir string
|
||||
queue chan string
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func NewBuilder(store *Store, exporter *Exporter, siteDir string) *Builder {
|
||||
return &Builder{
|
||||
store: store,
|
||||
exporter: exporter,
|
||||
siteDir: siteDir,
|
||||
queue: make(chan string, 32),
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) Start(ctx context.Context) {
|
||||
b.once.Do(func() {
|
||||
go b.loop(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
func (b *Builder) Enqueue(jobID string) bool {
|
||||
select {
|
||||
case b.queue <- jobID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) loop(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case jobID := <-b.queue:
|
||||
b.runBuildJob(ctx, jobID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (b *Builder) runBuildJob(ctx context.Context, jobID string) {
|
||||
log, err := b.run(ctx, jobID)
|
||||
if err != nil {
|
||||
_ = b.store.MarkBuildJobFailed(context.Background(), jobID, log, err.Error())
|
||||
return
|
||||
}
|
||||
_ = b.store.MarkBuildJobSuccess(context.Background(), jobID, log)
|
||||
}
|
||||
|
||||
func (b *Builder) run(ctx context.Context, jobID string) (string, error) {
|
||||
if err := b.store.MarkBuildJobRunning(ctx, jobID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
posts, err := b.store.PublishedPostsForExport(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := b.exporter.ExportPublishedPosts(ctx, posts); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
buildCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(buildCtx, "npm", "run", "build")
|
||||
cmd.Dir = b.siteDir
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd.Stdout = &output
|
||||
cmd.Stderr = &output
|
||||
if err := cmd.Run(); err != nil {
|
||||
return output.String(), fmt.Errorf("astro build failed: %w", err)
|
||||
}
|
||||
|
||||
return output.String(), nil
|
||||
}
|
||||
243
backend/internal/admin/config.go
Normal file
243
backend/internal/admin/config.go
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultAdminAddr = ":8080"
|
||||
const defaultMigrationsDir = "migrations"
|
||||
|
||||
type Config struct {
|
||||
Addr string
|
||||
DatabaseURL string
|
||||
MigrationsDir string
|
||||
RepoRoot string
|
||||
PostsDir string
|
||||
SiteDir string
|
||||
AssetsDir string
|
||||
AdminDir string
|
||||
DeepSeek DeepSeekConfig
|
||||
LocalLLM LocalLLMConfig
|
||||
SlugProvider string
|
||||
}
|
||||
|
||||
type DeepSeekConfig struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
}
|
||||
|
||||
type LocalLLMConfig struct {
|
||||
URL string
|
||||
Model string
|
||||
Temperature float64
|
||||
TopP float64
|
||||
NumPredict int
|
||||
}
|
||||
|
||||
type localConfig struct {
|
||||
Database struct {
|
||||
PostgresDSN string `yaml:"postgres_dsn"`
|
||||
} `yaml:"database"`
|
||||
Slug struct {
|
||||
Provider string `yaml:"provider"`
|
||||
} `yaml:"slug"`
|
||||
DeepSeek struct {
|
||||
APIKey string `yaml:"api_key"`
|
||||
APIKeyEnv string `yaml:"api_key_env"`
|
||||
BaseURL string `yaml:"base_url"`
|
||||
Model string `yaml:"model"`
|
||||
} `yaml:"deepseek"`
|
||||
LocalLLM struct {
|
||||
URL string `yaml:"url"`
|
||||
Model string `yaml:"model"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
TopP float64 `yaml:"top_p"`
|
||||
NumPredict int `yaml:"num_predict"`
|
||||
} `yaml:"local_llm"`
|
||||
}
|
||||
|
||||
func LoadConfig() Config {
|
||||
addr := os.Getenv("OSAET_ADMIN_ADDR")
|
||||
if addr == "" {
|
||||
addr = defaultAdminAddr
|
||||
}
|
||||
|
||||
migrationsDir := os.Getenv("OSAET_MIGRATIONS_DIR")
|
||||
if migrationsDir == "" {
|
||||
migrationsDir = defaultMigrationsDir
|
||||
}
|
||||
|
||||
repoRoot := os.Getenv("OSAET_REPO_ROOT")
|
||||
if repoRoot == "" {
|
||||
repoRoot = ".."
|
||||
}
|
||||
|
||||
postsDir := os.Getenv("OSAET_POSTS_DIR")
|
||||
if postsDir == "" {
|
||||
postsDir = filepath.Join(repoRoot, "content", "posts")
|
||||
}
|
||||
|
||||
siteDir := os.Getenv("OSAET_SITE_DIR")
|
||||
if siteDir == "" {
|
||||
siteDir = filepath.Join(repoRoot, "frontend", "site")
|
||||
}
|
||||
|
||||
assetsDir := os.Getenv("OSAET_ASSETS_DIR")
|
||||
if assetsDir == "" {
|
||||
assetsDir = filepath.Join(siteDir, "public", "assets")
|
||||
}
|
||||
|
||||
adminDir := os.Getenv("OSAET_ADMIN_DIR")
|
||||
if adminDir == "" {
|
||||
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
|
||||
}
|
||||
|
||||
local := loadLocalConfig(repoRoot)
|
||||
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
|
||||
|
||||
deepSeekAPIKeyEnv := firstNonEmpty(local.DeepSeek.APIKeyEnv, "DEEPSEEK_API_KEY")
|
||||
deepSeekAPIKey := strings.TrimSpace(os.Getenv(deepSeekAPIKeyEnv))
|
||||
if deepSeekAPIKey == "" && deepSeekAPIKeyEnv != "DEEPSEEK_API_KEY" {
|
||||
deepSeekAPIKey = strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
|
||||
}
|
||||
if deepSeekAPIKey == "" {
|
||||
deepSeekAPIKey = strings.TrimSpace(local.DeepSeek.APIKey)
|
||||
}
|
||||
|
||||
return Config{
|
||||
Addr: addr,
|
||||
DatabaseURL: databaseURL,
|
||||
MigrationsDir: migrationsDir,
|
||||
RepoRoot: repoRoot,
|
||||
PostsDir: postsDir,
|
||||
SiteDir: siteDir,
|
||||
AssetsDir: assetsDir,
|
||||
AdminDir: adminDir,
|
||||
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
|
||||
DeepSeek: DeepSeekConfig{
|
||||
APIKey: deepSeekAPIKey,
|
||||
BaseURL: firstNonEmpty(os.Getenv("DEEPSEEK_BASE_URL"), local.DeepSeek.BaseURL),
|
||||
Model: firstNonEmpty(os.Getenv("DEEPSEEK_MODEL"), local.DeepSeek.Model),
|
||||
},
|
||||
LocalLLM: LocalLLMConfig{
|
||||
URL: firstNonEmpty(os.Getenv("LOCAL_LLM_URL"), local.LocalLLM.URL, "http://127.0.0.1:11434/api/generate"),
|
||||
Model: firstNonEmpty(os.Getenv("LOCAL_LLM_MODEL"), local.LocalLLM.Model),
|
||||
Temperature: firstNonZeroFloat(envFloat("LOCAL_LLM_TEMPERATURE"), local.LocalLLM.Temperature, 0.1),
|
||||
TopP: firstNonZeroFloat(envFloat("LOCAL_LLM_TOP_P"), local.LocalLLM.TopP, 0.8),
|
||||
NumPredict: firstNonZeroInt(envInt("LOCAL_LLM_NUM_PREDICT"), local.LocalLLM.NumPredict, 32),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func loadLocalConfig(repoRoot string) localConfig {
|
||||
var config localConfig
|
||||
paths := []string{}
|
||||
if path := strings.TrimSpace(os.Getenv("OSAET_LOCAL_CONFIG")); path != "" {
|
||||
paths = append(paths, path)
|
||||
} else {
|
||||
paths = append(paths,
|
||||
filepath.Join(repoRoot, "config", "local.yaml"),
|
||||
filepath.Join("config", "local.yaml"),
|
||||
filepath.Join("..", "config", "local.yaml"),
|
||||
)
|
||||
}
|
||||
|
||||
path, data, ok := readFirstExistingFile(paths)
|
||||
if !ok {
|
||||
return config
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &config); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: failed to read %s: %v\n", path, err)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func readFirstExistingFile(paths []string) (string, []byte, bool) {
|
||||
for _, path := range paths {
|
||||
data, err := os.ReadFile(path)
|
||||
if err == nil {
|
||||
return path, data, true
|
||||
}
|
||||
}
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonZeroFloat(values ...float64) float64 {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstNonZeroInt(values ...int) int {
|
||||
for _, value := range values {
|
||||
if value != 0 {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func envFloat(key string) float64 {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var parsed float64
|
||||
if _, err := fmt.Sscanf(value, "%f", &parsed); err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func envInt(key string) int {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
var parsed int
|
||||
if _, err := fmt.Sscanf(value, "%d", &parsed); err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func (c Config) Validate() error {
|
||||
if c.Addr == "" {
|
||||
return errors.New("admin addr is required")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return errors.New("DATABASE_URL or config/local.yaml database.postgres_dsn is required")
|
||||
}
|
||||
if c.MigrationsDir == "" {
|
||||
return errors.New("migrations dir is required")
|
||||
}
|
||||
if c.PostsDir == "" {
|
||||
return errors.New("posts dir is required")
|
||||
}
|
||||
if c.SiteDir == "" {
|
||||
return errors.New("site dir is required")
|
||||
}
|
||||
if c.AssetsDir == "" {
|
||||
return errors.New("assets dir is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
20
backend/internal/admin/database.go
Normal file
20
backend/internal/admin/database.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func OpenDatabase(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create postgres pool: %w", err)
|
||||
}
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping postgres: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
135
backend/internal/admin/exporter.go
Normal file
135
backend/internal/admin/exporter.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Exporter struct {
|
||||
postsDir string
|
||||
}
|
||||
|
||||
type postFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
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 NewExporter(postsDir string) *Exporter {
|
||||
return &Exporter{postsDir: postsDir}
|
||||
}
|
||||
|
||||
func (e *Exporter) ExportPublishedPosts(ctx context.Context, posts []Post) error {
|
||||
if err := os.MkdirAll(e.postsDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create posts dir: %w", err)
|
||||
}
|
||||
|
||||
publishedFiles := make(map[string]bool, len(posts))
|
||||
for _, post := range posts {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
publishedFiles[post.Slug+".md"] = true
|
||||
if err := e.writePost(post); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := e.removeStalePosts(publishedFiles); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exporter) removeStalePosts(publishedFiles map[string]bool) error {
|
||||
entries, err := os.ReadDir(e.postsDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read posts dir: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
if publishedFiles[entry.Name()] {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(e.postsDir, entry.Name())); err != nil {
|
||||
return fmt.Errorf("remove stale post %s: %w", entry.Name(), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Exporter) writePost(post Post) error {
|
||||
path := filepath.Join(e.postsDir, post.Slug+".md")
|
||||
frontmatter := postFrontmatter{
|
||||
ID: post.ID,
|
||||
Slug: post.Slug,
|
||||
Title: post.Title,
|
||||
Summary: post.Summary,
|
||||
Status: string(post.Status),
|
||||
Tags: post.Tags,
|
||||
Cover: post.Cover,
|
||||
Version: post.Version,
|
||||
SlugSource: post.SlugSource,
|
||||
SlugLocked: post.SlugLocked,
|
||||
PublishedAt: formatFrontmatterTime(post.PublishedAt),
|
||||
CreatedAt: formatFrontmatterTime(&post.CreatedAt),
|
||||
UpdatedAt: formatFrontmatterTime(&post.UpdatedAt),
|
||||
}
|
||||
|
||||
var meta bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&meta)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(frontmatter); err != nil {
|
||||
return fmt.Errorf("encode frontmatter: %w", err)
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return fmt.Errorf("close frontmatter encoder: %w", err)
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(meta.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.BodyMarkdown, "\n"))
|
||||
if !strings.HasSuffix(output.String(), "\n") {
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return fmt.Errorf("write post %s: %w", post.Slug, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("replace post %s: %w", post.Slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatFrontmatterTime(value *time.Time) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return value.Format(time.RFC3339)
|
||||
}
|
||||
239
backend/internal/admin/markdown_import.go
Normal file
239
backend/internal/admin/markdown_import.go
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
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
|
||||
}
|
||||
128
backend/internal/admin/migrations.go
Normal file
128
backend/internal/admin/migrations.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Migration struct {
|
||||
Version string
|
||||
Path string
|
||||
Checksum string
|
||||
}
|
||||
|
||||
func RunMigrations(ctx context.Context, db *pgxpool.Pool, dir string) error {
|
||||
if db == nil {
|
||||
return errors.New("database is required")
|
||||
}
|
||||
|
||||
migrations, err := LoadMigrationFiles(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx, err := db.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration transaction: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS admin_schema_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
checksum TEXT NOT NULL,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("ensure migration table: %w", err)
|
||||
}
|
||||
|
||||
for _, migration := range migrations {
|
||||
if err := applyMigration(ctx, tx, migration); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func LoadMigrationFiles(dir string) ([]Migration, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migrations dir: %w", err)
|
||||
}
|
||||
|
||||
var migrations []Migration
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, entry.Name())
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read migration %s: %w", entry.Name(), err)
|
||||
}
|
||||
|
||||
migrations = append(migrations, Migration{
|
||||
Version: entry.Name(),
|
||||
Path: path,
|
||||
Checksum: checksum(content),
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(migrations, func(i, j int) bool {
|
||||
return migrations[i].Version < migrations[j].Version
|
||||
})
|
||||
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, tx pgx.Tx, migration Migration) error {
|
||||
var appliedChecksum string
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT checksum
|
||||
FROM admin_schema_migrations
|
||||
WHERE version = $1`, migration.Version).Scan(&appliedChecksum)
|
||||
|
||||
if err == nil {
|
||||
if appliedChecksum != migration.Checksum {
|
||||
return fmt.Errorf("migration %s checksum changed", migration.Version)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("check migration %s: %w", migration.Version, err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(migration.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", migration.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, string(content)); err != nil {
|
||||
return fmt.Errorf("apply migration %s: %w", migration.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_schema_migrations (version, checksum)
|
||||
VALUES ($1, $2)`, migration.Version, migration.Checksum); err != nil {
|
||||
return fmt.Errorf("record migration %s: %w", migration.Version, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checksum(content []byte) string {
|
||||
sum := sha256.Sum256(content)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
35
backend/internal/admin/migrations_test.go
Normal file
35
backend/internal/admin/migrations_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadMigrationFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"002_second.sql": "select 2;",
|
||||
"001_first.sql": "select 1;",
|
||||
"notes.txt": "ignored",
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write fixture: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
migrations, err := LoadMigrationFiles(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load migrations: %v", err)
|
||||
}
|
||||
if len(migrations) != 2 {
|
||||
t.Fatalf("expected 2 migrations, got %d", len(migrations))
|
||||
}
|
||||
if migrations[0].Version != "001_first.sql" || migrations[1].Version != "002_second.sql" {
|
||||
t.Fatalf("unexpected migration order: %#v", migrations)
|
||||
}
|
||||
if migrations[0].Checksum == "" || migrations[0].Checksum == migrations[1].Checksum {
|
||||
t.Fatalf("unexpected checksums: %#v", migrations)
|
||||
}
|
||||
}
|
||||
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()})
|
||||
}
|
||||
}
|
||||
31
backend/internal/admin/router_test.go
Normal file
31
backend/internal/admin/router_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHealth(t *testing.T) {
|
||||
server := NewServer(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadyWithoutDatabase(t *testing.T) {
|
||||
server := NewServer(nil)
|
||||
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
server.Router().ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code)
|
||||
}
|
||||
}
|
||||
51
backend/internal/admin/static.go
Normal file
51
backend/internal/admin/static.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed web/*
|
||||
var adminWeb embed.FS
|
||||
|
||||
func adminIndex(adminDir string) ([]byte, error) {
|
||||
if strings.TrimSpace(adminDir) != "" {
|
||||
path := filepath.Join(adminDir, "index.html")
|
||||
if page, err := os.ReadFile(path); err == nil {
|
||||
return rewriteAdminBase(page), nil
|
||||
}
|
||||
}
|
||||
return adminWeb.ReadFile("web/index.html")
|
||||
}
|
||||
|
||||
func serveAdminFile(c *gin.Context, adminDir string) bool {
|
||||
if strings.TrimSpace(adminDir) == "" {
|
||||
return false
|
||||
}
|
||||
|
||||
requested := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||
if requested == "" {
|
||||
return false
|
||||
}
|
||||
cleaned := filepath.Clean(requested)
|
||||
if cleaned == "." || strings.HasPrefix(cleaned, "..") {
|
||||
return false
|
||||
}
|
||||
|
||||
path := filepath.Join(adminDir, cleaned)
|
||||
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))
|
||||
}
|
||||
72
backend/internal/admin/status.go
Normal file
72
backend/internal/admin/status.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package admin
|
||||
|
||||
import "fmt"
|
||||
|
||||
func ValidPostStatus(status PostStatus) bool {
|
||||
switch status {
|
||||
case PostStatusDraft, PostStatusPublished, PostStatusArchived, PostStatusDeleted:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func IsPublicPostStatus(status PostStatus) bool {
|
||||
return status == PostStatusPublished || status == PostStatusArchived
|
||||
}
|
||||
|
||||
func ValidVersionReason(reason VersionReason) bool {
|
||||
switch reason {
|
||||
case VersionReasonSave, VersionReasonPublish, VersionReasonUnpublish, VersionReasonArchive, VersionReasonRestore, VersionReasonImport, VersionReasonRollback:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidBuildJobStatus(status BuildJobStatus) bool {
|
||||
switch status {
|
||||
case BuildJobStatusQueued, BuildJobStatusRunning, BuildJobStatusSuccess, BuildJobStatusFailed, BuildJobStatusCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidBuildJobTrigger(trigger BuildJobTrigger) bool {
|
||||
switch trigger {
|
||||
case BuildJobTriggerPublish, BuildJobTriggerManual, BuildJobTriggerImport, BuildJobTriggerSync:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func CanTransitionPostStatus(from PostStatus, to PostStatus) bool {
|
||||
if !ValidPostStatus(from) || !ValidPostStatus(to) {
|
||||
return false
|
||||
}
|
||||
if from == to {
|
||||
return true
|
||||
}
|
||||
|
||||
switch from {
|
||||
case PostStatusDraft:
|
||||
return to == PostStatusPublished || to == PostStatusArchived || to == PostStatusDeleted
|
||||
case PostStatusPublished:
|
||||
return to == PostStatusDraft || to == PostStatusArchived || to == PostStatusDeleted
|
||||
case PostStatusArchived:
|
||||
return to == PostStatusDraft || to == PostStatusPublished || to == PostStatusDeleted
|
||||
case PostStatusDeleted:
|
||||
return to == PostStatusDraft
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func ValidatePostStatusTransition(from PostStatus, to PostStatus) error {
|
||||
if CanTransitionPostStatus(from, to) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid post status transition: %s -> %s", from, to)
|
||||
}
|
||||
38
backend/internal/admin/status_test.go
Normal file
38
backend/internal/admin/status_test.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
package admin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanTransitionPostStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
from PostStatus
|
||||
to PostStatus
|
||||
want bool
|
||||
}{
|
||||
{name: "draft to published", from: PostStatusDraft, to: PostStatusPublished, want: true},
|
||||
{name: "published to draft", from: PostStatusPublished, to: PostStatusDraft, want: true},
|
||||
{name: "published to archived", from: PostStatusPublished, to: PostStatusArchived, want: true},
|
||||
{name: "archived to draft", from: PostStatusArchived, to: PostStatusDraft, want: true},
|
||||
{name: "deleted to draft", from: PostStatusDeleted, to: PostStatusDraft, want: true},
|
||||
{name: "deleted to published blocked", from: PostStatusDeleted, to: PostStatusPublished, want: false},
|
||||
{name: "unknown blocked", from: PostStatus("unknown"), to: PostStatusDraft, want: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got := CanTransitionPostStatus(test.from, test.to)
|
||||
if got != test.want {
|
||||
t.Fatalf("CanTransitionPostStatus(%q, %q) = %v, want %v", test.from, test.to, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePostStatusTransition(t *testing.T) {
|
||||
if err := ValidatePostStatusTransition(PostStatusDraft, PostStatusPublished); err != nil {
|
||||
t.Fatalf("valid transition returned error: %v", err)
|
||||
}
|
||||
if err := ValidatePostStatusTransition(PostStatusDeleted, PostStatusPublished); err == nil {
|
||||
t.Fatal("invalid transition returned nil error")
|
||||
}
|
||||
}
|
||||
823
backend/internal/admin/store.go
Normal file
823
backend/internal/admin/store.go
Normal file
|
|
@ -0,0 +1,823 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
type PostListOptions struct {
|
||||
Status PostStatus
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
type PostInput struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyMarkdown string `json:"bodyMarkdown"`
|
||||
Status PostStatus `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Cover string `json:"cover"`
|
||||
SlugSource string `json:"slugSource"`
|
||||
SlugLocked bool `json:"slugLocked"`
|
||||
CreatedAt *time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) *Store {
|
||||
return &Store{db: db}
|
||||
}
|
||||
|
||||
func (s *Store) ListPosts(ctx context.Context, opts PostListOptions) ([]Post, error) {
|
||||
limit := opts.Limit
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
offset := opts.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
args := []any{limit, offset}
|
||||
where := "deleted_at IS NULL"
|
||||
if opts.Status != "" {
|
||||
if !ValidPostStatus(opts.Status) {
|
||||
return nil, fmt.Errorf("invalid post status: %s", opts.Status)
|
||||
}
|
||||
args = append(args, opts.Status)
|
||||
where += fmt.Sprintf(" AND status = $%d", len(args))
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE `+where+`
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT $1 OFFSET $2`, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list posts: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []Post
|
||||
for rows.Next() {
|
||||
post, err := scanPost(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list posts rows: %w", err)
|
||||
}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (s *Store) CountPosts(ctx context.Context, status PostStatus) (int, error) {
|
||||
args := []any{}
|
||||
where := "deleted_at IS NULL"
|
||||
if status != "" {
|
||||
if !ValidPostStatus(status) {
|
||||
return 0, fmt.Errorf("invalid post status: %s", status)
|
||||
}
|
||||
args = append(args, status)
|
||||
where += " AND status = $1"
|
||||
}
|
||||
|
||||
var total int
|
||||
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM posts WHERE `+where, args...).Scan(&total); err != nil {
|
||||
return 0, fmt.Errorf("count posts: %w", err)
|
||||
}
|
||||
return total, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetPost(ctx context.Context, id string) (Post, error) {
|
||||
post, err := scanPost(s.db.QueryRow(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("get post: %w", err)
|
||||
}
|
||||
posts := []Post{post}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post = posts[0]
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *Store) UniqueSlug(ctx context.Context, base string, excludePostID string) (string, error) {
|
||||
base = sanitizeSlug(base)
|
||||
if base == "" {
|
||||
return "", errors.New("slug is empty")
|
||||
}
|
||||
for i := 0; i < 100; i++ {
|
||||
candidate := base
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", base, i+1)
|
||||
}
|
||||
exists, err := s.slugExists(ctx, candidate, excludePostID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !exists {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func (s *Store) slugExists(ctx context.Context, slug string, excludePostID string) (bool, error) {
|
||||
var exists bool
|
||||
var err error
|
||||
if strings.TrimSpace(excludePostID) != "" {
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = $1 AND id::text <> $2
|
||||
)`, slug, excludePostID).Scan(&exists)
|
||||
} else {
|
||||
err = s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE slug = $1
|
||||
)`, slug).Scan(&exists)
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check slug exists: %w", err)
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreatePost(ctx context.Context, input PostInput) (Post, error) {
|
||||
if err := validatePostInput(input, true); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("begin create post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
post, err := insertPost(ctx, tx, input)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post.Tags = normalizeTagNames(input.Tags)
|
||||
if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Post{}, fmt.Errorf("commit create post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *Store) UpdatePost(ctx context.Context, id string, input PostInput) (Post, error) {
|
||||
if err := validatePostInput(input, false); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("begin update post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var currentStatus PostStatus
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT status
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR UPDATE`, id).Scan(¤tStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, fmt.Errorf("lock post: %w", err)
|
||||
}
|
||||
if err := ValidatePostStatusTransition(currentStatus, input.Status); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post, err := updatePost(ctx, tx, id, input)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := replacePostTags(ctx, tx, post.ID, input.Tags); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post.Tags = normalizeTagNames(input.Tags)
|
||||
if err := insertPostVersion(ctx, tx, post, VersionReasonSave); err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Post{}, fmt.Errorf("commit update post: %w", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateManualBuildJob(ctx context.Context, postID string) (BuildJob, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return BuildJob{}, fmt.Errorf("begin create build job: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var exists bool
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
)`, postID).Scan(&exists); err != nil {
|
||||
return BuildJob{}, fmt.Errorf("check post for build: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
return BuildJob{}, ErrNotFound
|
||||
}
|
||||
|
||||
job, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &postID)
|
||||
if err != nil {
|
||||
return BuildJob{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return BuildJob{}, fmt.Errorf("commit create build job: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) DeletePost(ctx context.Context, id string) (*BuildJob, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin delete post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var currentStatus PostStatus
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT status
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR UPDATE`, id).Scan(¤tStatus)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lock post: %w", err)
|
||||
}
|
||||
|
||||
commandTag, err := tx.Exec(ctx, `
|
||||
UPDATE posts
|
||||
SET status = 'deleted', deleted_at = now(), updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL`, id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("delete post: %w", err)
|
||||
}
|
||||
if commandTag.RowsAffected() == 0 {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
var job *BuildJob
|
||||
if IsPublicPostStatus(currentStatus) {
|
||||
created, err := insertBuildJob(ctx, tx, BuildJobTriggerManual, &id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
job = &created
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, fmt.Errorf("commit delete post: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (s *Store) PublishPost(ctx context.Context, id string) (Post, BuildJob, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("begin publish post: %w", err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var current Post
|
||||
current, err = scanPost(tx.QueryRow(ctx, `
|
||||
SELECT id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
FOR UPDATE`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, BuildJob{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("lock post: %w", err)
|
||||
}
|
||||
if err := ValidatePostStatusTransition(current.Status, PostStatusPublished); err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
|
||||
post, err := scanPost(tx.QueryRow(ctx, `
|
||||
UPDATE posts
|
||||
SET status = 'published',
|
||||
version = version + 1,
|
||||
published_at = COALESCE(published_at, now()),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`, id))
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("publish post: %w", err)
|
||||
}
|
||||
if err := insertPostVersion(ctx, tx, post, VersionReasonPublish); err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
|
||||
job, err := insertBuildJob(ctx, tx, BuildJobTriggerPublish, &post.ID)
|
||||
if err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return Post{}, BuildJob{}, fmt.Errorf("commit publish post: %w", err)
|
||||
}
|
||||
posts := []Post{post}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return Post{}, BuildJob{}, err
|
||||
}
|
||||
post = posts[0]
|
||||
return post, job, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetBuildJob(ctx context.Context, id string) (BuildJob, error) {
|
||||
job, err := scanBuildJob(s.db.QueryRow(ctx, `
|
||||
SELECT id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by
|
||||
FROM build_jobs
|
||||
WHERE id = $1`, id))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return BuildJob{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return BuildJob{}, fmt.Errorf("get build job: %w", err)
|
||||
}
|
||||
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,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at
|
||||
FROM posts
|
||||
WHERE status IN ('published', 'archived') AND deleted_at IS NULL
|
||||
ORDER BY published_at DESC NULLS LAST, updated_at DESC`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("published posts for export: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var posts []Post
|
||||
for rows.Next() {
|
||||
post, err := scanPost(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
posts = append(posts, post)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("published posts rows: %w", err)
|
||||
}
|
||||
if err := s.attachTags(ctx, posts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBuildJobRunning(ctx context.Context, id string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE build_jobs
|
||||
SET status = 'running', started_at = now()
|
||||
WHERE id = $1 AND status = 'queued'`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark build job running: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBuildJobSuccess(ctx context.Context, id string, log string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE build_jobs
|
||||
SET status = 'success', finished_at = now(), log = $2, error = ''
|
||||
WHERE id = $1`, id, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark build job success: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) MarkBuildJobFailed(ctx context.Context, id string, log string, message string) error {
|
||||
_, err := s.db.Exec(ctx, `
|
||||
UPDATE build_jobs
|
||||
SET status = 'failed', finished_at = now(), log = $2, error = $3
|
||||
WHERE id = $1`, id, log, message)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark build job failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePostInput(input PostInput, creating bool) error {
|
||||
if strings.TrimSpace(input.Title) == "" {
|
||||
return errors.New("title is required")
|
||||
}
|
||||
if strings.TrimSpace(input.Slug) == "" {
|
||||
return errors.New("slug is required")
|
||||
}
|
||||
if input.Status == "" {
|
||||
if creating {
|
||||
input.Status = PostStatusDraft
|
||||
} else {
|
||||
return errors.New("status is required")
|
||||
}
|
||||
}
|
||||
if !ValidPostStatus(input.Status) {
|
||||
return fmt.Errorf("invalid post status: %s", input.Status)
|
||||
}
|
||||
if creating && input.Status == PostStatusDeleted {
|
||||
return errors.New("new post cannot be deleted")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) attachTags(ctx context.Context, posts []Post) error {
|
||||
if len(posts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]string, 0, len(posts))
|
||||
index := make(map[string]int, len(posts))
|
||||
for i := range posts {
|
||||
ids = append(ids, posts[i].ID)
|
||||
index[posts[i].ID] = i
|
||||
posts[i].Tags = []string{}
|
||||
}
|
||||
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT pt.post_id::text, t.name
|
||||
FROM post_tags pt
|
||||
JOIN tags t ON t.id = pt.tag_id
|
||||
WHERE pt.post_id::text = ANY($1)
|
||||
ORDER BY t.name`, ids)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load post tags: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var postID string
|
||||
var tag string
|
||||
if err := rows.Scan(&postID, &tag); err != nil {
|
||||
return fmt.Errorf("scan post tag: %w", err)
|
||||
}
|
||||
if i, ok := index[postID]; ok {
|
||||
posts[i].Tags = append(posts[i].Tags, tag)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return fmt.Errorf("post tags rows: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func insertPost(ctx context.Context, tx pgx.Tx, input PostInput) (Post, error) {
|
||||
if input.Status == "" {
|
||||
input.Status = PostStatusDraft
|
||||
}
|
||||
if input.SlugSource == "" {
|
||||
input.SlugSource = "manual"
|
||||
}
|
||||
|
||||
post, err := scanPost(tx.QueryRow(ctx, `
|
||||
INSERT INTO posts (slug, title, summary, body_markdown, status, cover, slug_source, slug_locked, published_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, CASE WHEN $5 IN ('published', 'archived') THEN now() ELSE NULL END, COALESCE($9, now()))
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
input.Slug,
|
||||
input.Title,
|
||||
input.Summary,
|
||||
input.BodyMarkdown,
|
||||
input.Status,
|
||||
input.Cover,
|
||||
input.SlugSource,
|
||||
input.SlugLocked,
|
||||
input.CreatedAt,
|
||||
))
|
||||
if err != nil {
|
||||
return Post{}, normalizeStoreError("insert post", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func updatePost(ctx context.Context, tx pgx.Tx, id string, input PostInput) (Post, error) {
|
||||
if input.SlugSource == "" {
|
||||
input.SlugSource = "manual"
|
||||
}
|
||||
|
||||
post, err := scanPost(tx.QueryRow(ctx, `
|
||||
UPDATE posts
|
||||
SET slug = $2,
|
||||
title = $3,
|
||||
summary = $4,
|
||||
body_markdown = $5,
|
||||
status = $6,
|
||||
cover = $7,
|
||||
slug_source = $8,
|
||||
slug_locked = $9,
|
||||
version = version + 1,
|
||||
published_at = CASE WHEN $6 IN ('published', 'archived') AND published_at IS NULL THEN now() ELSE published_at END,
|
||||
created_at = COALESCE($10, created_at),
|
||||
updated_at = now()
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
|
||||
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
|
||||
id,
|
||||
input.Slug,
|
||||
input.Title,
|
||||
input.Summary,
|
||||
input.BodyMarkdown,
|
||||
input.Status,
|
||||
input.Cover,
|
||||
input.SlugSource,
|
||||
input.SlugLocked,
|
||||
input.CreatedAt,
|
||||
))
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Post{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Post{}, normalizeStoreError("update post", err)
|
||||
}
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func insertPostVersion(ctx context.Context, tx pgx.Tx, post Post, reason VersionReason) error {
|
||||
_, 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)`,
|
||||
post.ID,
|
||||
post.Version,
|
||||
post.Title,
|
||||
post.Summary,
|
||||
post.BodyMarkdown,
|
||||
post.Status,
|
||||
reason,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert post version: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func replacePostTags(ctx context.Context, tx pgx.Tx, postID string, tags []string) error {
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM post_tags WHERE post_id = $1`, postID); err != nil {
|
||||
return fmt.Errorf("clear post tags: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range normalizeTagNames(tags) {
|
||||
slug := tagSlug(name)
|
||||
if slug == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
var tagID string
|
||||
if err := tx.QueryRow(ctx, `
|
||||
INSERT INTO tags (name, slug)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT (slug)
|
||||
DO UPDATE SET name = excluded.name, updated_at = now()
|
||||
RETURNING id`, name, slug).Scan(&tagID); err != nil {
|
||||
return fmt.Errorf("upsert tag %s: %w", name, err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO post_tags (post_id, tag_id)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING`, postID, tagID); err != nil {
|
||||
return fmt.Errorf("link post tag %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeTagNames(tags []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var normalized []string
|
||||
for _, tag := range tags {
|
||||
name := strings.TrimSpace(tag)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
normalized = append(normalized, name)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func tagSlug(tag string) string {
|
||||
slug := strings.ToLower(strings.TrimSpace(tag))
|
||||
slug = strings.ReplaceAll(slug, "_", "-")
|
||||
slug = strings.Join(strings.Fields(slug), "-")
|
||||
return strings.Trim(slug, "-")
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
replacer := strings.NewReplacer("_", "-", " ", "-")
|
||||
slug = replacer.Replace(slug)
|
||||
var builder strings.Builder
|
||||
lastHyphen := false
|
||||
for _, r := range slug {
|
||||
valid := (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9')
|
||||
if valid {
|
||||
builder.WriteRune(r)
|
||||
lastHyphen = false
|
||||
continue
|
||||
}
|
||||
if r == '-' && !lastHyphen {
|
||||
builder.WriteRune('-')
|
||||
lastHyphen = true
|
||||
}
|
||||
}
|
||||
cleaned := strings.Trim(builder.String(), "-")
|
||||
if len(cleaned) > 80 {
|
||||
cleaned = strings.Trim(cleaned[:80], "-")
|
||||
}
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func insertBuildJob(ctx context.Context, tx pgx.Tx, trigger BuildJobTrigger, postID *string) (BuildJob, error) {
|
||||
job, err := scanBuildJob(tx.QueryRow(ctx, `
|
||||
INSERT INTO build_jobs (trigger, status, post_id)
|
||||
VALUES ($1, 'queued', $2)
|
||||
RETURNING id, trigger, status, post_id, started_at, finished_at, log, error, created_at, created_by`,
|
||||
trigger,
|
||||
postID,
|
||||
))
|
||||
if err != nil {
|
||||
return BuildJob{}, fmt.Errorf("insert build job: %w", err)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
type postScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanPost(row postScanner) (Post, error) {
|
||||
var post Post
|
||||
var publishedAt sql.NullTime
|
||||
var deletedAt sql.NullTime
|
||||
err := row.Scan(
|
||||
&post.ID,
|
||||
&post.Slug,
|
||||
&post.Title,
|
||||
&post.Summary,
|
||||
&post.BodyMarkdown,
|
||||
&post.Status,
|
||||
&post.Cover,
|
||||
&post.Version,
|
||||
&post.SlugSource,
|
||||
&post.SlugLocked,
|
||||
&publishedAt,
|
||||
&post.CreatedAt,
|
||||
&post.UpdatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Post{}, err
|
||||
}
|
||||
post.PublishedAt = nullTimePtr(publishedAt)
|
||||
post.DeletedAt = nullTimePtr(deletedAt)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
func scanBuildJob(row postScanner) (BuildJob, error) {
|
||||
var job BuildJob
|
||||
var postID sql.NullString
|
||||
var startedAt sql.NullTime
|
||||
var finishedAt sql.NullTime
|
||||
var createdBy sql.NullString
|
||||
err := row.Scan(
|
||||
&job.ID,
|
||||
&job.Trigger,
|
||||
&job.Status,
|
||||
&postID,
|
||||
&startedAt,
|
||||
&finishedAt,
|
||||
&job.Log,
|
||||
&job.Error,
|
||||
&job.CreatedAt,
|
||||
&createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return BuildJob{}, err
|
||||
}
|
||||
job.PostID = nullStringPtr(postID)
|
||||
job.StartedAt = nullTimePtr(startedAt)
|
||||
job.FinishedAt = nullTimePtr(finishedAt)
|
||||
job.CreatedBy = nullStringPtr(createdBy)
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func scanAsset(row postScanner) (Asset, error) {
|
||||
var asset Asset
|
||||
var createdBy sql.NullString
|
||||
err := row.Scan(
|
||||
&asset.ID,
|
||||
&asset.Path,
|
||||
&asset.OriginalName,
|
||||
&asset.MimeType,
|
||||
&asset.SizeBytes,
|
||||
&asset.SHA256,
|
||||
&asset.CreatedAt,
|
||||
&createdBy,
|
||||
)
|
||||
if err != nil {
|
||||
return Asset{}, err
|
||||
}
|
||||
asset.CreatedBy = nullStringPtr(createdBy)
|
||||
return asset, nil
|
||||
}
|
||||
|
||||
func nullTimePtr(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.Time
|
||||
}
|
||||
|
||||
func nullStringPtr(value sql.NullString) *string {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
return &value.String
|
||||
}
|
||||
|
||||
func normalizeStoreError(action string, err error) error {
|
||||
var pgErr *pgconn.PgError
|
||||
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||
return fmt.Errorf("%s: slug already exists", action)
|
||||
}
|
||||
return fmt.Errorf("%s: %w", action, err)
|
||||
}
|
||||
101
backend/internal/admin/types.go
Normal file
101
backend/internal/admin/types.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package admin
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"`
|
||||
}
|
||||
|
||||
type PostStatus string
|
||||
|
||||
const (
|
||||
PostStatusDraft PostStatus = "draft"
|
||||
PostStatusPublished PostStatus = "published"
|
||||
PostStatusArchived PostStatus = "archived"
|
||||
PostStatusDeleted PostStatus = "deleted"
|
||||
)
|
||||
|
||||
type VersionReason string
|
||||
|
||||
const (
|
||||
VersionReasonSave VersionReason = "save"
|
||||
VersionReasonPublish VersionReason = "publish"
|
||||
VersionReasonUnpublish VersionReason = "unpublish"
|
||||
VersionReasonArchive VersionReason = "archive"
|
||||
VersionReasonRestore VersionReason = "restore"
|
||||
VersionReasonImport VersionReason = "import"
|
||||
VersionReasonRollback VersionReason = "rollback"
|
||||
)
|
||||
|
||||
type BuildJobTrigger string
|
||||
|
||||
const (
|
||||
BuildJobTriggerPublish BuildJobTrigger = "publish"
|
||||
BuildJobTriggerManual BuildJobTrigger = "manual"
|
||||
BuildJobTriggerImport BuildJobTrigger = "import"
|
||||
BuildJobTriggerSync BuildJobTrigger = "sync"
|
||||
)
|
||||
|
||||
type BuildJobStatus string
|
||||
|
||||
const (
|
||||
BuildJobStatusQueued BuildJobStatus = "queued"
|
||||
BuildJobStatusRunning BuildJobStatus = "running"
|
||||
BuildJobStatusSuccess BuildJobStatus = "success"
|
||||
BuildJobStatusFailed BuildJobStatus = "failed"
|
||||
BuildJobStatusCancelled BuildJobStatus = "cancelled"
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Summary string `json:"summary"`
|
||||
BodyMarkdown string `json:"bodyMarkdown"`
|
||||
Status PostStatus `json:"status"`
|
||||
Tags []string `json:"tags"`
|
||||
Cover string `json:"cover"`
|
||||
Version int `json:"version"`
|
||||
SlugSource string `json:"slugSource"`
|
||||
SlugLocked bool `json:"slugLocked"`
|
||||
PublishedAt *time.Time `json:"publishedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt *time.Time `json:"deletedAt"`
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
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"`
|
||||
Status BuildJobStatus `json:"status"`
|
||||
PostID *string `json:"postId"`
|
||||
StartedAt *time.Time `json:"startedAt"`
|
||||
FinishedAt *time.Time `json:"finishedAt"`
|
||||
Log string `json:"log"`
|
||||
Error string `json:"error"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
CreatedBy *string `json:"createdBy"`
|
||||
}
|
||||
260
backend/internal/admin/web/assets/admin.css
Normal file
260
backend/internal/admin/web/assets/admin.css
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
:root {
|
||||
color: #232428;
|
||||
background: #f7f6f2;
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans CJK SC",
|
||||
"Source Han Sans SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: #f7f6f2;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
border: 0;
|
||||
border-radius: 0.65em;
|
||||
padding: 0.72em 1.15em;
|
||||
background: #243b53;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #1c3147;
|
||||
}
|
||||
|
||||
button.ghost {
|
||||
background: #fff;
|
||||
color: #243b53;
|
||||
box-shadow: 0 0 0.2em rgb(29 53 87 / 13%);
|
||||
}
|
||||
|
||||
button.publish {
|
||||
background: #7b4f27;
|
||||
}
|
||||
|
||||
button.publish:hover {
|
||||
background: #643f1f;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e1ded7;
|
||||
border-radius: 0.7em;
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
padding: 0.8em 0.95em;
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
border-color: #9aa9b6;
|
||||
box-shadow: 0 0 0 0.2em rgb(36 59 83 / 10%);
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 0.5em;
|
||||
color: #55575d;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(92vw, 1180px);
|
||||
margin: 0 auto;
|
||||
padding: 5vh 0;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
min-height: 90vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.login-panel {
|
||||
width: min(92vw, 28em);
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
padding: 2em;
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
box-shadow: 0 1em 3em rgb(29 53 87 / 10%);
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 2em;
|
||||
margin-bottom: 1.8em;
|
||||
}
|
||||
|
||||
.topbar h1,
|
||||
.editor-head h2,
|
||||
.panel-heading h2,
|
||||
.login-panel h1 {
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.35em;
|
||||
color: #8b8175;
|
||||
font-size: 0.78em;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.7em;
|
||||
}
|
||||
|
||||
.user-badge {
|
||||
color: #6d7179;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18em, 0.9fr) minmax(0, 2.1fr);
|
||||
gap: 1.4em;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.post-list-panel,
|
||||
.editor-panel {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
.panel-heading select {
|
||||
max-width: 9em;
|
||||
}
|
||||
|
||||
.post-list {
|
||||
display: grid;
|
||||
gap: 0.75em;
|
||||
}
|
||||
|
||||
.post-item {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 0.45em;
|
||||
text-align: left;
|
||||
border-radius: 0.8em;
|
||||
padding: 1em;
|
||||
background: #fff;
|
||||
color: #232428;
|
||||
box-shadow: 0 0 0.2em rgb(29 53 87 / 10%);
|
||||
}
|
||||
|
||||
.post-item:hover,
|
||||
.post-item.active {
|
||||
background: #fbfaf7;
|
||||
box-shadow: 0 0.45em 1.4em rgb(29 53 87 / 11%);
|
||||
}
|
||||
|
||||
.post-item-title {
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.post-item-meta {
|
||||
color: #777b82;
|
||||
font-size: 0.82em;
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
border-radius: 1em;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0.2em rgb(29 53 87 / 10%);
|
||||
}
|
||||
|
||||
.editor-form {
|
||||
display: grid;
|
||||
gap: 1.2em;
|
||||
padding: 1.4em;
|
||||
}
|
||||
|
||||
.editor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.fields-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.wide-field {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.body-field textarea {
|
||||
min-height: 42vh;
|
||||
font-family:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: #7b4f27;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.topbar,
|
||||
.editor-head {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.workspace,
|
||||
.fields-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.editor-actions {
|
||||
width: 100%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
321
backend/internal/admin/web/assets/admin.js
Normal file
321
backend/internal/admin/web/assets/admin.js
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
const state = {
|
||||
user: null,
|
||||
posts: [],
|
||||
currentPost: null,
|
||||
};
|
||||
|
||||
const el = {
|
||||
loginView: document.querySelector("#loginView"),
|
||||
appView: document.querySelector("#appView"),
|
||||
loginForm: document.querySelector("#loginForm"),
|
||||
loginUsername: document.querySelector("#loginUsername"),
|
||||
loginPassword: document.querySelector("#loginPassword"),
|
||||
loginMessage: document.querySelector("#loginMessage"),
|
||||
userBadge: document.querySelector("#userBadge"),
|
||||
logoutButton: document.querySelector("#logoutButton"),
|
||||
newPostButton: document.querySelector("#newPostButton"),
|
||||
statusFilter: document.querySelector("#statusFilter"),
|
||||
postList: document.querySelector("#postList"),
|
||||
postForm: document.querySelector("#postForm"),
|
||||
editorMode: document.querySelector("#editorMode"),
|
||||
editorTitle: document.querySelector("#editorTitle"),
|
||||
titleInput: document.querySelector("#titleInput"),
|
||||
slugInput: document.querySelector("#slugInput"),
|
||||
statusInput: document.querySelector("#statusInput"),
|
||||
coverInput: document.querySelector("#coverInput"),
|
||||
tagsInput: document.querySelector("#tagsInput"),
|
||||
summaryInput: document.querySelector("#summaryInput"),
|
||||
bodyInput: document.querySelector("#bodyInput"),
|
||||
publishButton: document.querySelector("#publishButton"),
|
||||
editorMessage: document.querySelector("#editorMessage"),
|
||||
};
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(`/api/admin${path}`, {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers ?? {}),
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
const data = text ? JSON.parse(text) : {};
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error ?? `HTTP ${response.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
el.loginView.hidden = false;
|
||||
el.appView.hidden = true;
|
||||
el.loginPassword.focus();
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
el.loginView.hidden = true;
|
||||
el.appView.hidden = false;
|
||||
el.userBadge.textContent = state.user?.username ?? "";
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
try {
|
||||
const data = await api("/me");
|
||||
state.user = data.user;
|
||||
showApp();
|
||||
await loadPosts();
|
||||
resetEditor();
|
||||
} catch {
|
||||
showLogin();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPosts() {
|
||||
const status = el.statusFilter.value;
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
const data = await api(`/posts${query}`);
|
||||
state.posts = data.posts ?? [];
|
||||
renderPostList();
|
||||
}
|
||||
|
||||
function renderPostList() {
|
||||
el.postList.innerHTML = "";
|
||||
|
||||
if (state.posts.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "message";
|
||||
empty.textContent = "暂无文章";
|
||||
el.postList.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const post of state.posts) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "post-item";
|
||||
if (state.currentPost?.id === post.id) {
|
||||
button.classList.add("active");
|
||||
}
|
||||
button.innerHTML = `
|
||||
<span class="post-item-title"></span>
|
||||
<span class="post-item-meta"></span>
|
||||
`;
|
||||
button.querySelector(".post-item-title").textContent = post.title || "未命名";
|
||||
button.querySelector(".post-item-meta").textContent = `${statusText(post.status)} / ${formatDate(post.updatedAt)}`;
|
||||
button.addEventListener("click", () => selectPost(post.id));
|
||||
el.postList.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectPost(id) {
|
||||
const data = await api(`/posts/${id}`);
|
||||
state.currentPost = data.post;
|
||||
fillEditor(data.post);
|
||||
renderPostList();
|
||||
}
|
||||
|
||||
function resetEditor() {
|
||||
state.currentPost = null;
|
||||
el.editorMode.textContent = "新文章";
|
||||
el.editorTitle.textContent = "开始写作";
|
||||
el.postForm.reset();
|
||||
el.statusInput.value = "draft";
|
||||
el.editorMessage.textContent = "";
|
||||
renderPostList();
|
||||
}
|
||||
|
||||
function fillEditor(post) {
|
||||
el.editorMode.textContent = `版本 ${post.version}`;
|
||||
el.editorTitle.textContent = post.title || "未命名";
|
||||
el.titleInput.value = post.title ?? "";
|
||||
el.slugInput.value = post.slug ?? "";
|
||||
el.statusInput.value = post.status ?? "draft";
|
||||
el.coverInput.value = post.cover ?? "";
|
||||
el.tagsInput.value = (post.tags ?? []).join(", ");
|
||||
el.summaryInput.value = post.summary ?? "";
|
||||
el.bodyInput.value = post.bodyMarkdown ?? "";
|
||||
el.editorMessage.textContent = "";
|
||||
}
|
||||
|
||||
function readPostInput() {
|
||||
return {
|
||||
title: el.titleInput.value.trim(),
|
||||
slug: el.slugInput.value.trim(),
|
||||
status: el.statusInput.value,
|
||||
cover: el.coverInput.value.trim(),
|
||||
tags: parseTags(el.tagsInput.value),
|
||||
summary: el.summaryInput.value.trim(),
|
||||
bodyMarkdown: el.bodyInput.value,
|
||||
slugSource: "manual",
|
||||
slugLocked: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function savePost() {
|
||||
const input = readPostInput();
|
||||
const path = state.currentPost ? `/posts/${state.currentPost.id}` : "/posts";
|
||||
const method = state.currentPost ? "PUT" : "POST";
|
||||
const data = await api(path, {
|
||||
method,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
state.currentPost = data.post;
|
||||
fillEditor(data.post);
|
||||
await loadPosts();
|
||||
el.editorMessage.textContent = "已保存";
|
||||
}
|
||||
|
||||
async function publishPost() {
|
||||
if (!state.currentPost) {
|
||||
await savePost();
|
||||
}
|
||||
|
||||
const data = await api(`/posts/${state.currentPost.id}/publish`, { method: "POST" });
|
||||
state.currentPost = data.post;
|
||||
fillEditor(data.post);
|
||||
await loadPosts();
|
||||
el.editorMessage.textContent = "已开始构建";
|
||||
|
||||
if (data.buildJob?.id) {
|
||||
pollBuildJob(data.buildJob.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function pollBuildJob(id) {
|
||||
for (;;) {
|
||||
await wait(1400);
|
||||
const data = await api(`/build-jobs/${id}`);
|
||||
const job = data.buildJob;
|
||||
el.editorMessage.textContent = `构建状态:${buildStatusText(job.status)}`;
|
||||
if (["success", "failed", "cancelled"].includes(job.status)) {
|
||||
if (job.error) {
|
||||
el.editorMessage.textContent = `构建失败:${job.error}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wait(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function statusText(status) {
|
||||
return {
|
||||
draft: "草稿",
|
||||
published: "已发布",
|
||||
archived: "归档",
|
||||
deleted: "已删除",
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function buildStatusText(status) {
|
||||
return {
|
||||
queued: "等待中",
|
||||
running: "构建中",
|
||||
success: "成功",
|
||||
failed: "失败",
|
||||
cancelled: "已取消",
|
||||
}[status] ?? status;
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return "无时间";
|
||||
}
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function slugify(value) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, "-")
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5-]+/g, "")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function parseTags(value) {
|
||||
const seen = new Set();
|
||||
return value
|
||||
.split(/[,,]/)
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => {
|
||||
const key = tag.toLowerCase();
|
||||
if (!tag || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
el.loginForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
el.loginMessage.textContent = "";
|
||||
|
||||
try {
|
||||
const data = await api("/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username: el.loginUsername.value.trim(),
|
||||
password: el.loginPassword.value,
|
||||
}),
|
||||
});
|
||||
state.user = data.user;
|
||||
showApp();
|
||||
await loadPosts();
|
||||
resetEditor();
|
||||
} catch (error) {
|
||||
el.loginMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.logoutButton.addEventListener("click", async () => {
|
||||
await api("/logout", { method: "POST" });
|
||||
state.user = null;
|
||||
state.posts = [];
|
||||
state.currentPost = null;
|
||||
showLogin();
|
||||
});
|
||||
|
||||
el.newPostButton.addEventListener("click", resetEditor);
|
||||
el.statusFilter.addEventListener("change", loadPosts);
|
||||
|
||||
el.titleInput.addEventListener("input", () => {
|
||||
if (!state.currentPost && !el.slugInput.value.trim()) {
|
||||
el.slugInput.value = slugify(el.titleInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
el.postForm.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await savePost();
|
||||
} catch (error) {
|
||||
el.editorMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
el.publishButton.addEventListener("click", async () => {
|
||||
try {
|
||||
await publishPost();
|
||||
} catch (error) {
|
||||
el.editorMessage.textContent = error.message;
|
||||
}
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
115
backend/internal/admin/web/index.html
Normal file
115
backend/internal/admin/web/index.html
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Osaet Admin</title>
|
||||
<link rel="stylesheet" href="/admin/assets/admin.css" />
|
||||
</head>
|
||||
<body>
|
||||
<main class="shell">
|
||||
<section id="loginView" class="login-view" hidden>
|
||||
<form id="loginForm" class="login-panel">
|
||||
<div>
|
||||
<p class="eyebrow">Osaet Admin</p>
|
||||
<h1>登录后台</h1>
|
||||
</div>
|
||||
<label>
|
||||
用户名
|
||||
<input id="loginUsername" autocomplete="username" value="yarnom" />
|
||||
</label>
|
||||
<label>
|
||||
密码
|
||||
<input id="loginPassword" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<button type="submit">登录</button>
|
||||
<p id="loginMessage" class="message" role="status"></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section id="appView" hidden>
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Osaet Admin</p>
|
||||
<h1>文章管理</h1>
|
||||
</div>
|
||||
<div class="topbar-actions">
|
||||
<span id="userBadge" class="user-badge"></span>
|
||||
<button id="newPostButton" type="button">新文章</button>
|
||||
<button id="logoutButton" type="button" class="ghost">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="workspace">
|
||||
<aside class="post-list-panel">
|
||||
<div class="panel-heading">
|
||||
<h2>文章</h2>
|
||||
<select id="statusFilter" aria-label="文章状态">
|
||||
<option value="">全部</option>
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="postList" class="post-list"></div>
|
||||
</aside>
|
||||
|
||||
<section class="editor-panel">
|
||||
<form id="postForm" class="editor-form">
|
||||
<div class="editor-head">
|
||||
<div>
|
||||
<p id="editorMode" class="eyebrow">新文章</p>
|
||||
<h2 id="editorTitle">开始写作</h2>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="saveButton" type="submit">保存</button>
|
||||
<button id="publishButton" type="button" class="publish">发布</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fields-grid">
|
||||
<label>
|
||||
标题
|
||||
<input id="titleInput" required />
|
||||
</label>
|
||||
<label>
|
||||
Slug
|
||||
<input id="slugInput" required />
|
||||
</label>
|
||||
<label>
|
||||
状态
|
||||
<select id="statusInput">
|
||||
<option value="draft">草稿</option>
|
||||
<option value="published">已发布</option>
|
||||
<option value="archived">归档</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
封面
|
||||
<input id="coverInput" />
|
||||
</label>
|
||||
<label class="wide-field">
|
||||
标签
|
||||
<input id="tagsInput" placeholder="用逗号分隔,例如:生活, 技术" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
摘要
|
||||
<textarea id="summaryInput" rows="3"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="body-field">
|
||||
正文 Markdown
|
||||
<textarea id="bodyInput" spellcheck="false"></textarea>
|
||||
</label>
|
||||
|
||||
<p id="editorMessage" class="message" role="status"></p>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<script src="/admin/assets/admin.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -24,6 +24,14 @@ type Config struct {
|
|||
Model string
|
||||
}
|
||||
|
||||
type LocalConfig struct {
|
||||
URL string
|
||||
Model string
|
||||
Temperature float64
|
||||
TopP float64
|
||||
NumPredict int
|
||||
}
|
||||
|
||||
type deepSeekMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
|
|
@ -58,6 +66,24 @@ type slugResponse struct {
|
|||
Alternatives []string `json:"alternatives"`
|
||||
}
|
||||
|
||||
type localGenerateRequest struct {
|
||||
Model string `json:"model"`
|
||||
Stream bool `json:"stream"`
|
||||
Options localOptions `json:"options"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
type localOptions struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
TopP float64 `json:"top_p"`
|
||||
NumPredict int `json:"num_predict"`
|
||||
}
|
||||
|
||||
type localGenerateResponse struct {
|
||||
Response string `json:"response"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
func GenerateSlug(ctx context.Context, config Config, title string, summary string) (string, error) {
|
||||
apiKey := strings.TrimSpace(config.APIKey)
|
||||
if apiKey == "" {
|
||||
|
|
@ -168,6 +194,85 @@ JSON format: {"slug":"example-slug","alternatives":["another-slug"]}`,
|
|||
return slug, nil
|
||||
}
|
||||
|
||||
func GenerateLocalSlug(ctx context.Context, config LocalConfig, title string, summary string) (string, error) {
|
||||
url := strings.TrimSpace(config.URL)
|
||||
if url == "" {
|
||||
return "", errors.New("local LLM URL is empty")
|
||||
}
|
||||
model := strings.TrimSpace(config.Model)
|
||||
if model == "" {
|
||||
return "", errors.New("local LLM model is empty")
|
||||
}
|
||||
temperature := config.Temperature
|
||||
if temperature == 0 {
|
||||
temperature = 0.1
|
||||
}
|
||||
topP := config.TopP
|
||||
if topP == 0 {
|
||||
topP = 0.8
|
||||
}
|
||||
numPredict := config.NumPredict
|
||||
if numPredict == 0 {
|
||||
numPredict = 32
|
||||
}
|
||||
|
||||
prompt := fmt.Sprintf(`Convert the following Chinese blog title into a concise English URL slug.
|
||||
Output only the slug. Lowercase only. Use hyphens. Max 8 words.
|
||||
Title: %s`, title)
|
||||
if strings.TrimSpace(summary) != "" {
|
||||
prompt += "\nSummary: " + summary
|
||||
}
|
||||
|
||||
payload := localGenerateRequest{
|
||||
Model: model,
|
||||
Stream: false,
|
||||
Options: localOptions{
|
||||
Temperature: temperature,
|
||||
TopP: topP,
|
||||
NumPredict: numPredict,
|
||||
},
|
||||
Prompt: prompt,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("local LLM returned %s: %s", resp.Status, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
|
||||
var generated localGenerateResponse
|
||||
if err := json.Unmarshal(respBody, &generated); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if generated.Error != "" {
|
||||
return "", fmt.Errorf("local LLM error: %s", generated.Error)
|
||||
}
|
||||
slug := sanitizeSlug(generated.Response)
|
||||
if slug == "" {
|
||||
return "", errors.New("local LLM returned an empty slug")
|
||||
}
|
||||
return slug, nil
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
|
|
|
|||
|
|
@ -95,10 +95,7 @@ Usage:
|
|||
osaetctl tags list [--all]
|
||||
osaetctl db init [--path .osaet/osaet.db]
|
||||
osaetctl db status [--path .osaet/osaet.db]
|
||||
osaetctl config import [--db .osaet/osaet.db]
|
||||
osaetctl config export [--db .osaet/osaet.db] [--overwrite]
|
||||
osaetctl config diff [--db .osaet/osaet.db]
|
||||
osaetctl config sync [--from file|db|auto] [--yes] [--db .osaet/osaet.db]
|
||||
osaetctl config
|
||||
osaetctl dev [--host 127.0.0.1] [--port 4321]
|
||||
osaetctl build
|
||||
osaetctl serve [--host 127.0.0.1] [--port 4321] [--dir dist/site]
|
||||
|
|
|
|||
|
|
@ -12,20 +12,9 @@ import (
|
|||
|
||||
func runConfig(root string, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errors.New("missing config subcommand")
|
||||
}
|
||||
switch args[0] {
|
||||
case "import":
|
||||
return runConfigImport(root, args[1:])
|
||||
case "export":
|
||||
return runConfigExport(root, args[1:])
|
||||
case "diff":
|
||||
return runConfigDiff(root, args[1:])
|
||||
case "sync":
|
||||
return runConfigSync(root, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown config subcommand %q", args[0])
|
||||
return errors.New("site config is local-file-only; edit config/site.yaml directly")
|
||||
}
|
||||
return fmt.Errorf("site config is local-file-only; edit config/site.yaml directly (unsupported subcommand %q)", args[0])
|
||||
}
|
||||
|
||||
func loadLocalConfig(root string) (localConfig, error) {
|
||||
|
|
|
|||
|
|
@ -1,25 +1,19 @@
|
|||
package cli
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestChangedConfigFields(t *testing.T) {
|
||||
var a siteConfigFile
|
||||
a.Meta.ConfigVersion = 1
|
||||
a.Meta.UpdatedAt = "2026-05-28T12:00:00+08:00"
|
||||
a.Site.Title = "A"
|
||||
a.Content.PostsDir = "content/posts"
|
||||
a.Build.OutputDir = "dist/site"
|
||||
|
||||
b := a
|
||||
if fields := changedConfigFields(a, b); len(fields) != 0 {
|
||||
t.Fatalf("unchanged config fields = %#v", fields)
|
||||
func TestRunConfigReportsLocalOnly(t *testing.T) {
|
||||
err := runConfig(t.TempDir(), nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "local-file-only") {
|
||||
t.Fatalf("expected local-file-only error, got %v", err)
|
||||
}
|
||||
|
||||
b.Site.Title = "B"
|
||||
b.Build.OutputDir = "dist/other"
|
||||
fields := changedConfigFields(a, b)
|
||||
if len(fields) != 2 || fields[0] != "site.title" || fields[1] != "build.output_dir" {
|
||||
t.Fatalf("changed fields = %#v", fields)
|
||||
err = runConfig(t.TempDir(), []string{"sync"})
|
||||
if err == nil || !strings.Contains(err.Error(), "unsupported subcommand \"sync\"") {
|
||||
t.Fatalf("expected unsupported subcommand error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,122 +9,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func runConfigImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, dbPath, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
config, err := readSiteConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("imported config/site.yaml into %s\n", mustRel(root, dbPath))
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigExport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config export", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
overwrite := fs.Bool("overwrite", false, "overwrite config/site.yaml")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
config, ok, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("settings.site not found; run `osaetctl config import` first")
|
||||
}
|
||||
|
||||
path := filepath.Join(root, "config/site.yaml")
|
||||
if _, err := os.Stat(path); err == nil && !*overwrite {
|
||||
return errors.New("config/site.yaml exists; pass --overwrite to replace it")
|
||||
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeSiteConfig(root, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("exported settings.site into config/site.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
func runConfigDiff(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config diff", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
return printConfigDiff(root, db)
|
||||
}
|
||||
|
||||
func runConfigSync(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("config sync", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
|
||||
from := fs.String("from", "", "sync source: file, db, or auto")
|
||||
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
db, _, err := openProjectSQLite(root, *dbPathFlag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch *from {
|
||||
case "file", "files":
|
||||
return syncConfigFromFile(root, db)
|
||||
case "db":
|
||||
return syncConfigFromDB(root, db)
|
||||
case "auto":
|
||||
return syncConfigAuto(root, db)
|
||||
case "":
|
||||
if err := printConfigDiff(root, db); err != nil {
|
||||
return err
|
||||
}
|
||||
if !*yes && !confirm("Auto Sync config by newer updated_at?") {
|
||||
fmt.Println("No changes applied.")
|
||||
fmt.Println("Use `osaetctl config sync --from file` to write config/site.yaml into SQLite.")
|
||||
fmt.Println("Use `osaetctl config sync --from db` to write SQLite settings into config/site.yaml.")
|
||||
return nil
|
||||
}
|
||||
return syncConfigAuto(root, db)
|
||||
default:
|
||||
return errors.New("--from must be file, db, or auto")
|
||||
}
|
||||
}
|
||||
|
||||
func runPostsImport(root string, args []string) error {
|
||||
fs := flag.NewFlagSet("posts import", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
|
|
@ -445,138 +331,6 @@ func syncAuto(root string, db *sql.DB) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func upsertSetting(db *sql.DB, key string, version int, updatedAt string, value any) error {
|
||||
if version == 0 {
|
||||
version = 1
|
||||
}
|
||||
if strings.TrimSpace(updatedAt) == "" {
|
||||
updatedAt = time.Now().Format(time.RFC3339)
|
||||
}
|
||||
valueJSON, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`INSERT INTO settings (key, value_json, version, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value_json = excluded.value_json,
|
||||
version = excluded.version,
|
||||
updated_at = excluded.updated_at`,
|
||||
key, string(valueJSON), version, updatedAt)
|
||||
return err
|
||||
}
|
||||
|
||||
func loadSiteSetting(db *sql.DB) (siteConfigFile, bool, error) {
|
||||
var config siteConfigFile
|
||||
var valueJSON string
|
||||
err := db.QueryRow(`SELECT value_json FROM settings WHERE key = 'site'`).Scan(&valueJSON)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return config, false, nil
|
||||
}
|
||||
return config, false, err
|
||||
}
|
||||
if err := json.Unmarshal([]byte(valueJSON), &config); err != nil {
|
||||
return config, false, err
|
||||
}
|
||||
return config, true, nil
|
||||
}
|
||||
|
||||
func printConfigDiff(root string, db *sql.DB) error {
|
||||
fileConfig, fileErr := readSiteConfig(root)
|
||||
dbConfig, dbOK, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
||||
return fileErr
|
||||
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
||||
fmt.Println("summary: file=no db=no")
|
||||
case errors.Is(fileErr, os.ErrNotExist):
|
||||
fmt.Println("only-db settings.site")
|
||||
fmt.Println("summary: file=no db=yes")
|
||||
case !dbOK:
|
||||
fmt.Println("only-file config/site.yaml")
|
||||
fmt.Println("summary: file=yes db=no")
|
||||
default:
|
||||
fields := changedConfigFields(fileConfig, dbConfig)
|
||||
if len(fields) == 0 {
|
||||
fmt.Println("same config/site.yaml <-> settings.site")
|
||||
fmt.Println("summary: same=1 changed=0")
|
||||
} else {
|
||||
fmt.Println("changed config/site.yaml <-> settings.site")
|
||||
for _, field := range fields {
|
||||
fmt.Printf(" - %s\n", field)
|
||||
}
|
||||
fmt.Println("summary: same=0 changed=1")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigFromFile(root string, db *sql.DB) error {
|
||||
config, err := readSiteConfig(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := upsertSetting(db, "site", config.Meta.ConfigVersion, config.Meta.UpdatedAt, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("synced config/site.yaml into settings.site")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigFromDB(root string, db *sql.DB) error {
|
||||
config, ok, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return errors.New("settings.site not found; run `osaetctl config import` first")
|
||||
}
|
||||
if err := writeSiteConfig(root, config); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("synced settings.site into config/site.yaml")
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigAuto(root string, db *sql.DB) error {
|
||||
fileConfig, fileErr := readSiteConfig(root)
|
||||
dbConfig, dbOK, err := loadSiteSetting(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case fileErr != nil && !errors.Is(fileErr, os.ErrNotExist):
|
||||
return fileErr
|
||||
case errors.Is(fileErr, os.ErrNotExist) && !dbOK:
|
||||
fmt.Println("nothing to sync")
|
||||
case errors.Is(fileErr, os.ErrNotExist):
|
||||
return syncConfigFromDB(root, db)
|
||||
case !dbOK:
|
||||
return syncConfigFromFile(root, db)
|
||||
default:
|
||||
if len(changedConfigFields(fileConfig, dbConfig)) == 0 {
|
||||
fmt.Println("config already in sync")
|
||||
return nil
|
||||
}
|
||||
fileTime, fileOK := parseTime(fileConfig.Meta.UpdatedAt)
|
||||
dbTime, dbTimeOK := parseTime(dbConfig.Meta.UpdatedAt)
|
||||
if !fileOK && !dbTimeOK {
|
||||
return errors.New("cannot auto sync config: both updated_at values are invalid")
|
||||
}
|
||||
if fileOK && (!dbTimeOK || fileTime.After(dbTime)) {
|
||||
return syncConfigFromFile(root, db)
|
||||
}
|
||||
return syncConfigFromDB(root, db)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func upsertSQLitePost(root string, db *sql.DB, post postFile) error {
|
||||
tagsJSON, err := json.Marshal(post.Frontmatter.Tags)
|
||||
if err != nil {
|
||||
|
|
@ -802,44 +556,3 @@ func changedPostFields(root string, filePost postFile, dbPost postFile) []string
|
|||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func changedConfigFields(fileConfig siteConfigFile, dbConfig siteConfigFile) []string {
|
||||
var fields []string
|
||||
if fileConfig.Meta.ConfigVersion != dbConfig.Meta.ConfigVersion {
|
||||
fields = append(fields, "meta.config_version")
|
||||
}
|
||||
if fileConfig.Meta.UpdatedAt != dbConfig.Meta.UpdatedAt {
|
||||
fields = append(fields, "meta.updated_at")
|
||||
}
|
||||
if fileConfig.Meta.UpdatedBy != dbConfig.Meta.UpdatedBy {
|
||||
fields = append(fields, "meta.updated_by")
|
||||
}
|
||||
if fileConfig.Site.Title != dbConfig.Site.Title {
|
||||
fields = append(fields, "site.title")
|
||||
}
|
||||
if fileConfig.Site.Description != dbConfig.Site.Description {
|
||||
fields = append(fields, "site.description")
|
||||
}
|
||||
if fileConfig.Site.BaseURL != dbConfig.Site.BaseURL {
|
||||
fields = append(fields, "site.base_url")
|
||||
}
|
||||
if fileConfig.Site.Language != dbConfig.Site.Language {
|
||||
fields = append(fields, "site.language")
|
||||
}
|
||||
if fileConfig.Site.Timezone != dbConfig.Site.Timezone {
|
||||
fields = append(fields, "site.timezone")
|
||||
}
|
||||
if fileConfig.Content.PostsDir != dbConfig.Content.PostsDir {
|
||||
fields = append(fields, "content.posts_dir")
|
||||
}
|
||||
if fileConfig.Content.AssetsDir != dbConfig.Content.AssetsDir {
|
||||
fields = append(fields, "content.assets_dir")
|
||||
}
|
||||
if fileConfig.Build.AstroProject != dbConfig.Build.AstroProject {
|
||||
fields = append(fields, "build.astro_project")
|
||||
}
|
||||
if fileConfig.Build.OutputDir != dbConfig.Build.OutputDir {
|
||||
fields = append(fields, "build.output_dir")
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
|
|
|||
460
backend/internal/postimport/import.go
Normal file
460
backend/internal/postimport/import.go
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultPostsDir = "content/posts"
|
||||
|
||||
type Options struct {
|
||||
CSVPath string
|
||||
PostsDir string
|
||||
Overwrite bool
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Imported int
|
||||
SkippedExisting int
|
||||
SkippedNonPost int
|
||||
}
|
||||
|
||||
type PostFile struct {
|
||||
Path string
|
||||
Frontmatter PostFrontmatter
|
||||
Body string
|
||||
}
|
||||
|
||||
type PostFrontmatter struct {
|
||||
ID string `yaml:"id"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type csvArticle struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
BodyMD string
|
||||
BodyHTML string
|
||||
Status string
|
||||
ArchiveID string
|
||||
AuthorID string
|
||||
PublishedAt string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
Type string
|
||||
}
|
||||
|
||||
func Import(options Options) (Result, error) {
|
||||
root, err := findProjectRoot(options.WorkingDir)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
csvPath := resolveRootPath(root, firstNonEmpty(options.CSVPath, "articles.csv"))
|
||||
postsDir := resolveRootPath(root, firstNonEmpty(options.PostsDir, defaultPostsDir))
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
|
||||
file, err := os.Open(csvPath)
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
reader := csv.NewReader(file)
|
||||
reader.FieldsPerRecord = -1
|
||||
reader.LazyQuotes = false
|
||||
|
||||
headers, err := reader.Read()
|
||||
if err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
index := map[string]int{}
|
||||
for i, header := range headers {
|
||||
index[strings.TrimSpace(header)] = i
|
||||
}
|
||||
|
||||
var result Result
|
||||
for rowNum := 2; ; rowNum++ {
|
||||
record, err := reader.Read()
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
return result, fmt.Errorf("%s row %d: %w", csvPath, rowNum, err)
|
||||
}
|
||||
|
||||
article := csvArticle{
|
||||
ID: csvValue(record, index, "id"),
|
||||
Slug: csvValue(record, index, "slug"),
|
||||
Title: csvValue(record, index, "title"),
|
||||
BodyMD: csvValue(record, index, "body_md"),
|
||||
BodyHTML: csvValue(record, index, "body_html"),
|
||||
Status: csvValue(record, index, "status"),
|
||||
ArchiveID: csvValue(record, index, "archive_id"),
|
||||
AuthorID: csvValue(record, index, "author_id"),
|
||||
PublishedAt: csvValue(record, index, "published_at"),
|
||||
CreatedAt: csvValue(record, index, "created_at"),
|
||||
UpdatedAt: csvValue(record, index, "updated_at"),
|
||||
Type: csvValue(record, index, "type"),
|
||||
}
|
||||
|
||||
if strings.TrimSpace(article.Type) != "" && strings.TrimSpace(article.Type) != "post" {
|
||||
result.SkippedNonPost++
|
||||
continue
|
||||
}
|
||||
|
||||
post, skippedExisting, err := articleToPost(root, postsDir, article, options.Overwrite)
|
||||
if err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
if skippedExisting {
|
||||
result.SkippedExisting++
|
||||
continue
|
||||
}
|
||||
if err := writePostFile(post); err != nil {
|
||||
return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func articleToPost(root string, postsDir string, article csvArticle, overwrite bool) (PostFile, bool, error) {
|
||||
id := strings.TrimSpace(article.ID)
|
||||
if id == "" {
|
||||
id = randomID()
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(article.Title)
|
||||
if title == "" {
|
||||
return PostFile{}, false, errors.New("missing title")
|
||||
}
|
||||
|
||||
slug := sanitizeSlug(article.Slug)
|
||||
if slug == "" {
|
||||
slug = fallbackSlug(title)
|
||||
}
|
||||
if slug == "" {
|
||||
return PostFile{}, false, errors.New("missing slug")
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(article.Status)
|
||||
if status != "published" && status != "draft" {
|
||||
status = "draft"
|
||||
}
|
||||
|
||||
createdAt, err := normalizeLegacyTime(article.CreatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid created_at: %w", err)
|
||||
}
|
||||
updatedAt, err := normalizeLegacyTime(article.UpdatedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid updated_at: %w", err)
|
||||
}
|
||||
|
||||
var publishedAt *string
|
||||
if strings.TrimSpace(article.PublishedAt) != "" {
|
||||
normalized, err := normalizeLegacyTime(article.PublishedAt)
|
||||
if err != nil {
|
||||
return PostFile{}, false, fmt.Errorf("invalid published_at: %w", err)
|
||||
}
|
||||
publishedAt = &normalized
|
||||
}
|
||||
|
||||
path := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
if !overwrite {
|
||||
return PostFile{}, true, nil
|
||||
}
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
|
||||
finalSlug := slug
|
||||
if !overwrite {
|
||||
finalSlug, err = uniqueSlug(root, slug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
path, err = uniquePostPath(postsDir, finalSlug)
|
||||
if err != nil {
|
||||
return PostFile{}, false, err
|
||||
}
|
||||
}
|
||||
|
||||
body := strings.TrimLeft(article.BodyMD, "\n")
|
||||
if strings.TrimSpace(body) == "" {
|
||||
body = strings.TrimLeft(article.BodyHTML, "\n")
|
||||
}
|
||||
|
||||
return PostFile{
|
||||
Path: path,
|
||||
Frontmatter: PostFrontmatter{
|
||||
ID: id,
|
||||
Slug: finalSlug,
|
||||
Title: title,
|
||||
Summary: "",
|
||||
Status: status,
|
||||
Tags: []string{},
|
||||
Cover: "",
|
||||
Version: 1,
|
||||
SlugSource: "manual",
|
||||
SlugLocked: true,
|
||||
PublishedAt: publishedAt,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
Body: body,
|
||||
}, false, nil
|
||||
}
|
||||
|
||||
func csvValue(record []string, index map[string]int, key string) string {
|
||||
i, ok := index[key]
|
||||
if !ok || i >= len(record) {
|
||||
return ""
|
||||
}
|
||||
return record[i]
|
||||
}
|
||||
|
||||
func normalizeLegacyTime(value string) (string, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "", errors.New("empty time")
|
||||
}
|
||||
layouts := []string{
|
||||
"2006-01-02 15:04:05.999999999Z07",
|
||||
"2006-01-02 15:04:05.999999Z07",
|
||||
"2006-01-02 15:04:05Z07",
|
||||
time.RFC3339,
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
parsed, err := time.Parse(layout, value)
|
||||
if err == nil {
|
||||
return parsed.Format(time.RFC3339), nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("unsupported time format %q", value)
|
||||
}
|
||||
|
||||
func writePostFile(post PostFile) error {
|
||||
var frontmatter bytes.Buffer
|
||||
encoder := yaml.NewEncoder(&frontmatter)
|
||||
encoder.SetIndent(2)
|
||||
if err := encoder.Encode(post.Frontmatter); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := encoder.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var output bytes.Buffer
|
||||
output.WriteString("---\n")
|
||||
output.Write(frontmatter.Bytes())
|
||||
output.WriteString("---\n\n")
|
||||
output.WriteString(strings.TrimLeft(post.Body, "\n"))
|
||||
|
||||
tmp := post.Path + ".tmp"
|
||||
if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, post.Path)
|
||||
}
|
||||
|
||||
func loadPosts(root string, postsDir string) ([]PostFile, error) {
|
||||
dir := resolveRootPath(root, postsDir)
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var posts []PostFile
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
|
||||
continue
|
||||
}
|
||||
posts = append(posts, PostFile{Path: filepath.Join(dir, entry.Name()), Frontmatter: PostFrontmatter{Slug: strings.TrimSuffix(entry.Name(), ".md")}})
|
||||
}
|
||||
return posts, nil
|
||||
}
|
||||
|
||||
func slugExists(root string, slug string) bool {
|
||||
posts, err := loadPosts(root, defaultPostsDir)
|
||||
if err == nil {
|
||||
for _, post := range posts {
|
||||
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
|
||||
if post.Frontmatter.Slug == slug || fileSlug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md"))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func uniqueSlug(root string, slug string) (string, error) {
|
||||
base := sanitizeSlug(slug)
|
||||
if base == "" {
|
||||
return "", errors.New("empty slug")
|
||||
}
|
||||
if !slugExists(root, base) {
|
||||
return base, nil
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate := fmt.Sprintf("%s-%d", base, i)
|
||||
if !slugExists(root, candidate) {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available slug for %q", base)
|
||||
}
|
||||
|
||||
func uniquePostPath(postsDir string, slug string) (string, error) {
|
||||
candidate := filepath.Join(postsDir, slug+".md")
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for i := 2; i < 1000; i++ {
|
||||
candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i))
|
||||
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
|
||||
return candidate, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("could not find available filename for slug %q", slug)
|
||||
}
|
||||
|
||||
func findProjectRoot(start string) (string, error) {
|
||||
wd := start
|
||||
if wd == "" {
|
||||
var err error
|
||||
wd, err = os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
for {
|
||||
if isProjectRoot(wd) {
|
||||
return wd, nil
|
||||
}
|
||||
parent := filepath.Dir(wd)
|
||||
if parent == wd {
|
||||
return "", errors.New("could not find project root")
|
||||
}
|
||||
wd = parent
|
||||
}
|
||||
}
|
||||
|
||||
func isProjectRoot(dir string) bool {
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func resolveRootPath(root string, path string) string {
|
||||
if filepath.IsAbs(path) {
|
||||
return path
|
||||
}
|
||||
return filepath.Join(root, path)
|
||||
}
|
||||
|
||||
func fallbackSlug(title string) string {
|
||||
var words []string
|
||||
var b strings.Builder
|
||||
flush := func() {
|
||||
if b.Len() > 0 {
|
||||
words = append(words, b.String())
|
||||
b.Reset()
|
||||
}
|
||||
}
|
||||
for _, r := range strings.ToLower(title) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z':
|
||||
b.WriteRune(r)
|
||||
case r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/':
|
||||
flush()
|
||||
default:
|
||||
flush()
|
||||
}
|
||||
}
|
||||
flush()
|
||||
if len(words) == 0 {
|
||||
return "post-" + time.Now().Format("20060102150405")
|
||||
}
|
||||
return sanitizeSlug(strings.Join(words, "-"))
|
||||
}
|
||||
|
||||
func sanitizeSlug(slug string) string {
|
||||
slug = strings.ToLower(strings.TrimSpace(slug))
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug = re.ReplaceAllString(slug, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
for strings.Contains(slug, "--") {
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
}
|
||||
if len(slug) > 80 {
|
||||
slug = strings.Trim(slug[:80], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
func randomID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
return fmt.Sprintf("post-%d", time.Now().UnixNano())
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
125
backend/internal/postimport/import_test.go
Normal file
125
backend/internal/postimport/import_test.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
package postimport
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeLegacyTime(t *testing.T) {
|
||||
got, err := normalizeLegacyTime("2026-01-13 01:25:27.486491+00")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("normalized time = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostFallbackSlugAndBody(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
post, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Title: "Hello World",
|
||||
BodyMD: "\nBody\n",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
PublishedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if skipped {
|
||||
t.Fatal("expected importable post")
|
||||
}
|
||||
if post.Frontmatter.Slug != "hello-world" {
|
||||
t.Fatalf("slug = %q", post.Frontmatter.Slug)
|
||||
}
|
||||
if post.Body != "Body\n" {
|
||||
t.Fatalf("body = %q", post.Body)
|
||||
}
|
||||
if post.Frontmatter.PublishedAt == nil || *post.Frontmatter.PublishedAt != "2026-01-13T01:25:27Z" {
|
||||
t.Fatalf("published_at = %#v", post.Frontmatter.PublishedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArticleToPostSkipsExistingWithoutOverwrite(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
postsDir := filepath.Join(root, defaultPostsDir)
|
||||
if err := os.MkdirAll(postsDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(postsDir, "smoking.md"), []byte("existing"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, skipped, err := articleToPost(root, postsDir, csvArticle{
|
||||
Slug: "smoking",
|
||||
Title: "抽烟",
|
||||
BodyMD: "Body",
|
||||
Status: "published",
|
||||
CreatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
UpdatedAt: "2026-01-13 01:25:27.486491+00",
|
||||
Type: "post",
|
||||
}, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !skipped {
|
||||
t.Fatal("expected existing file to be skipped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportWritesMarkdown(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(root, "backend", "cmd", "osaetctl"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(root, "frontend", "site"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "frontend", "site", "package.json"), []byte("{}"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csvPath := filepath.Join(root, "articles.csv")
|
||||
csvContent := strings.Join([]string{
|
||||
"id,slug,title,body_md,body_html,status,archive_id,author_id,published_at,created_at,updated_at,type",
|
||||
"post-1,test-post,Test Post,\"Line 1\n\nLine 2\",,published,,,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,post",
|
||||
}, "\n")
|
||||
if err := os.WriteFile(csvPath, []byte(csvContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
result, err := Import(Options{CSVPath: csvPath, PostsDir: defaultPostsDir, WorkingDir: root})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Imported != 1 || result.SkippedExisting != 0 || result.SkippedNonPost != 0 {
|
||||
t.Fatalf("result = %#v", result)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(root, defaultPostsDir, "test-post.md"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content := string(data)
|
||||
for _, want := range []string{
|
||||
"slug: test-post",
|
||||
"title: Test Post",
|
||||
"status: published",
|
||||
"published_at: \"2026-01-13T01:25:27Z\"",
|
||||
"Line 1",
|
||||
"Line 2",
|
||||
} {
|
||||
if !strings.Contains(content, want) {
|
||||
t.Fatalf("expected output to contain %q\n%s", want, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue