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:
yarnom 2026-06-01 15:48:04 +08:00
parent b78f4b39c9
commit f0b50d13ea
121 changed files with 27139 additions and 550 deletions

View file

@ -0,0 +1,33 @@
package main
import (
"flag"
"fmt"
"os"
"osaet/backend/internal/postimport"
)
func main() {
fs := flag.NewFlagSet("import-articles", flag.ExitOnError)
file := fs.String("file", "articles.csv", "CSV file path")
overwrite := fs.Bool("overwrite", false, "overwrite existing markdown files")
postsDir := fs.String("posts-dir", "content/posts", "posts output directory relative to project root")
if err := fs.Parse(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
result, err := postimport.Import(postimport.Options{
CSVPath: *file,
PostsDir: *postsDir,
Overwrite: *overwrite,
WorkingDir: "",
})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
fmt.Printf("imported %d post(s), skipped %d existing file(s), skipped %d non-post row(s)\n", result.Imported, result.SkippedExisting, result.SkippedNonPost)
}

View file

@ -0,0 +1,116 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"osaet/backend/internal/admin"
)
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run() error {
command := "serve"
if len(os.Args) > 1 {
command = os.Args[1]
}
cfg := admin.LoadConfig()
if err := cfg.Validate(); err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
db, err := admin.OpenDatabase(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
switch command {
case "serve":
return serve(ctx, cfg, db)
case "migrate":
return admin.RunMigrations(ctx, db, cfg.MigrationsDir)
case "create-user":
return createUser(ctx, db)
case "import-markdown":
return importMarkdown(ctx, cfg, db)
default:
return fmt.Errorf("unknown command %q", command)
}
}
func createUser(ctx context.Context, db *pgxpool.Pool) error {
if len(os.Args) < 3 {
return errors.New("usage: osaet-admin create-user <username>")
}
password := os.Getenv("OSAET_ADMIN_PASSWORD")
if password == "" {
return errors.New("OSAET_ADMIN_PASSWORD is required")
}
user, err := admin.NewStore(db).CreateOrUpdateUser(ctx, os.Args[2], password)
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "admin user %q is ready\n", user.Username)
return nil
}
func importMarkdown(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
postsDir := cfg.PostsDir
if len(os.Args) >= 3 {
postsDir = os.Args[2]
}
result, err := admin.NewStore(db).ImportMarkdownPosts(ctx, postsDir)
if err != nil {
return err
}
fmt.Fprintf(os.Stdout, "imported %d markdown post(s), skipped %d file(s)\n", result.Imported, result.Skipped)
return nil
}
func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
server := &http.Server{
Addr: cfg.Addr,
Handler: admin.NewServerWithContext(ctx, db, cfg).Router(),
ReadHeaderTimeout: 5 * time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- server.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return server.Shutdown(shutdownCtx)
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}

View file

@ -1,20 +1,53 @@
module osaet/backend
go 1.25
go 1.25.0
require (
github.com/gin-gonic/gin v1.12.0
github.com/jackc/pgx/v5 v5.4.3
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.33.1
)
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect

View file

@ -1,28 +1,125 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=

View 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)
}

View 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,
})
}

View 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
}

View 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
}

View 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
}

View 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
}

View 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)
}

View 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
}

View 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[:])
}

View 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)
}
}

View 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()})
}
}

View 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)
}
}

View 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))
}

View 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)
}

View 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")
}
}

View 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(&currentStatus)
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(&currentStatus)
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)
}

View 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"`
}

View 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;
}
}

View 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();

View 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>

View file

@ -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]+`)

View file

@ -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]

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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
}

View 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 ""
}

View 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)
}
}
}

View file

@ -0,0 +1,100 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_login_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
body_markdown TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
cover TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
slug_source TEXT NOT NULL DEFAULT 'manual',
slug_locked BOOLEAN NOT NULL DEFAULT false,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted_at TIMESTAMPTZ,
CONSTRAINT posts_status_check CHECK (status IN ('draft', 'published', 'archived', 'deleted')),
CONSTRAINT posts_version_check CHECK (version >= 1)
);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at DESC);
CREATE INDEX IF NOT EXISTS idx_posts_updated_at ON posts(updated_at DESC);
CREATE TABLE IF NOT EXISTS post_versions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
title TEXT NOT NULL,
summary TEXT NOT NULL,
body_markdown TEXT NOT NULL,
status TEXT NOT NULL,
reason TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT post_versions_status_check CHECK (status IN ('draft', 'published', 'archived', 'deleted')),
CONSTRAINT post_versions_reason_check CHECK (reason IN ('save', 'publish', 'unpublish', 'archive', 'restore', 'import', 'rollback')),
CONSTRAINT post_versions_version_check CHECK (version >= 1),
UNIQUE (post_id, version)
);
CREATE INDEX IF NOT EXISTS idx_post_versions_post_id_created_at ON post_versions(post_id, created_at DESC);
CREATE TABLE IF NOT EXISTS tags (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS post_tags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX IF NOT EXISTS idx_post_tags_tag_id ON post_tags(tag_id);
CREATE TABLE IF NOT EXISTS build_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
trigger TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued',
post_id UUID REFERENCES posts(id) ON DELETE SET NULL,
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
log TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT build_jobs_trigger_check CHECK (trigger IN ('publish', 'manual', 'import', 'sync')),
CONSTRAINT build_jobs_status_check CHECK (status IN ('queued', 'running', 'success', 'failed', 'cancelled'))
);
CREATE INDEX IF NOT EXISTS idx_build_jobs_status_created_at ON build_jobs(status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_build_jobs_post_id_created_at ON build_jobs(post_id, created_at DESC);
CREATE TABLE IF NOT EXISTS assets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
path TEXT NOT NULL UNIQUE,
original_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
size_bytes BIGINT NOT NULL,
sha256 TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
CONSTRAINT assets_size_bytes_check CHECK (size_bytes >= 0)
);
CREATE INDEX IF NOT EXISTS idx_assets_sha256 ON assets(sha256);

View file

@ -0,0 +1,11 @@
CREATE TABLE IF NOT EXISTS admin_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_user_id ON admin_sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_admin_sessions_expires_at ON admin_sessions(expires_at);