Simplify admin publishing pipeline

This commit is contained in:
yarnom 2026-06-03 18:18:50 +08:00
parent 13e7e4026d
commit 9186801c7f
37 changed files with 750 additions and 3367 deletions

View file

@ -1,33 +0,0 @@
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

@ -32,6 +32,13 @@ func run() error {
if err := cfg.Validate(); err != nil { if err := cfg.Validate(); err != nil {
return err return err
} }
logCloser, err := admin.ConfigureLogging(cfg)
if err != nil {
return err
}
if logCloser != nil {
defer logCloser.Close()
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop() defer stop()
@ -49,8 +56,6 @@ func run() error {
return admin.RunMigrations(ctx, db, cfg.MigrationsDir) return admin.RunMigrations(ctx, db, cfg.MigrationsDir)
case "create-user": case "create-user":
return createUser(ctx, db) return createUser(ctx, db)
case "import-markdown":
return importMarkdown(ctx, cfg, db)
default: default:
return fmt.Errorf("unknown command %q", command) return fmt.Errorf("unknown command %q", command)
} }
@ -75,21 +80,6 @@ func createUser(ctx context.Context, db *pgxpool.Pool) error {
return nil 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 { func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
server := &http.Server{ server := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,

View file

@ -1,15 +0,0 @@
package main
import (
"fmt"
"os"
"osaet/backend/internal/cli"
)
func main() {
if err := cli.Run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}

View file

@ -5,8 +5,8 @@ go 1.25.0
require ( require (
github.com/gin-gonic/gin v1.12.0 github.com/gin-gonic/gin v1.12.0
github.com/jackc/pgx/v5 v5.4.3 github.com/jackc/pgx/v5 v5.4.3
golang.org/x/crypto v0.48.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.33.1
) )
require ( require (
@ -14,7 +14,6 @@ require (
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
@ -22,8 +21,6 @@ require (
github.com/go-playground/validator/v10 v10.30.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-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // 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/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect
@ -33,25 +30,16 @@ require (
github.com/mattn/go-isatty v0.0.20 // 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/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.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/net v0.51.0 // indirect
golang.org/x/sync v0.19.0 // indirect golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // 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
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
) )

View file

@ -9,8 +9,6 @@ github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gE
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= 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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@ -32,12 +30,6 @@ github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7Lk
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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/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/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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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 h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
@ -63,8 +55,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= 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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@ -73,8 +63,6 @@ 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/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 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= 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=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 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/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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@ -101,8 +89,6 @@ 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/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= 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 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= 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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
@ -112,8 +98,6 @@ 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/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 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= 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 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -122,29 +106,3 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View file

@ -1,80 +0,0 @@
package admin
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
const maxAssetSizeBytes = 12 * 1024 * 1024
var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
type AssetUploader struct {
store *Store
assetsDir string
}
func NewAssetUploader(store *Store, assetsDir string) *AssetUploader {
return &AssetUploader{store: store, assetsDir: assetsDir}
}
func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) {
if header.Size > maxAssetSizeBytes {
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
}
data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1))
if err != nil {
return Asset{}, fmt.Errorf("read asset: %w", err)
}
if int64(len(data)) > maxAssetSizeBytes {
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
}
mimeType := http.DetectContentType(data)
if !strings.HasPrefix(mimeType, "image/") {
return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType)
}
sum := sha256.Sum256(data)
sha := hex.EncodeToString(sum[:])
name := assetFilename(header.Filename, sha)
if err := os.MkdirAll(u.assetsDir, 0o755); err != nil {
return Asset{}, fmt.Errorf("create assets dir: %w", err)
}
if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil {
return Asset{}, fmt.Errorf("write asset: %w", err)
}
asset := Asset{
Path: "/assets/" + name,
OriginalName: header.Filename,
MimeType: mimeType,
SizeBytes: int64(len(data)),
SHA256: sha,
}
return u.store.CreateAsset(ctx, asset)
}
func assetFilename(original string, sha string) string {
ext := strings.ToLower(filepath.Ext(original))
base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original))
base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._")
if base == "" {
base = "image"
}
if ext == "" {
ext = ".bin"
}
return fmt.Sprintf("%s-%s%s", base, sha[:12], ext)
}

View file

@ -20,8 +20,11 @@ type Config struct {
RepoRoot string RepoRoot string
PostsDir string PostsDir string
SiteDir string SiteDir string
AssetsDir string StaticDir string
AdminDir string AdminDir string
LogFile string
LogMaxBytes int64
LogMaxBackups int
DeepSeek DeepSeekConfig DeepSeek DeepSeekConfig
LocalLLM LocalLLMConfig LocalLLM LocalLLMConfig
SlugProvider string SlugProvider string
@ -89,9 +92,9 @@ func LoadConfig() Config {
siteDir = filepath.Join(repoRoot, "frontend", "site") siteDir = filepath.Join(repoRoot, "frontend", "site")
} }
assetsDir := os.Getenv("OSAET_ASSETS_DIR") staticDir := os.Getenv("OSAET_STATIC_DIR")
if assetsDir == "" { if staticDir == "" {
assetsDir = filepath.Join(siteDir, "public", "assets") staticDir = filepath.Join(repoRoot, "dist", "site")
} }
adminDir := os.Getenv("OSAET_ADMIN_DIR") adminDir := os.Getenv("OSAET_ADMIN_DIR")
@ -99,6 +102,11 @@ func LoadConfig() Config {
adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser") adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser")
} }
logFile := os.Getenv("OSAET_LOG_FILE")
if logFile == "" {
logFile = filepath.Join(repoRoot, ".osaet", "logs", "osaet-admin.log")
}
local := loadLocalConfig(repoRoot) local := loadLocalConfig(repoRoot)
databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN) databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN)
@ -118,8 +126,11 @@ func LoadConfig() Config {
RepoRoot: repoRoot, RepoRoot: repoRoot,
PostsDir: postsDir, PostsDir: postsDir,
SiteDir: siteDir, SiteDir: siteDir,
AssetsDir: assetsDir, StaticDir: staticDir,
AdminDir: adminDir, AdminDir: adminDir,
LogFile: logFile,
LogMaxBytes: int64(firstNonZeroInt(envInt("OSAET_LOG_MAX_BYTES"), 10*1024*1024)),
LogMaxBackups: firstNonZeroInt(envInt("OSAET_LOG_MAX_BACKUPS"), 5),
SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"), SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"),
DeepSeek: DeepSeekConfig{ DeepSeek: DeepSeekConfig{
APIKey: deepSeekAPIKey, APIKey: deepSeekAPIKey,
@ -236,8 +247,8 @@ func (c Config) Validate() error {
if c.SiteDir == "" { if c.SiteDir == "" {
return errors.New("site dir is required") return errors.New("site dir is required")
} }
if c.AssetsDir == "" { if c.StaticDir == "" {
return errors.New("assets dir is required") return errors.New("static dir is required")
} }
return nil return nil
} }

View file

@ -0,0 +1,139 @@
package admin
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
)
type rotatingLogWriter struct {
path string
maxBytes int64
maxBackups int
file *os.File
size int64
mu sync.Mutex
}
func ConfigureLogging(cfg Config) (io.Closer, error) {
if cfg.LogFile == "" {
return nil, nil
}
writer, err := newRotatingLogWriter(cfg.LogFile, cfg.LogMaxBytes, cfg.LogMaxBackups)
if err != nil {
return nil, err
}
multi := io.MultiWriter(os.Stdout, writer)
log.SetOutput(multi)
log.SetFlags(log.LstdFlags | log.Lmicroseconds | log.LUTC)
return writer, nil
}
func newRotatingLogWriter(path string, maxBytes int64, maxBackups int) (*rotatingLogWriter, error) {
if maxBytes <= 0 {
maxBytes = 10 * 1024 * 1024
}
if maxBackups <= 0 {
maxBackups = 5
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, fmt.Errorf("create log dir: %w", err)
}
file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return nil, fmt.Errorf("open log file: %w", err)
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, fmt.Errorf("stat log file: %w", err)
}
return &rotatingLogWriter{
path: path,
maxBytes: maxBytes,
maxBackups: maxBackups,
file: file,
size: info.Size(),
}, nil
}
func (w *rotatingLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.size+int64(len(p)) > w.maxBytes {
if err := w.rotateLocked(); err != nil {
return 0, err
}
}
n, err := w.file.Write(p)
w.size += int64(n)
return n, err
}
func (w *rotatingLogWriter) Close() error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return nil
}
err := w.file.Close()
w.file = nil
return err
}
func (w *rotatingLogWriter) rotateLocked() error {
if w.file != nil {
if err := w.file.Close(); err != nil {
return err
}
}
stamp := time.Now().UTC().Format("20060102T150405")
rotated := fmt.Sprintf("%s.%s", w.path, stamp)
if err := os.Rename(w.path, rotated); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("rotate log: %w", err)
}
if err := w.pruneLocked(); err != nil {
return err
}
file, err := os.OpenFile(w.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return fmt.Errorf("open rotated log file: %w", err)
}
w.file = file
w.size = 0
return nil
}
func (w *rotatingLogWriter) pruneLocked() error {
pattern := w.path + ".*"
matches, err := filepath.Glob(pattern)
if err != nil {
return fmt.Errorf("list rotated logs: %w", err)
}
if len(matches) <= w.maxBackups {
return nil
}
sortStrings(matches)
for _, path := range matches[:len(matches)-w.maxBackups] {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove old log %s: %w", path, err)
}
}
return nil
}
func sortStrings(values []string) {
for i := 1; i < len(values); i++ {
value := values[i]
j := i - 1
for ; j >= 0 && values[j] > value; j-- {
values[j+1] = values[j]
}
values[j+1] = value
}
}

View file

@ -1,239 +0,0 @@
package admin
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
type MarkdownImportResult struct {
Imported int
Skipped int
}
type markdownFrontmatter struct {
Slug string `yaml:"slug"`
Title string `yaml:"title"`
Summary string `yaml:"summary"`
Status string `yaml:"status"`
Tags []string `yaml:"tags"`
Cover string `yaml:"cover"`
Version int `yaml:"version"`
SlugSource string `yaml:"slug_source"`
SlugLocked bool `yaml:"slug_locked"`
PublishedAt string `yaml:"published_at"`
CreatedAt string `yaml:"created_at"`
UpdatedAt string `yaml:"updated_at"`
}
func (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) {
entries, err := os.ReadDir(postsDir)
if err != nil {
return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err)
}
var result MarkdownImportResult
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") {
continue
}
post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name()))
if err != nil {
return result, err
}
if post.Title == "" || post.Slug == "" {
result.Skipped++
continue
}
if err := s.upsertImportedPost(ctx, post); err != nil {
return result, err
}
result.Imported++
}
return result, nil
}
func readMarkdownPost(path string) (Post, error) {
data, err := os.ReadFile(path)
if err != nil {
return Post{}, fmt.Errorf("read markdown post %s: %w", path, err)
}
frontmatterData, body, err := splitMarkdownFrontmatter(data)
if err != nil {
return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err)
}
var meta markdownFrontmatter
if err := yaml.Unmarshal(frontmatterData, &meta); err != nil {
return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err)
}
slug := strings.TrimSpace(meta.Slug)
if slug == "" {
slug = strings.TrimSuffix(filepath.Base(path), ".md")
}
status := PostStatus(strings.TrimSpace(meta.Status))
if !ValidPostStatus(status) || status == PostStatusDeleted {
status = PostStatusDraft
}
version := meta.Version
if version < 1 {
version = 1
}
slugSource := strings.TrimSpace(meta.SlugSource)
if slugSource == "" {
slugSource = "manual"
}
now := time.Now()
createdAt := parseFrontmatterTime(meta.CreatedAt, now)
updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt)
var publishedAt *time.Time
if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok {
publishedAt = &parsed
}
return Post{
Slug: slug,
Title: strings.TrimSpace(meta.Title),
Summary: meta.Summary,
BodyMarkdown: strings.TrimLeft(string(body), "\n"),
Status: status,
Tags: normalizeTagNames(meta.Tags),
Cover: meta.Cover,
Version: version,
SlugSource: slugSource,
SlugLocked: meta.SlugLocked,
PublishedAt: publishedAt,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
}, nil
}
func (s *Store) upsertImportedPost(ctx context.Context, post Post) error {
tx, err := s.db.Begin(ctx)
if err != nil {
return fmt.Errorf("begin markdown import: %w", err)
}
defer tx.Rollback(ctx)
imported, err := scanPost(tx.QueryRow(ctx, `
INSERT INTO posts (
slug, title, summary, body_markdown, status, cover, version,
slug_source, slug_locked, published_at, created_at, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (slug)
DO UPDATE SET
title = excluded.title,
summary = excluded.summary,
body_markdown = excluded.body_markdown,
status = excluded.status,
cover = excluded.cover,
version = GREATEST(posts.version, excluded.version),
slug_source = excluded.slug_source,
slug_locked = excluded.slug_locked,
published_at = excluded.published_at,
updated_at = excluded.updated_at,
deleted_at = NULL
RETURNING id, slug, title, summary, body_markdown, status, cover, version,
slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`,
post.Slug,
post.Title,
post.Summary,
post.BodyMarkdown,
post.Status,
post.Cover,
post.Version,
post.SlugSource,
post.SlugLocked,
post.PublishedAt,
post.CreatedAt,
post.UpdatedAt,
))
if err != nil {
return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err)
}
if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil {
return err
}
if _, err := tx.Exec(ctx, `
INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (post_id, version) DO NOTHING`,
imported.ID,
imported.Version,
imported.Title,
imported.Summary,
imported.BodyMarkdown,
imported.Status,
VersionReasonImport,
); err != nil {
return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit markdown import: %w", err)
}
return nil
}
func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) {
if !bytes.HasPrefix(data, []byte("---\n")) {
return nil, nil, errors.New("missing frontmatter opening marker")
}
rest := data[len("---\n"):]
idx := bytes.Index(rest, []byte("\n---"))
if idx < 0 {
return nil, nil, errors.New("missing frontmatter closing marker")
}
frontmatter := rest[:idx]
body := rest[idx+len("\n---"):]
if bytes.HasPrefix(body, []byte("\r\n")) {
body = body[2:]
} else if bytes.HasPrefix(body, []byte("\n")) {
body = body[1:]
}
return frontmatter, body, nil
}
func parseFrontmatterTime(value string, fallback time.Time) time.Time {
if parsed, ok := parseOptionalFrontmatterTime(value); ok {
return parsed
}
return fallback
}
func parseOptionalFrontmatterTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
layouts := []string{
time.RFC3339,
"2006-01-02 15:04:05.999999999Z07",
"2006-01-02 15:04:05.999999Z07",
"2006-01-02 15:04:05Z07",
"2006-01-02 15:04:05",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, value); err == nil {
return parsed, true
}
}
return time.Time{}, false
}

View file

@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -19,11 +20,11 @@ type Server struct {
db *pgxpool.Pool db *pgxpool.Pool
store *Store store *Store
builder *Builder builder *Builder
uploader *AssetUploader
deepSeek DeepSeekConfig deepSeek DeepSeekConfig
localLLM LocalLLMConfig localLLM LocalLLMConfig
slugProvider string slugProvider string
adminDir string adminDir string
staticDir string
ctx context.Context ctx context.Context
} }
@ -38,26 +39,22 @@ func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server {
func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server { func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server {
var store *Store var store *Store
var builder *Builder var builder *Builder
var uploader *AssetUploader
if db != nil { if db != nil {
store = NewStore(db) store = NewStore(db)
if cfg.PostsDir != "" && cfg.SiteDir != "" { if cfg.PostsDir != "" && cfg.SiteDir != "" {
builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir) builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir)
builder.Start(ctx) builder.Start(ctx)
} }
if cfg.AssetsDir != "" {
uploader = NewAssetUploader(store, cfg.AssetsDir)
}
} }
return &Server{ return &Server{
db: db, db: db,
store: store, store: store,
builder: builder, builder: builder,
uploader: uploader,
deepSeek: cfg.DeepSeek, deepSeek: cfg.DeepSeek,
localLLM: cfg.LocalLLM, localLLM: cfg.LocalLLM,
slugProvider: cfg.SlugProvider, slugProvider: cfg.SlugProvider,
adminDir: cfg.AdminDir, adminDir: cfg.AdminDir,
staticDir: cfg.StaticDir,
ctx: ctx, ctx: ctx,
} }
} }
@ -67,6 +64,7 @@ func (s *Server) Router() http.Handler {
r := gin.New() r := gin.New()
r.Use(gin.Recovery()) r.Use(gin.Recovery())
r.Use(requestLogger())
r.GET("/healthz", s.health) r.GET("/healthz", s.health)
r.GET("/readyz", s.ready) r.GET("/readyz", s.ready)
@ -82,8 +80,8 @@ func (s *Server) Router() http.Handler {
protected.Use(s.requireAuth) protected.Use(s.requireAuth)
protected.GET("/me", s.me) protected.GET("/me", s.me)
protected.POST("/logout", s.logout) protected.POST("/logout", s.logout)
protected.POST("/assets", s.uploadAsset)
protected.POST("/slug", s.generateSlug) protected.POST("/slug", s.generateSlug)
protected.GET("/audit-logs", s.listAuditLogs)
protected.GET("/posts", s.listPosts) protected.GET("/posts", s.listPosts)
protected.POST("/posts", s.createPost) protected.POST("/posts", s.createPost)
protected.GET("/posts/:id", s.getPost) protected.GET("/posts/:id", s.getPost)
@ -93,6 +91,8 @@ func (s *Server) Router() http.Handler {
protected.POST("/posts/:id/publish", s.publishPost) protected.POST("/posts/:id/publish", s.publishPost)
protected.GET("/build-jobs/:id", s.getBuildJob) protected.GET("/build-jobs/:id", s.getBuildJob)
r.NoRoute(s.siteFile)
return r return r
} }
@ -119,6 +119,17 @@ func (s *Server) adminFile(c *gin.Context) {
s.adminPage(c) s.adminPage(c)
} }
func (s *Server) siteFile(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
return
}
if serveSiteFile(c, s.staticDir) {
return
}
c.String(http.StatusNotFound, "not found")
}
func (s *Server) ready(c *gin.Context) { func (s *Server) ready(c *gin.Context) {
if s.db == nil { if s.db == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
@ -190,10 +201,12 @@ func (s *Server) login(c *gin.Context) {
result, err := s.store.Login(c.Request.Context(), input) result, err := s.store.Login(c.Request.Context(), input)
if err != nil { if err != nil {
s.audit(c, nil, "login_failed", "user", "", gin.H{"username": input.Username, "error": err.Error()})
writeStoreError(c, err) writeStoreError(c, err)
return return
} }
SetSessionCookie(c, result.Token, result.ExpiresAt) SetSessionCookie(c, result.Token, result.ExpiresAt)
s.audit(c, &result.User, "login", "user", result.User.ID, gin.H{"username": result.User.Username})
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"user": result.User, "user": result.User,
"expiresAt": result.ExpiresAt, "expiresAt": result.ExpiresAt,
@ -214,12 +227,14 @@ func (s *Server) logout(c *gin.Context) {
return return
} }
user, _ := currentUser(c)
token, _ := c.Cookie(SessionCookieName) token, _ := c.Cookie(SessionCookieName)
if err := s.store.Logout(c.Request.Context(), token); err != nil { if err := s.store.Logout(c.Request.Context(), token); err != nil {
writeStoreError(c, err) writeStoreError(c, err)
return return
} }
ClearSessionCookie(c) ClearSessionCookie(c)
s.audit(c, &user, "logout", "user", user.ID, gin.H{"username": user.Username})
c.JSON(http.StatusOK, gin.H{"ok": true}) c.JSON(http.StatusOK, gin.H{"ok": true})
} }
@ -252,6 +267,7 @@ func (s *Server) createPost(c *gin.Context) {
writeStoreError(c, err) writeStoreError(c, err)
return return
} }
s.auditCurrentUser(c, "post_create", "post", post.ID, postAuditDetails(post))
c.JSON(http.StatusCreated, gin.H{"post": post}) c.JSON(http.StatusCreated, gin.H{"post": post})
} }
@ -271,6 +287,7 @@ func (s *Server) updatePost(c *gin.Context) {
writeStoreError(c, err) writeStoreError(c, err)
return return
} }
s.auditCurrentUser(c, "post_update", "post", post.ID, postAuditDetails(post))
c.JSON(http.StatusOK, gin.H{"post": post}) c.JSON(http.StatusOK, gin.H{"post": post})
} }
@ -285,33 +302,14 @@ func (s *Server) deletePost(c *gin.Context) {
return return
} }
s.enqueueBuildJob(job) s.enqueueBuildJob(job)
details := gin.H{}
if job != nil {
details["buildJobId"] = job.ID
}
s.auditCurrentUser(c, "post_delete", "post", c.Param("id"), details)
c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job}) 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 { type GenerateSlugInput struct {
Title string `json:"title"` Title string `json:"title"`
Summary string `json:"summary"` Summary string `json:"summary"`
@ -345,9 +343,35 @@ func (s *Server) generateSlug(c *gin.Context) {
writeStoreError(c, err) writeStoreError(c, err)
return return
} }
s.auditCurrentUser(c, "slug_generate", "post", input.PostID, gin.H{"title": input.Title, "slug": slug})
c.JSON(http.StatusOK, gin.H{"slug": slug}) c.JSON(http.StatusOK, gin.H{"slug": slug})
} }
func (s *Server) listAuditLogs(c *gin.Context) {
if !s.requireStore(c) {
return
}
opts := AuditLogListOptions{
Action: c.Query("action"),
ResourceType: c.Query("resourceType"),
Query: c.Query("query"),
Limit: queryInt(c, "limit"),
Offset: queryInt(c, "offset"),
}
logs, err := s.store.ListAuditLogs(c.Request.Context(), opts)
if err != nil {
writeStoreError(c, err)
return
}
total, err := s.store.CountAuditLogs(c.Request.Context(), opts)
if err != nil {
writeStoreError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total})
}
func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) { func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) {
switch strings.ToLower(strings.TrimSpace(s.slugProvider)) { switch strings.ToLower(strings.TrimSpace(s.slugProvider)) {
case "", "deepseek": case "", "deepseek":
@ -385,6 +409,7 @@ func (s *Server) buildPost(c *gin.Context) {
} }
s.enqueueBuildJob(&job) s.enqueueBuildJob(&job)
s.auditCurrentUser(c, "build_create", "build_job", job.ID, gin.H{"postId": c.Param("id")})
c.JSON(http.StatusAccepted, gin.H{"buildJob": job}) c.JSON(http.StatusAccepted, gin.H{"buildJob": job})
} }
@ -400,6 +425,12 @@ func (s *Server) publishPost(c *gin.Context) {
} }
s.enqueueBuildJob(&job) s.enqueueBuildJob(&job)
s.auditCurrentUser(c, "post_publish", "post", post.ID, gin.H{
"title": post.Title,
"slug": post.Slug,
"status": post.Status,
"buildJobId": job.ID,
})
c.JSON(http.StatusAccepted, gin.H{ c.JSON(http.StatusAccepted, gin.H{
"post": post, "post": post,
@ -472,3 +503,58 @@ func writeStoreError(c *gin.Context, err error) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
} }
} }
func currentUser(c *gin.Context) (User, bool) {
user, ok := c.Request.Context().Value(userContextKey).(User)
return user, ok
}
func (s *Server) auditCurrentUser(c *gin.Context, action string, resourceType string, resourceID string, details map[string]any) {
user, ok := currentUser(c)
if !ok {
s.audit(c, nil, action, resourceType, resourceID, details)
return
}
s.audit(c, &user, action, resourceType, resourceID, details)
}
func (s *Server) audit(c *gin.Context, user *User, action string, resourceType string, resourceID string, details map[string]any) {
if s.store == nil {
return
}
var actorID *string
actorUsername := ""
if user != nil {
actorID = &user.ID
actorUsername = user.Username
}
if err := s.store.CreateAuditLog(c.Request.Context(), AuditLogInput{
ActorID: actorID,
ActorUsername: actorUsername,
Action: action,
ResourceType: resourceType,
ResourceID: resourceID,
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
Details: details,
}); err != nil {
log.Printf("audit log failed: %v", err)
}
}
func requestLogger() gin.HandlerFunc {
return func(c *gin.Context) {
started := time.Now()
c.Next()
log.Printf("%s %s %d %s %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), c.ClientIP())
}
}
func postAuditDetails(post Post) map[string]any {
return map[string]any{
"title": post.Title,
"slug": post.Slug,
"status": post.Status,
"version": post.Version,
}
}

View file

@ -46,6 +46,33 @@ func serveAdminFile(c *gin.Context, adminDir string) bool {
return true return true
} }
func serveSiteFile(c *gin.Context, staticDir string) bool {
if strings.TrimSpace(staticDir) == "" {
return false
}
requested := strings.TrimPrefix(c.Request.URL.Path, "/")
if requested == "" {
requested = "index.html"
}
cleaned := filepath.Clean(requested)
if cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) {
return false
}
path := filepath.Join(staticDir, cleaned)
info, err := os.Stat(path)
if err == nil && info.IsDir() {
path = filepath.Join(path, "index.html")
info, err = os.Stat(path)
}
if err != nil || info.IsDir() {
return false
}
http.ServeFile(c.Writer, c.Request, path)
return true
}
func rewriteAdminBase(page []byte) []byte { func rewriteAdminBase(page []byte) []byte {
return []byte(strings.Replace(string(page), `<base href="/">`, `<base href="/admin/">`, 1)) return []byte(strings.Replace(string(page), `<base href="/">`, `<base href="/admin/">`, 1))
} }

View file

@ -3,6 +3,7 @@ package admin
import ( import (
"context" "context"
"database/sql" "database/sql"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
@ -25,6 +26,25 @@ type PostListOptions struct {
Offset int Offset int
} }
type AuditLogInput struct {
ActorID *string
ActorUsername string
Action string
ResourceType string
ResourceID string
IPAddress string
UserAgent string
Details map[string]any
}
type AuditLogListOptions struct {
Action string
ResourceType string
Query string
Limit int
Offset int
}
type PostInput struct { type PostInput struct {
Slug string `json:"slug"` Slug string `json:"slug"`
Title string `json:"title"` Title string `json:"title"`
@ -389,28 +409,6 @@ WHERE id = $1`, id))
return job, nil 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) { func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) {
rows, err := s.db.Query(ctx, ` rows, err := s.db.Query(ctx, `
SELECT id, slug, title, summary, body_markdown, status, cover, version, SELECT id, slug, title, summary, body_markdown, status, cover, version,
@ -473,6 +471,114 @@ WHERE id = $1`, id, log, message)
return nil return nil
} }
func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error {
action := strings.TrimSpace(input.Action)
if action == "" {
return errors.New("audit action is required")
}
details := input.Details
if details == nil {
details = map[string]any{}
}
detailsJSON, err := json.Marshal(details)
if err != nil {
return fmt.Errorf("encode audit details: %w", err)
}
_, err = s.db.Exec(ctx, `
INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`,
input.ActorID,
input.ActorUsername,
action,
strings.TrimSpace(input.ResourceType),
strings.TrimSpace(input.ResourceID),
strings.TrimSpace(input.IPAddress),
strings.TrimSpace(input.UserAgent),
string(detailsJSON),
)
if err != nil {
return fmt.Errorf("create audit log: %w", err)
}
return nil
}
func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) {
limit := opts.Limit
if limit <= 0 || limit > 200 {
limit = 50
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
args := []any{limit, offset}
where := "true"
if strings.TrimSpace(opts.Action) != "" {
args = append(args, strings.TrimSpace(opts.Action))
where += fmt.Sprintf(" AND action = $%d", len(args))
}
if strings.TrimSpace(opts.ResourceType) != "" {
args = append(args, strings.TrimSpace(opts.ResourceType))
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
}
if strings.TrimSpace(opts.Query) != "" {
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
where += fmt.Sprintf(` AND (
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
}
rows, err := s.db.Query(ctx, `
SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at
FROM audit_logs
WHERE `+where+`
ORDER BY created_at DESC
LIMIT $1 OFFSET $2`, args...)
if err != nil {
return nil, fmt.Errorf("list audit logs: %w", err)
}
defer rows.Close()
var logs []AuditLog
for rows.Next() {
log, err := scanAuditLog(rows)
if err != nil {
return nil, err
}
logs = append(logs, log)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("audit log rows: %w", err)
}
return logs, nil
}
func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) {
args := []any{}
where := "true"
if strings.TrimSpace(opts.Action) != "" {
args = append(args, strings.TrimSpace(opts.Action))
where += fmt.Sprintf(" AND action = $%d", len(args))
}
if strings.TrimSpace(opts.ResourceType) != "" {
args = append(args, strings.TrimSpace(opts.ResourceType))
where += fmt.Sprintf(" AND resource_type = $%d", len(args))
}
if strings.TrimSpace(opts.Query) != "" {
args = append(args, "%"+strings.TrimSpace(opts.Query)+"%")
where += fmt.Sprintf(` AND (
actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d
)`, len(args), len(args), len(args), len(args), len(args))
}
var total int
if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil {
return 0, fmt.Errorf("count audit logs: %w", err)
}
return total, nil
}
func validatePostInput(input PostInput, creating bool) error { func validatePostInput(input PostInput, creating bool) error {
if strings.TrimSpace(input.Title) == "" { if strings.TrimSpace(input.Title) == "" {
return errors.New("title is required") return errors.New("title is required")
@ -780,24 +886,35 @@ func scanBuildJob(row postScanner) (BuildJob, error) {
return job, nil return job, nil
} }
func scanAsset(row postScanner) (Asset, error) { func scanAuditLog(row postScanner) (AuditLog, error) {
var asset Asset var log AuditLog
var createdBy sql.NullString var actorID sql.NullString
var details []byte
err := row.Scan( err := row.Scan(
&asset.ID, &log.ID,
&asset.Path, &actorID,
&asset.OriginalName, &log.ActorUsername,
&asset.MimeType, &log.Action,
&asset.SizeBytes, &log.ResourceType,
&asset.SHA256, &log.ResourceID,
&asset.CreatedAt, &log.IPAddress,
&createdBy, &log.UserAgent,
&details,
&log.CreatedAt,
) )
if err != nil { if err != nil {
return Asset{}, err return AuditLog{}, err
} }
asset.CreatedBy = nullStringPtr(createdBy) log.ActorID = nullStringPtr(actorID)
return asset, nil if len(details) > 0 {
if err := json.Unmarshal(details, &log.Details); err != nil {
return AuditLog{}, fmt.Errorf("decode audit details: %w", err)
}
}
if log.Details == nil {
log.Details = map[string]any{}
}
return log, nil
} }
func nullTimePtr(value sql.NullTime) *time.Time { func nullTimePtr(value sql.NullTime) *time.Time {

View file

@ -76,17 +76,6 @@ type Tag struct {
UpdatedAt time.Time `json:"updatedAt"` 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 { type BuildJob struct {
ID string `json:"id"` ID string `json:"id"`
Trigger BuildJobTrigger `json:"trigger"` Trigger BuildJobTrigger `json:"trigger"`
@ -99,3 +88,16 @@ type BuildJob struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
CreatedBy *string `json:"createdBy"` CreatedBy *string `json:"createdBy"`
} }
type AuditLog struct {
ID string `json:"id"`
ActorID *string `json:"actorId"`
ActorUsername string `json:"actorUsername"`
Action string `json:"action"`
ResourceType string `json:"resourceType"`
ResourceID string `json:"resourceId"`
IPAddress string `json:"ipAddress"`
UserAgent string `json:"userAgent"`
Details map[string]any `json:"details"`
CreatedAt time.Time `json:"createdAt"`
}

View file

@ -1,88 +0,0 @@
package cli
import (
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"osaet/backend/internal/staticserver"
)
func runBuild(root string, args []string) error {
fs := flag.NewFlagSet("build", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
if err := fs.Parse(args); err != nil {
return err
}
sitePath := *siteDir
if !filepath.IsAbs(sitePath) {
sitePath = filepath.Join(root, sitePath)
}
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
return fmt.Errorf("Astro project not found at %s; run from repo root", *siteDir)
}
cmd := exec.Command("npm", "run", "build")
cmd.Dir = sitePath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func runDev(root string, args []string) error {
fs := flag.NewFlagSet("dev", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
host := fs.String("host", "127.0.0.1", "host to bind")
port := fs.String("port", "4321", "port to listen on")
siteDir := fs.String("site-dir", defaultAstroDir, "Astro project directory")
if err := fs.Parse(args); err != nil {
return err
}
sitePath := *siteDir
if !filepath.IsAbs(sitePath) {
sitePath = filepath.Join(root, sitePath)
}
if _, err := os.Stat(filepath.Join(sitePath, "package.json")); err != nil {
return fmt.Errorf("Astro project not found at %s; run `osaetctl init` first", *siteDir)
}
cmd := exec.Command("npm", "run", "dev", "--", "--host", *host, "--port", *port)
cmd.Dir = sitePath
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func runServe(root string, args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
host := fs.String("host", "127.0.0.1", "host to bind")
port := fs.String("port", "4321", "port to listen on")
dir := fs.String("dir", defaultBuildOutDir, "static output directory")
if err := fs.Parse(args); err != nil {
return err
}
staticDir := *dir
if !filepath.IsAbs(staticDir) {
staticDir = filepath.Join(root, staticDir)
}
if _, err := os.Stat(staticDir); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("static directory %s does not exist; run `osaetctl build` first", *dir)
}
return err
}
return staticserver.Serve(staticDir, *host, *port)
}

View file

@ -1,103 +0,0 @@
package cli
import (
"flag"
"fmt"
"os"
"path/filepath"
)
func Run(args []string) error {
if len(args) == 0 {
printUsage()
return nil
}
root, err := findProjectRoot()
if err != nil {
return err
}
switch args[0] {
case "init":
return runInit(root, args[1:])
case "posts":
return runPosts(root, args[1:])
case "tags":
return runTags(root, args[1:])
case "db":
return runDB(root, args[1:])
case "config":
return runConfig(root, args[1:])
case "build":
return runBuild(root, args[1:])
case "serve":
return runServe(root, args[1:])
case "dev":
return runDev(root, args[1:])
case "help", "-h", "--help":
printUsage()
return nil
default:
return fmt.Errorf("unknown command %q", args[0])
}
}
func runInit(root string, args []string) error {
fs := flag.NewFlagSet("init", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
dirs := []string{
"config",
defaultPostsDir,
defaultAssetsDir,
".osaet",
defaultAstroDir,
defaultBuildOutDir,
}
for _, dir := range dirs {
if err := os.MkdirAll(filepath.Join(root, dir), 0o755); err != nil {
return err
}
}
if err := writeFileIfMissing(filepath.Join(root, "config/site.yaml"), defaultSiteConfig()); err != nil {
return err
}
if err := writeFileIfMissing(filepath.Join(root, "config/local.example.yaml"), defaultLocalExampleConfig()); err != nil {
return err
}
fmt.Println("initialized local project structure")
return nil
}
func printUsage() {
fmt.Print(`osaetctl manages local blog content and static builds.
Usage:
osaetctl init
osaetctl posts slug --title "My Post" [--summary "..."]
osaetctl posts new --title "My Post" [--slug my-post|--no-ai-slug] [--tag go] [--summary "..."] [--status draft]
osaetctl posts list [--status draft|published]
osaetctl posts show <slug>
osaetctl posts publish <slug>
osaetctl posts unpublish <slug>
osaetctl posts delete <slug>
osaetctl posts edit <slug>
osaetctl posts import [--db .osaet/osaet.db]
osaetctl posts export [--db .osaet/osaet.db] [--overwrite]
osaetctl posts diff [--db .osaet/osaet.db]
osaetctl posts sync [--from files|db|auto] [--yes] [--db .osaet/osaet.db]
osaetctl tags list [--all]
osaetctl db init [--path .osaet/osaet.db]
osaetctl db status [--path .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

@ -1,106 +0,0 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
func runConfig(root string, args []string) error {
if len(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) {
var config localConfig
path := filepath.Join(root, "config/local.yaml")
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return config, nil
}
return config, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return config, fmt.Errorf("%s: %w", path, err)
}
return config, nil
}
func readSiteConfig(root string) (siteConfigFile, error) {
var config siteConfigFile
path := filepath.Join(root, "config/site.yaml")
data, err := os.ReadFile(path)
if err != nil {
return config, err
}
if err := yaml.Unmarshal(data, &config); err != nil {
return config, fmt.Errorf("%s: %w", path, err)
}
return config, nil
}
func writeSiteConfig(root string, config siteConfigFile) error {
var out bytes.Buffer
encoder := yaml.NewEncoder(&out)
encoder.SetIndent(2)
if err := encoder.Encode(config); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
path := filepath.Join(root, "config/site.yaml")
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, out.Bytes(), 0o644); err != nil {
return err
}
return os.Rename(tmp, path)
}
func defaultSiteConfig() string {
return `meta:
config_version: 1
updated_at: "2026-05-28T12:00:00+08:00"
updated_by: "cli"
site:
title: "Osaet"
description: "Personal blog"
base_url: "http://localhost:4321"
language: "zh-CN"
timezone: "Asia/Shanghai"
content:
posts_dir: "content/posts"
assets_dir: "content/assets"
build:
astro_project: "frontend/site"
output_dir: "dist/site"
`
}
func defaultLocalExampleConfig() string {
return `database:
driver: "sqlite"
sqlite_path: ".osaet/osaet.db"
postgres_dsn: ""
deepseek:
api_key: ""
api_key_env: "DEEPSEEK_API_KEY"
base_url: "https://api.deepseek.com"
model: "deepseek-v4-pro"
`
}

View file

@ -1,27 +0,0 @@
package cli
import (
"strings"
"testing"
)
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)
}
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)
}
}
func TestParseTime(t *testing.T) {
if _, ok := parseTime("2026-05-28T12:00:00+08:00"); !ok {
t.Fatal("expected RFC3339 time to parse")
}
if _, ok := parseTime("not-time"); ok {
t.Fatal("expected invalid time")
}
}

View file

@ -1,130 +0,0 @@
package cli
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func loadPosts(root string) ([]postFile, error) {
postsDir := filepath.Join(root, defaultPostsDir)
entries, err := os.ReadDir(postsDir)
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
}
post, err := readPostFile(filepath.Join(postsDir, entry.Name()))
if err != nil {
return nil, err
}
posts = append(posts, post)
}
return posts, nil
}
func loadPostBySlug(root string, slug string) (postFile, error) {
cleanSlug := sanitizeSlug(slug)
if cleanSlug == "" {
return postFile{}, errors.New("missing slug")
}
posts, err := loadPosts(root)
if err != nil {
return postFile{}, err
}
for _, post := range posts {
fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md")
if post.Frontmatter.Slug == cleanSlug || fileSlug == cleanSlug {
return post, nil
}
}
return postFile{}, fmt.Errorf("post not found: %s", cleanSlug)
}
func readPostFile(path string) (postFile, error) {
data, err := os.ReadFile(path)
if err != nil {
return postFile{}, err
}
frontmatter, body, err := splitFrontmatter(data)
if err != nil {
return postFile{}, fmt.Errorf("%s: %w", path, err)
}
var meta postFrontmatter
if err := yaml.Unmarshal(frontmatter, &meta); err != nil {
return postFile{}, fmt.Errorf("%s: %w", path, err)
}
if meta.Slug == "" {
meta.Slug = strings.TrimSuffix(filepath.Base(path), ".md")
}
if meta.Status == "" {
meta.Status = "draft"
}
return postFile{
Path: path,
Frontmatter: meta,
Body: strings.TrimPrefix(string(body), "\n"),
}, nil
}
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 splitFrontmatter(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
}

View file

@ -1,146 +0,0 @@
package cli
import (
"database/sql"
_ "embed"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
_ "modernc.org/sqlite"
)
//go:embed sqlite_schema.sql
var sqliteSchema string
func runDB(root string, args []string) error {
if len(args) == 0 {
return errors.New("missing db subcommand")
}
switch args[0] {
case "init":
return runDBInit(root, args[1:])
case "status":
return runDBStatus(root, args[1:])
default:
return fmt.Errorf("unknown db subcommand %q", args[0])
}
}
func runDBInit(root string, args []string) error {
fs := flag.NewFlagSet("db init", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
path := fs.String("path", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *path)
if err := os.MkdirAll(filepath.Dir(dbPath), 0o755); err != nil {
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
return err
}
fmt.Printf("initialized SQLite database: %s\n", mustRel(root, dbPath))
return nil
}
func runDBStatus(root string, args []string) error {
fs := flag.NewFlagSet("db status", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
path := fs.String("path", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *path)
info, err := os.Stat(dbPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
fmt.Printf("database: %s\n", mustRel(root, dbPath))
fmt.Println("exists: no")
return nil
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
fmt.Printf("database: %s\n", mustRel(root, dbPath))
fmt.Println("exists: yes")
fmt.Printf("size: %d bytes\n", info.Size())
for _, table := range []string{"posts", "settings", "sync_state"} {
ok, err := sqliteTableExists(db, table)
if err != nil {
return err
}
fmt.Printf("table %-10s %s\n", table+":", yesNo(ok))
}
return nil
}
func openSQLite(path string) (*sql.DB, error) {
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
return db, nil
}
func openProjectSQLite(root string, path string) (*sql.DB, string, error) {
dbPath := resolveRootPath(root, path)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, "", fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return nil, "", err
}
db, err := openSQLite(dbPath)
if err != nil {
return nil, "", err
}
if err := applySQLiteSchema(db); err != nil {
db.Close()
return nil, "", err
}
return db, dbPath, nil
}
func applySQLiteSchema(db *sql.DB) error {
for _, statement := range strings.Split(sqliteSchema, ";") {
statement = strings.TrimSpace(statement)
if statement == "" {
continue
}
if _, err := db.Exec(statement); err != nil {
return err
}
}
return nil
}
func sqliteTableExists(db *sql.DB, table string) (bool, error) {
var count int
err := db.QueryRow(`SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&count)
return count > 0, err
}

View file

@ -1,57 +0,0 @@
package cli
import (
"os"
"path/filepath"
"testing"
)
func TestReadWritePostFile(t *testing.T) {
dir := t.TempDir()
publishedAt := "2026-05-28T12:00:00+08:00"
post := postFile{
Path: filepath.Join(dir, "hello.md"),
Frontmatter: postFrontmatter{
ID: "post-1",
Slug: "hello",
Title: "Hello",
Summary: "Summary",
Status: "published",
Tags: []string{"go", "astro"},
Version: 2,
SlugSource: "manual",
PublishedAt: &publishedAt,
CreatedAt: "2026-05-28T11:00:00+08:00",
UpdatedAt: "2026-05-28T12:00:00+08:00",
},
Body: "Body\n",
}
if err := writePostFile(post); err != nil {
t.Fatal(err)
}
got, err := readPostFile(post.Path)
if err != nil {
t.Fatal(err)
}
if got.Frontmatter.ID != post.Frontmatter.ID || got.Frontmatter.Slug != post.Frontmatter.Slug {
t.Fatalf("frontmatter mismatch: %#v", got.Frontmatter)
}
if got.Body != post.Body {
t.Fatalf("body = %q, want %q", got.Body, post.Body)
}
if len(got.Frontmatter.Tags) != 2 || got.Frontmatter.Tags[1] != "astro" {
t.Fatalf("tags = %#v", got.Frontmatter.Tags)
}
}
func TestReadPostFileRequiresFrontmatter(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.md")
if err := os.WriteFile(path, []byte("no frontmatter"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := readPostFile(path); err == nil {
t.Fatal("expected frontmatter error")
}
}

View file

@ -1,368 +0,0 @@
package cli
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"osaet/backend/internal/ai"
)
func runPosts(root string, args []string) error {
if len(args) == 0 {
return errors.New("missing posts subcommand")
}
switch args[0] {
case "new":
return runPostsNew(root, args[1:])
case "slug":
return runPostsSlug(root, args[1:])
case "list":
return runPostsList(root, args[1:])
case "show":
return runPostsShow(root, args[1:])
case "publish":
return runPostsStatus(root, args[1:], "published")
case "unpublish":
return runPostsStatus(root, args[1:], "draft")
case "delete":
return runPostsDelete(root, args[1:])
case "edit":
return runPostsEdit(root, args[1:])
case "import":
return runPostsImport(root, args[1:])
case "export":
return runPostsExport(root, args[1:])
case "diff":
return runPostsDiff(root, args[1:])
case "sync":
return runPostsSync(root, args[1:])
default:
return fmt.Errorf("unknown posts subcommand %q", args[0])
}
}
func runTags(root string, args []string) error {
if len(args) == 0 {
return errors.New("missing tags subcommand")
}
switch args[0] {
case "list":
return runTagsList(root, args[1:])
default:
return fmt.Errorf("unknown tags subcommand %q", args[0])
}
}
func runTagsList(root string, args []string) error {
fs := flag.NewFlagSet("tags list", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
all := fs.Bool("all", false, "include draft posts")
if err := fs.Parse(args); err != nil {
return err
}
posts, err := loadPosts(root)
if err != nil {
return err
}
counts := map[string]int{}
for _, post := range posts {
if !*all && post.Frontmatter.Status != "published" {
continue
}
for _, tag := range post.Frontmatter.Tags {
tag = strings.TrimSpace(tag)
if tag != "" {
counts[tag]++
}
}
}
for _, tag := range sortedKeys(counts) {
fmt.Printf("%-24s %d\n", tag, counts[tag])
}
return nil
}
func runPostsNew(root string, args []string) error {
fs := flag.NewFlagSet("posts new", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
title := fs.String("title", "", "post title")
slug := fs.String("slug", "", "post slug")
status := fs.String("status", "draft", "post status: draft or published")
summary := fs.String("summary", "", "post summary")
body := fs.String("body", "", "initial markdown body")
aiSlug := fs.Bool("ai-slug", true, "generate slug with DeepSeek when --slug is empty")
noAISlug := fs.Bool("no-ai-slug", false, "disable AI slug generation")
tags := stringListFlag{}
fs.Var(&tags, "tag", "post tag; may be repeated")
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*title) == "" {
return errors.New("missing required --title")
}
cleanSlug := ""
slugSource := "manual"
if strings.TrimSpace(*slug) != "" {
cleanSlug = sanitizeSlug(*slug)
} else if *aiSlug && !*noAISlug {
generatedSlug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
if err == nil {
cleanSlug = generatedSlug
slugSource = "ai"
} else {
fmt.Fprintf(os.Stderr, "warning: AI slug generation failed, using local fallback: %v\n", err)
}
}
if cleanSlug == "" {
cleanSlug = fallbackSlug(*title)
}
if cleanSlug == "" {
return errors.New("could not derive slug; pass --slug")
}
cleanSlug, err := uniqueSlug(root, cleanSlug)
if err != nil {
return err
}
if *status != "draft" && *status != "published" {
return errors.New("--status must be draft or published")
}
postsDir := filepath.Join(root, defaultPostsDir)
if err := os.MkdirAll(postsDir, 0o755); err != nil {
return err
}
path, err := uniquePostPath(postsDir, cleanSlug)
if err != nil {
return err
}
now := time.Now().Format(time.RFC3339)
publishedAt := "null"
if *status == "published" {
publishedAt = fmt.Sprintf("%q", now)
}
content := strings.TrimSpace(*body)
if content == "" {
content = "Write your post here."
}
post := fmt.Sprintf(`---
id: "%s"
slug: "%s"
title: "%s"
summary: "%s"
status: "%s"
tags: %s
cover: ""
version: 1
slug_source: "%s"
slug_locked: false
published_at: %s
created_at: "%s"
updated_at: "%s"
---
%s
`, randomID(), cleanSlug, escapeYAML(*title), escapeYAML(*summary), *status, formatYAMLStringList(tags.Values()), slugSource, publishedAt, now, now, content)
if err := os.WriteFile(path, []byte(post), 0o644); err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = path
}
fmt.Println(rel)
return nil
}
func runPostsSlug(root string, args []string) error {
fs := flag.NewFlagSet("posts slug", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
title := fs.String("title", "", "post title")
summary := fs.String("summary", "", "optional post summary")
if err := fs.Parse(args); err != nil {
return err
}
if strings.TrimSpace(*title) == "" {
return errors.New("missing required --title")
}
slug, err := generateDeepSeekSlug(context.Background(), root, *title, *summary)
if err != nil {
return err
}
slug, err = uniqueSlug(root, slug)
if err != nil {
return err
}
fmt.Println(slug)
return nil
}
func runPostsList(root string, args []string) error {
fs := flag.NewFlagSet("posts list", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
status := fs.String("status", "", "filter by status")
if err := fs.Parse(args); err != nil {
return err
}
posts, err := loadPosts(root)
if err != nil {
return err
}
for _, post := range posts {
if *status != "" && post.Frontmatter.Status != *status {
continue
}
fmt.Printf("%-12s %-32s %-24s %s\n", post.Frontmatter.Status, post.Frontmatter.Slug, strings.Join(post.Frontmatter.Tags, ","), post.Frontmatter.Title)
}
return nil
}
func runPostsShow(root string, args []string) error {
if len(args) != 1 {
return errors.New("usage: osaetctl posts show <slug>")
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
fmt.Printf("title: %s\n", post.Frontmatter.Title)
fmt.Printf("slug: %s\n", post.Frontmatter.Slug)
fmt.Printf("status: %s\n", post.Frontmatter.Status)
fmt.Printf("summary: %s\n", post.Frontmatter.Summary)
fmt.Printf("tags: %s\n", strings.Join(post.Frontmatter.Tags, ", "))
fmt.Printf("path: %s\n", mustRel(root, post.Path))
fmt.Println()
fmt.Println(post.Body)
return nil
}
func runPostsStatus(root string, args []string, status string) error {
if len(args) != 1 {
return fmt.Errorf("usage: osaetctl posts %s <slug>", statusCommand(status))
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
now := time.Now().Format(time.RFC3339)
post.Frontmatter.Status = status
post.Frontmatter.UpdatedAt = now
post.Frontmatter.Version++
if status == "published" && post.Frontmatter.PublishedAt == nil {
post.Frontmatter.PublishedAt = &now
}
if status == "draft" {
post.Frontmatter.PublishedAt = nil
}
if err := writePostFile(post); err != nil {
return err
}
fmt.Printf("%s -> %s\n", post.Frontmatter.Slug, status)
return nil
}
func runPostsDelete(root string, args []string) error {
if len(args) != 1 {
return errors.New("usage: osaetctl posts delete <slug>")
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
trashDir := filepath.Join(root, defaultTrashDir)
if err := os.MkdirAll(trashDir, 0o755); err != nil {
return err
}
target := uniquePath(filepath.Join(trashDir, filepath.Base(post.Path)))
if err := os.Rename(post.Path, target); err != nil {
return err
}
fmt.Printf("%s -> %s\n", mustRel(root, post.Path), mustRel(root, target))
return nil
}
func runPostsEdit(root string, args []string) error {
if len(args) != 1 {
return errors.New("usage: osaetctl posts edit <slug>")
}
editor := strings.TrimSpace(os.Getenv("EDITOR"))
if editor == "" {
return errors.New("EDITOR is not set")
}
post, err := loadPostBySlug(root, args[0])
if err != nil {
return err
}
cmd := exec.Command(editor, post.Path)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
return cmd.Run()
}
func generateDeepSeekSlug(ctx context.Context, root string, title string, summary string) (string, error) {
config, err := loadLocalConfig(root)
if err != nil {
return "", err
}
apiKeyEnv := strings.TrimSpace(config.DeepSeek.APIKeyEnv)
if apiKeyEnv == "" {
apiKeyEnv = "DEEPSEEK_API_KEY"
}
apiKey := strings.TrimSpace(os.Getenv(apiKeyEnv))
if apiKey == "" {
apiKey = strings.TrimSpace(config.DeepSeek.APIKey)
}
if apiKey == "" {
return "", fmt.Errorf("%s is not set and config/local.yaml deepseek.api_key is empty", apiKeyEnv)
}
baseURL := strings.TrimRight(strings.TrimSpace(os.Getenv("DEEPSEEK_BASE_URL")), "/")
if baseURL == "" {
baseURL = strings.TrimRight(strings.TrimSpace(config.DeepSeek.BaseURL), "/")
}
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = strings.TrimSpace(config.DeepSeek.Model)
}
return ai.GenerateSlug(ctx, ai.Config{
APIKey: apiKey,
BaseURL: baseURL,
Model: model,
}, title, summary)
}

View file

@ -1,38 +0,0 @@
package cli
import "testing"
func TestSanitizeSlug(t *testing.T) {
tests := map[string]string{
"Hello Astro": "hello-astro",
" Go / Astro_Blog! ": "go-astro-blog",
"Already--Clean": "already-clean",
"喜欢你": "",
"abc123": "abc123",
}
for input, want := range tests {
if got := sanitizeSlug(input); got != want {
t.Fatalf("sanitizeSlug(%q) = %q, want %q", input, got, want)
}
}
}
func TestFallbackSlug(t *testing.T) {
if got := fallbackSlug("Hello Astro"); got != "hello-astro" {
t.Fatalf("fallbackSlug english = %q", got)
}
got := fallbackSlug("喜欢你")
if len(got) <= len("post-") || got[:5] != "post-" {
t.Fatalf("fallbackSlug non-ascii = %q, want post-*", got)
}
}
func TestFormatYAMLStringList(t *testing.T) {
got := formatYAMLStringList([]string{"go", `astro "site"`})
want := `["go", "astro \"site\""]`
if got != want {
t.Fatalf("formatYAMLStringList = %q, want %q", got, want)
}
}

View file

@ -1,35 +0,0 @@
CREATE TABLE IF NOT EXISTS posts (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
summary TEXT NOT NULL DEFAULT '',
content_markdown TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
tags_json TEXT NOT NULL DEFAULT '[]',
cover TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
version INTEGER NOT NULL DEFAULT 1,
content_hash TEXT NOT NULL DEFAULT '',
slug_source TEXT NOT NULL DEFAULT 'manual',
slug_locked INTEGER NOT NULL DEFAULT 0,
published_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_posts_status ON posts(status);
CREATE INDEX IF NOT EXISTS idx_posts_published_at ON posts(published_at);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);

View file

@ -1,68 +0,0 @@
package cli
import (
"path/filepath"
"testing"
)
func TestSQLitePostImportLoadExport(t *testing.T) {
root := t.TempDir()
db, err := openSQLite(filepath.Join(root, ".osaet.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
t.Fatal(err)
}
publishedAt := "2026-05-28T12:00:00+08:00"
post := postFile{
Path: filepath.Join(root, defaultPostsDir, "hello.md"),
Frontmatter: postFrontmatter{
ID: "post-1",
Slug: "hello",
Title: "Hello",
Status: "published",
Tags: []string{"go"},
Version: 1,
SlugSource: "manual",
PublishedAt: &publishedAt,
CreatedAt: "2026-05-28T11:00:00+08:00",
UpdatedAt: "2026-05-28T12:00:00+08:00",
},
Body: "Body\n",
}
if err := upsertSQLitePost(root, db, post); err != nil {
t.Fatal(err)
}
posts, err := loadSQLitePosts(db)
if err != nil {
t.Fatal(err)
}
if len(posts) != 1 {
t.Fatalf("loaded %d posts, want 1", len(posts))
}
if posts[0].Frontmatter.Slug != "hello" || posts[0].Body != "Body\n" {
t.Fatalf("loaded post mismatch: %#v", posts[0])
}
exported, skipped, err := exportPostsToFilesCount(root, posts, false)
if err != nil {
t.Fatal(err)
}
if exported != 1 || skipped != 0 {
t.Fatalf("exported=%d skipped=%d", exported, skipped)
}
read, err := readPostFile(filepath.Join(root, defaultPostsDir, "hello.md"))
if err != nil {
t.Fatal(err)
}
if read.Frontmatter.Title != "Hello" {
t.Fatalf("exported title = %q", read.Frontmatter.Title)
}
}

View file

@ -1,558 +0,0 @@
package cli
import (
"database/sql"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
func runPostsImport(root string, args []string) error {
fs := flag.NewFlagSet("posts import", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
return err
}
posts, err := loadPosts(root)
if err != nil {
return err
}
imported := 0
for _, post := range posts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
if post.Frontmatter.Slug == "" {
return fmt.Errorf("%s: missing slug", mustRel(root, post.Path))
}
if err := upsertSQLitePost(root, db, post); err != nil {
return err
}
imported++
}
fmt.Printf("imported %d post(s) into %s\n", imported, mustRel(root, dbPath))
return nil
}
func runPostsExport(root string, args []string) error {
fs := flag.NewFlagSet("posts export", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
overwrite := fs.Bool("overwrite", false, "overwrite existing Markdown files")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
posts, err := loadSQLitePosts(db)
if err != nil {
return err
}
exported, skipped, err := exportPostsToFilesCount(root, posts, *overwrite)
if err != nil {
return err
}
fmt.Printf("exported %d post(s), skipped %d existing file(s)\n", exported, skipped)
return nil
}
func runPostsDiff(root string, args []string) error {
fs := flag.NewFlagSet("posts diff", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
return printPostsDiff(root, db)
}
func printPostsDiff(root string, db *sql.DB) error {
filePosts, err := loadPosts(root)
if err != nil {
return err
}
dbPosts, err := loadSQLitePosts(db)
if err != nil {
return err
}
fileByID := map[string]postFile{}
dbByID := map[string]postFile{}
for _, post := range filePosts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
fileByID[post.Frontmatter.ID] = post
}
for _, post := range dbPosts {
dbByID[post.Frontmatter.ID] = post
}
ids := map[string]bool{}
for id := range fileByID {
ids[id] = true
}
for id := range dbByID {
ids[id] = true
}
summary := map[string]int{}
for _, id := range sortedBoolKeys(ids) {
filePost, inFile := fileByID[id]
dbPost, inDB := dbByID[id]
switch {
case inFile && !inDB:
summary["only-file"]++
fmt.Printf("only-file %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
case !inFile && inDB:
summary["only-db"]++
fmt.Printf("only-db %-32s %s\n", dbPost.Frontmatter.Slug, dbPost.Frontmatter.Title)
default:
if postsEquivalent(root, filePost, dbPost) {
summary["same"]++
} else {
summary["changed"]++
fmt.Printf("changed %-32s %s\n", filePost.Frontmatter.Slug, filePost.Frontmatter.Title)
printPostDiffFields(root, filePost, dbPost)
}
}
}
fmt.Printf("summary: same=%d changed=%d only-file=%d only-db=%d\n",
summary["same"],
summary["changed"],
summary["only-file"],
summary["only-db"],
)
return nil
}
func runPostsSync(root string, args []string) error {
fs := flag.NewFlagSet("posts sync", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
dbPathFlag := fs.String("db", defaultSQLitePath, "SQLite database path")
from := fs.String("from", "", "sync source: files, db, or auto")
yes := fs.Bool("yes", false, "confirm automatic sync without prompting")
if err := fs.Parse(args); err != nil {
return err
}
dbPath := resolveRootPath(root, *dbPathFlag)
if _, err := os.Stat(dbPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("database does not exist: %s; run `osaetctl db init` first", mustRel(root, dbPath))
}
return err
}
db, err := openSQLite(dbPath)
if err != nil {
return err
}
defer db.Close()
if err := applySQLiteSchema(db); err != nil {
return err
}
switch *from {
case "files":
return syncFromFiles(root, db, dbPath)
case "db":
return syncFromDB(root, db)
case "auto":
return syncAuto(root, db)
case "":
if err := printPostsDiff(root, db); err != nil {
return err
}
if !*yes && !confirm("Auto Sync by newer updated_at?") {
fmt.Println("No changes applied.")
fmt.Println("Use `osaetctl posts sync --from files` to write Markdown into SQLite.")
fmt.Println("Use `osaetctl posts sync --from db` to write SQLite into Markdown.")
return nil
}
return syncAuto(root, db)
default:
return errors.New("--from must be files, db, or auto")
}
}
func syncFromFiles(root string, db *sql.DB, dbPath string) error {
posts, err := loadPosts(root)
if err != nil {
return err
}
for _, post := range posts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
if err := upsertSQLitePost(root, db, post); err != nil {
return err
}
}
fmt.Printf("synced %d file post(s) into %s\n", len(posts), mustRel(root, dbPath))
return nil
}
func syncFromDB(root string, db *sql.DB) error {
posts, err := loadSQLitePosts(db)
if err != nil {
return err
}
if err := exportPostsToFiles(root, posts, true); err != nil {
return err
}
fmt.Printf("synced %d db post(s) into Markdown files\n", len(posts))
return nil
}
func syncAuto(root string, db *sql.DB) error {
filePosts, err := loadPosts(root)
if err != nil {
return err
}
dbPosts, err := loadSQLitePosts(db)
if err != nil {
return err
}
fileByID := map[string]postFile{}
dbByID := map[string]postFile{}
ids := map[string]bool{}
for _, post := range filePosts {
if post.Frontmatter.ID == "" {
return fmt.Errorf("%s: missing id", mustRel(root, post.Path))
}
fileByID[post.Frontmatter.ID] = post
ids[post.Frontmatter.ID] = true
}
for _, post := range dbPosts {
dbByID[post.Frontmatter.ID] = post
ids[post.Frontmatter.ID] = true
}
filesToDB := 0
dbToFiles := 0
for _, id := range sortedBoolKeys(ids) {
filePost, inFile := fileByID[id]
dbPost, inDB := dbByID[id]
switch {
case inFile && !inDB:
if err := upsertSQLitePost(root, db, filePost); err != nil {
return err
}
filesToDB++
case !inFile && inDB:
if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
return err
}
dbToFiles++
case inFile && inDB:
if postsEquivalent(root, filePost, dbPost) {
continue
}
fileTime, fileOK := parseTime(filePost.Frontmatter.UpdatedAt)
dbTime, dbOK := parseTime(dbPost.Frontmatter.UpdatedAt)
if !fileOK && !dbOK {
fmt.Printf("skipped %s: cannot compare updated_at\n", filePost.Frontmatter.Slug)
continue
}
if fileOK && (!dbOK || fileTime.After(dbTime)) {
if err := upsertSQLitePost(root, db, filePost); err != nil {
return err
}
filesToDB++
} else if err := exportPostsToFiles(root, []postFile{dbPost}, true); err != nil {
return err
} else {
dbToFiles++
}
}
}
fmt.Printf("auto sync complete: files->db=%d db->files=%d\n", filesToDB, dbToFiles)
return nil
}
func upsertSQLitePost(root string, db *sql.DB, post postFile) error {
tagsJSON, err := json.Marshal(post.Frontmatter.Tags)
if err != nil {
return err
}
publishedAt := sql.NullString{}
if post.Frontmatter.PublishedAt != nil && strings.TrimSpace(*post.Frontmatter.PublishedAt) != "" {
publishedAt.Valid = true
publishedAt.String = *post.Frontmatter.PublishedAt
}
_, err = db.Exec(`INSERT INTO posts (
id,
slug,
title,
summary,
content_markdown,
status,
tags_json,
cover,
file_path,
version,
content_hash,
slug_source,
slug_locked,
published_at,
created_at,
updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
slug = excluded.slug,
title = excluded.title,
summary = excluded.summary,
content_markdown = excluded.content_markdown,
status = excluded.status,
tags_json = excluded.tags_json,
cover = excluded.cover,
file_path = excluded.file_path,
version = excluded.version,
content_hash = excluded.content_hash,
slug_source = excluded.slug_source,
slug_locked = excluded.slug_locked,
published_at = excluded.published_at,
created_at = excluded.created_at,
updated_at = excluded.updated_at,
deleted_at = NULL`,
post.Frontmatter.ID,
post.Frontmatter.Slug,
post.Frontmatter.Title,
post.Frontmatter.Summary,
post.Body,
post.Frontmatter.Status,
string(tagsJSON),
post.Frontmatter.Cover,
mustRel(root, post.Path),
post.Frontmatter.Version,
contentHash(post.Body),
post.Frontmatter.SlugSource,
boolInt(post.Frontmatter.SlugLocked),
publishedAt,
post.Frontmatter.CreatedAt,
post.Frontmatter.UpdatedAt,
)
return err
}
func loadSQLitePosts(db *sql.DB) ([]postFile, error) {
rows, err := db.Query(`SELECT
id,
slug,
title,
summary,
content_markdown,
status,
tags_json,
cover,
file_path,
version,
slug_source,
slug_locked,
published_at,
created_at,
updated_at
FROM posts
WHERE deleted_at IS NULL
ORDER BY COALESCE(published_at, updated_at) DESC, title ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []postFile
for rows.Next() {
var post postFile
var tagsJSON string
var publishedAt sql.NullString
var slugLocked int
if err := rows.Scan(
&post.Frontmatter.ID,
&post.Frontmatter.Slug,
&post.Frontmatter.Title,
&post.Frontmatter.Summary,
&post.Body,
&post.Frontmatter.Status,
&tagsJSON,
&post.Frontmatter.Cover,
&post.Path,
&post.Frontmatter.Version,
&post.Frontmatter.SlugSource,
&slugLocked,
&publishedAt,
&post.Frontmatter.CreatedAt,
&post.Frontmatter.UpdatedAt,
); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(tagsJSON), &post.Frontmatter.Tags); err != nil {
return nil, err
}
if publishedAt.Valid {
post.Frontmatter.PublishedAt = &publishedAt.String
}
post.Frontmatter.SlugLocked = slugLocked != 0
posts = append(posts, post)
}
if err := rows.Err(); err != nil {
return nil, err
}
return posts, nil
}
func exportPostsToFiles(root string, posts []postFile, overwrite bool) error {
_, _, err := exportPostsToFilesCount(root, posts, overwrite)
return err
}
func exportPostsToFilesCount(root string, posts []postFile, overwrite bool) (int, int, error) {
postsDir := filepath.Join(root, defaultPostsDir)
if err := os.MkdirAll(postsDir, 0o755); err != nil {
return 0, 0, err
}
exported := 0
skipped := 0
for _, post := range posts {
if post.Frontmatter.Slug == "" {
return exported, skipped, fmt.Errorf("post %s has empty slug", post.Frontmatter.ID)
}
path := filepath.Join(postsDir, sanitizeSlug(post.Frontmatter.Slug)+".md")
post.Path = path
if _, err := os.Stat(path); err == nil && !overwrite {
skipped++
continue
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return exported, skipped, err
}
if err := writePostFile(post); err != nil {
return exported, skipped, err
}
exported++
}
return exported, skipped, nil
}
func postsEquivalent(root string, filePost postFile, dbPost postFile) bool {
return len(changedPostFields(root, filePost, dbPost)) == 0
}
func printPostDiffFields(root string, filePost postFile, dbPost postFile) {
for _, field := range changedPostFields(root, filePost, dbPost) {
fmt.Printf(" - %s\n", field)
}
}
func changedPostFields(root string, filePost postFile, dbPost postFile) []string {
var fields []string
if filePost.Frontmatter.Slug != dbPost.Frontmatter.Slug {
fields = append(fields, "slug")
}
if filePost.Frontmatter.Title != dbPost.Frontmatter.Title {
fields = append(fields, "title")
}
if filePost.Frontmatter.Summary != dbPost.Frontmatter.Summary {
fields = append(fields, "summary")
}
if filePost.Frontmatter.Status != dbPost.Frontmatter.Status {
fields = append(fields, "status")
}
if strings.Join(filePost.Frontmatter.Tags, "\x00") != strings.Join(dbPost.Frontmatter.Tags, "\x00") {
fields = append(fields, "tags")
}
if filePost.Frontmatter.Cover != dbPost.Frontmatter.Cover {
fields = append(fields, "cover")
}
if filePost.Frontmatter.Version != dbPost.Frontmatter.Version {
fields = append(fields, "version")
}
if filePost.Frontmatter.SlugSource != dbPost.Frontmatter.SlugSource {
fields = append(fields, "slug_source")
}
if filePost.Frontmatter.SlugLocked != dbPost.Frontmatter.SlugLocked {
fields = append(fields, "slug_locked")
}
if stringPtrValue(filePost.Frontmatter.PublishedAt) != stringPtrValue(dbPost.Frontmatter.PublishedAt) {
fields = append(fields, "published_at")
}
if filePost.Frontmatter.CreatedAt != dbPost.Frontmatter.CreatedAt {
fields = append(fields, "created_at")
}
if filePost.Frontmatter.UpdatedAt != dbPost.Frontmatter.UpdatedAt {
fields = append(fields, "updated_at")
}
if contentHash(filePost.Body) != contentHash(dbPost.Body) {
fields = append(fields, "content")
}
filePath := mustRel(root, filePost.Path)
if dbPost.Path != "" && filePath != dbPost.Path {
fields = append(fields, "file_path")
}
return fields
}

View file

@ -1,87 +0,0 @@
package cli
import "strings"
const (
defaultPostsDir = "content/posts"
defaultAssetsDir = "content/assets"
defaultTrashDir = "content/.trash/posts"
defaultAstroDir = "frontend/site"
defaultBuildOutDir = "dist/site"
defaultSQLitePath = ".osaet/osaet.db"
)
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 localConfig struct {
DeepSeek struct {
APIKey string `yaml:"api_key"`
APIKeyEnv string `yaml:"api_key_env"`
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
} `yaml:"deepseek"`
}
type siteConfigFile struct {
Meta struct {
ConfigVersion int `yaml:"config_version" json:"config_version"`
UpdatedAt string `yaml:"updated_at" json:"updated_at"`
UpdatedBy string `yaml:"updated_by" json:"updated_by"`
} `yaml:"meta" json:"meta"`
Site struct {
Title string `yaml:"title" json:"title"`
Description string `yaml:"description" json:"description"`
BaseURL string `yaml:"base_url" json:"base_url"`
Language string `yaml:"language" json:"language"`
Timezone string `yaml:"timezone" json:"timezone"`
} `yaml:"site" json:"site"`
Content struct {
PostsDir string `yaml:"posts_dir" json:"posts_dir"`
AssetsDir string `yaml:"assets_dir" json:"assets_dir"`
} `yaml:"content" json:"content"`
Build struct {
AstroProject string `yaml:"astro_project" json:"astro_project"`
OutputDir string `yaml:"output_dir" json:"output_dir"`
} `yaml:"build" json:"build"`
}
type stringListFlag struct {
values []string
}
func (f *stringListFlag) String() string {
return strings.Join(f.values, ",")
}
func (f *stringListFlag) Set(value string) error {
value = strings.TrimSpace(value)
if value == "" {
return nil
}
f.values = append(f.values, value)
return nil
}
func (f *stringListFlag) Values() []string {
return append([]string(nil), f.values...)
}

View file

@ -1,295 +0,0 @@
package cli
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"unicode"
)
func statusCommand(status string) string {
if status == "published" {
return "publish"
}
return "unpublish"
}
func uniquePath(path string) string {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
return path
}
ext := filepath.Ext(path)
base := strings.TrimSuffix(path, ext)
for i := 2; i < 1000; i++ {
candidate := fmt.Sprintf("%s-%d%s", base, i, ext)
if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) {
return candidate
}
}
return fmt.Sprintf("%s-%d%s", base, time.Now().Unix(), ext)
}
func mustRel(root string, path string) string {
rel, err := filepath.Rel(root, path)
if err != nil {
return path
}
return rel
}
func stringPtrValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func parseTime(value string) (time.Time, bool) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, false
}
parsed, err := time.Parse(time.RFC3339, value)
if err == nil {
return parsed, true
}
parsed, err = time.Parse("2006-01-02 15:04:05", value)
if err == nil {
return parsed, true
}
return time.Time{}, false
}
func confirm(prompt string) bool {
fmt.Fprintf(os.Stderr, "%s [y/N] ", prompt)
var answer string
if _, err := fmt.Fscan(os.Stdin, &answer); err != nil {
return false
}
answer = strings.ToLower(strings.TrimSpace(answer))
return answer == "y" || answer == "yes"
}
func resolveRootPath(root string, path string) string {
if filepath.IsAbs(path) {
return path
}
return filepath.Join(root, path)
}
func yesNo(value bool) string {
if value {
return "yes"
}
return "no"
}
func boolInt(value bool) int {
if value {
return 1
}
return 0
}
func contentHash(content string) string {
sum := sha256.Sum256([]byte(content))
return "sha256:" + hex.EncodeToString(sum[:])
}
func sortedKeys(values map[string]int) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
func sortedBoolKeys(values map[string]bool) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
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 slugExists(root string, slug string) bool {
posts, err := loadPosts(root)
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 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() (string, 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 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 escapeYAML(s string) string {
s = strings.ReplaceAll(s, `\`, `\\`)
s = strings.ReplaceAll(s, `"`, `\"`)
return s
}
func formatYAMLStringList(values []string) string {
if len(values) == 0 {
return "[]"
}
var b strings.Builder
b.WriteString("[")
for i, value := range values {
if i > 0 {
b.WriteString(", ")
}
b.WriteString(`"`)
b.WriteString(escapeYAML(value))
b.WriteString(`"`)
}
b.WriteString("]")
return b.String()
}
func writeFileIfMissing(path string, content string) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.WriteFile(path, []byte(content), 0o644)
}

View file

@ -1,460 +0,0 @@
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

@ -1,125 +0,0 @@
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

@ -1,45 +0,0 @@
package staticserver
import (
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
)
func Serve(dir string, host string, port string) error {
info, err := os.Stat(dir)
if err != nil {
return err
}
if !info.IsDir() {
return fmt.Errorf("static path %s is not a directory", dir)
}
addr := net.JoinHostPort(host, port)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := filepath.Clean("/" + r.URL.Path)
if path == "/" {
path = "/index.html"
}
fullPath := filepath.Join(dir, filepath.FromSlash(path))
if info, err := os.Stat(fullPath); err == nil && info.IsDir() {
fullPath = filepath.Join(fullPath, "index.html")
} else if errors.Is(err, os.ErrNotExist) && filepath.Ext(fullPath) == "" {
fullPath = filepath.Join(dir, filepath.FromSlash(path), "index.html")
}
if _, err := os.Stat(fullPath); err != nil {
http.NotFound(w, r)
return
}
http.ServeFile(w, r, fullPath)
})
fmt.Printf("serving %s at http://%s\n", dir, addr)
return http.ListenAndServe(addr, handler)
}

View file

@ -0,0 +1,17 @@
CREATE TABLE IF NOT EXISTS audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_username TEXT NOT NULL DEFAULT '',
action TEXT NOT NULL,
resource_type TEXT NOT NULL DEFAULT '',
resource_id TEXT NOT NULL DEFAULT '',
ip_address TEXT NOT NULL DEFAULT '',
user_agent TEXT NOT NULL DEFAULT '',
details JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_action_created_at ON audit_logs(action, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_resource_created_at ON audit_logs(resource_type, resource_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_logs_actor_created_at ON audit_logs(actor_id, created_at DESC);

View file

@ -1,7 +1,5 @@
database: database:
driver: "sqlite" postgres_dsn: "postgres://osaet:password@127.0.0.1:5432/osaet?sslmode=disable"
sqlite_path: ".osaet/osaet.db"
postgres_dsn: ""
slug: slug:
provider: "deepseek" # deepseek | local provider: "deepseek" # deepseek | local

View file

@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { import {
AssetResponse, AuditLogsResponse,
BuildJobResponse, BuildJobResponse,
LoginResponse, LoginResponse,
DeletePostResponse, DeletePostResponse,
@ -99,14 +99,6 @@ export class AdminApiService {
}); });
} }
uploadAsset(file: File) {
const body = new FormData();
body.append('file', file);
return this.http.post<AssetResponse>(`${this.baseUrl}/assets`, body, {
withCredentials: true
});
}
generateSlug(title: string, summary: string, postId?: string) { generateSlug(title: string, summary: string, postId?: string) {
return this.http.post<SlugResponse>( return this.http.post<SlugResponse>(
`${this.baseUrl}/slug`, `${this.baseUrl}/slug`,
@ -114,4 +106,27 @@ export class AdminApiService {
{ withCredentials: true } { withCredentials: true }
); );
} }
listAuditLogs(action: string, resourceType: string, query: string, limit?: number, offset?: number) {
let params = new HttpParams();
if (action) {
params = params.set('action', action);
}
if (resourceType) {
params = params.set('resourceType', resourceType);
}
if (query) {
params = params.set('query', query);
}
if (limit) {
params = params.set('limit', String(limit));
}
if (offset) {
params = params.set('offset', String(offset));
}
return this.http.get<AuditLogsResponse>(`${this.baseUrl}/audit-logs`, {
params,
withCredentials: true
});
}
} }

View file

@ -23,25 +23,6 @@ button:disabled {
opacity: 0.5; opacity: 0.5;
} }
.upload-button {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.7em;
background: #fff;
color: #2f4a63;
cursor: pointer;
padding: 0.72em 1.15em;
}
.upload-button:hover {
background: #f1f3f5;
}
.upload-button input {
display: none;
}
.link-button { .link-button {
background: transparent; background: transparent;
color: #2f4a63; color: #2f4a63;
@ -312,6 +293,19 @@ textarea {
padding: 0 1em; padding: 0 1em;
} }
.log-actions {
max-width: 100%;
flex-wrap: wrap;
justify-content: flex-end;
}
.log-actions input {
width: min(20em, 100%);
min-height: 2.55em;
border-radius: 0.65em;
font-size: 0.9em;
}
.editor-head h1 { .editor-head h1 {
display: flex; display: flex;
align-items: center; align-items: center;
@ -371,6 +365,39 @@ textarea {
background: #fafafa; background: #fafafa;
} }
.log-table {
overflow-x: auto;
}
.log-table-head,
.log-table-row {
min-width: 58em;
display: grid;
grid-template-columns: 10em 7em 8em 14em minmax(14em, 1fr) 8em;
align-items: center;
gap: 1em;
border-bottom: 1px solid #eee;
padding: 0.9em 0.75em;
}
.log-table-head {
color: #777b82;
font-size: 0.78em;
line-height: 1.2;
}
.log-table-row {
color: #777b82;
font-size: 0.86em;
}
.log-details {
overflow: hidden;
color: #3d3d3f;
text-overflow: ellipsis;
white-space: nowrap;
}
.post-row-title { .post-row-title {
overflow: hidden; overflow: hidden;
font-weight: 700; font-weight: 700;
@ -461,8 +488,7 @@ textarea {
box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%); box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%);
} }
.editor-actions button, .editor-actions button {
.editor-actions .upload-button {
min-height: 2.45em; min-height: 2.45em;
border-radius: 0.62em; border-radius: 0.62em;
padding: 0 0.9em; padding: 0 0.9em;
@ -470,14 +496,12 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.editor-actions .upload-button,
.editor-actions .save-button, .editor-actions .save-button,
.editor-actions .build-button { .editor-actions .build-button {
background: transparent; background: transparent;
color: #2f4a63; color: #2f4a63;
} }
.editor-actions .upload-button:hover,
.editor-actions .save-button:hover, .editor-actions .save-button:hover,
.editor-actions .build-button:hover { .editor-actions .build-button:hover {
background: #243b53; background: #243b53;
@ -791,8 +815,7 @@ textarea {
align-items: stretch; align-items: stretch;
} }
.editor-actions button, .editor-actions button {
.editor-actions .upload-button {
width: 100%; width: 100%;
} }

View file

@ -35,6 +35,10 @@
<span>/</span> <span>/</span>
@if (view === 'list') { @if (view === 'list') {
<span>文章</span> <span>文章</span>
} @else if (view === 'logs') {
<button type="button" (click)="backToList()">文章</button>
<span>/</span>
<span>日志</span>
} @else { } @else {
<button type="button" (click)="backToList()">文章</button> <button type="button" (click)="backToList()">文章</button>
<span>/</span> <span>/</span>
@ -45,6 +49,7 @@
<details class="user-menu"> <details class="user-menu">
<summary>{{ user.username }}</summary> <summary>{{ user.username }}</summary>
<div class="user-menu-panel"> <div class="user-menu-panel">
<button type="button" (click)="showLogs()">日志</button>
<button type="button" (click)="logout()">退出</button> <button type="button" (click)="logout()">退出</button>
</div> </div>
</details> </details>
@ -135,6 +140,82 @@
</span> </span>
<button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()"> <button type="button" class="link-button" [disabled]="page >= totalPages" (click)="nextPage()">
下一页 → 下一页 →
</button>
</nav>
</section>
} @else if (view === 'logs') {
<section class="list-view">
<div class="page-heading">
<div>
<p class="eyebrow">Audit</p>
<h1>操作日志</h1>
</div>
<div class="page-actions log-actions">
<input
name="auditQuery"
placeholder="搜索用户、动作、资源、详情"
[(ngModel)]="auditQuery"
(keyup.enter)="changeAuditFilter()"
/>
<select aria-label="日志动作" [(ngModel)]="auditActionFilter" (change)="changeAuditFilter()">
<option value="">全部动作</option>
<option value="login">登录</option>
<option value="login_failed">登录失败</option>
<option value="logout">退出</option>
<option value="post_create">新建文章</option>
<option value="post_update">修改文章</option>
<option value="post_delete">删除文章</option>
<option value="post_publish">发布文章</option>
<option value="build_create">提交构建</option>
<option value="slug_generate">生成 Slug</option>
</select>
<select aria-label="资源类型" [(ngModel)]="auditResourceFilter" (change)="changeAuditFilter()">
<option value="">全部资源</option>
<option value="user">用户</option>
<option value="post">文章</option>
<option value="build_job">构建</option>
</select>
<button type="button" (click)="changeAuditFilter()">查询</button>
</div>
</div>
<div class="post-table log-table">
@if (auditLogs.length === 0) {
<p class="empty-message">暂无日志</p>
} @else {
<div class="log-table-head">
<span>时间</span>
<span>用户</span>
<span>动作</span>
<span>资源</span>
<span>详情</span>
<span>IP</span>
</div>
@for (log of auditLogs; track log.id) {
<div class="log-table-row">
<span>{{ formatDate(log.createdAt) }}</span>
<span>{{ log.actorUsername || '匿名' }}</span>
<span>{{ actionText(log.action) }}</span>
<span>{{ log.resourceType || '-' }} {{ log.resourceId || '' }}</span>
<span class="log-details">{{ detailsText(log) }}</span>
<span>{{ log.ipAddress || '-' }}</span>
</div>
}
}
</div>
<nav class="pagination" aria-label="日志分页">
<button type="button" class="link-button" [disabled]="auditPage <= 1" (click)="previousAuditPage()">
← 上一页
</button>
<span>第 {{ auditPage }} / {{ totalAuditPages }} 页,共 {{ totalAuditLogs }} 条</span>
<button
type="button"
class="link-button"
[disabled]="auditPage >= totalAuditPages"
(click)="nextAuditPage()"
>
下一页 →
</button> </button>
</nav> </nav>
</section> </section>
@ -152,10 +233,6 @@
</h1> </h1>
</div> </div>
<div class="editor-actions"> <div class="editor-actions">
<label class="upload-button">
{{ uploadingAsset ? '上传中' : '上传图片' }}
<input type="file" accept="image/*" [disabled]="uploadingAsset" (change)="uploadAsset($event)" />
</label>
<button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button> <button type="submit" class="save-button" [disabled]="saving">{{ saving ? '保存中' : '保存' }}</button>
<button type="button" class="build-button" (click)="buildPost()">构建</button> <button type="button" class="build-button" (click)="buildPost()">构建</button>
</div> </div>
@ -248,7 +325,6 @@
<div class="markdown-workspace" [class.split]="editorMode === 'split'"> <div class="markdown-workspace" [class.split]="editorMode === 'split'">
@if (editorMode !== 'preview') { @if (editorMode !== 'preview') {
<textarea <textarea
#bodyTextarea
name="bodyMarkdown" name="bodyMarkdown"
spellcheck="false" spellcheck="false"
[(ngModel)]="draft.bodyMarkdown" [(ngModel)]="draft.bodyMarkdown"

View file

@ -1,10 +1,10 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { Component, ElementRef, HostListener, OnDestroy, OnInit, ViewChild, inject } from '@angular/core'; import { Component, HostListener, OnDestroy, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs'; import { Subscription, catchError, firstValueFrom, interval, of, switchMap, takeWhile } from 'rxjs';
import { AdminApiService } from './admin-api.service'; import { AdminApiService } from './admin-api.service';
import { BuildJob, Post, PostInput, PostStatus, User } from './models'; import { AuditLog, BuildJob, Post, PostInput, PostStatus, User } from './models';
type FeedbackTone = 'success' | 'info' | 'error'; type FeedbackTone = 'success' | 'info' | 'error';
@ -17,17 +17,23 @@ type FeedbackTone = 'success' | 'info' | 'error';
}) })
export class AppComponent implements OnInit, OnDestroy { export class AppComponent implements OnInit, OnDestroy {
private readonly api = inject(AdminApiService); private readonly api = inject(AdminApiService);
@ViewChild('bodyTextarea') private bodyTextarea?: ElementRef<HTMLTextAreaElement>;
user: User | null = null; user: User | null = null;
posts: Post[] = []; posts: Post[] = [];
auditLogs: AuditLog[] = [];
currentPost: Post | null = null; currentPost: Post | null = null;
statusFilter: PostStatus | '' = ''; statusFilter: PostStatus | '' = '';
view: 'list' | 'editor' = 'list'; auditActionFilter = '';
auditResourceFilter = '';
auditQuery = '';
view: 'list' | 'editor' | 'logs' = 'list';
editorMode: 'edit' | 'preview' | 'split' = 'edit'; editorMode: 'edit' | 'preview' | 'split' = 'edit';
page = 1; page = 1;
auditPage = 1;
readonly pageSize = 12; readonly pageSize = 12;
readonly auditPageSize = 20;
totalPosts = 0; totalPosts = 0;
totalAuditLogs = 0;
loginUsername = 'yarnom'; loginUsername = 'yarnom';
loginPassword = ''; loginPassword = '';
@ -38,7 +44,6 @@ export class AppComponent implements OnInit, OnDestroy {
showBuildLog = false; showBuildLog = false;
loading = true; loading = true;
saving = false; saving = false;
uploadingAsset = false;
generatingSlug = false; generatingSlug = false;
autosaveStatus = '未修改'; autosaveStatus = '未修改';
lastAutosavedAt: Date | null = null; lastAutosavedAt: Date | null = null;
@ -98,6 +103,7 @@ export class AppComponent implements OnInit, OnDestroy {
await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true })))); await firstValueFrom(this.api.logout().pipe(catchError(() => of({ ok: true }))));
this.user = null; this.user = null;
this.posts = []; this.posts = [];
this.auditLogs = [];
this.currentPost = null; this.currentPost = null;
this.draft = this.emptyDraft(); this.draft = this.emptyDraft();
this.tagsText = ''; this.tagsText = '';
@ -117,6 +123,32 @@ export class AppComponent implements OnInit, OnDestroy {
} }
} }
async loadAuditLogs() {
const response = await firstValueFrom(
this.api.listAuditLogs(
this.auditActionFilter,
this.auditResourceFilter,
this.auditQuery.trim(),
this.auditPageSize,
(this.auditPage - 1) * this.auditPageSize
)
);
this.auditLogs = response.logs ?? [];
this.totalAuditLogs = response.total ?? 0;
if (this.auditPage > this.totalAuditPages) {
this.auditPage = this.totalAuditPages;
await this.loadAuditLogs();
}
}
async showLogs() {
if (!this.confirmDiscard()) {
return;
}
this.view = 'logs';
await this.loadAuditLogs();
}
async selectPost(id: string) { async selectPost(id: string) {
if (!this.confirmDiscard()) { if (!this.confirmDiscard()) {
return; return;
@ -188,6 +220,27 @@ export class AppComponent implements OnInit, OnDestroy {
await this.loadPosts(); await this.loadPosts();
} }
async changeAuditFilter() {
this.auditPage = 1;
await this.loadAuditLogs();
}
async previousAuditPage() {
if (this.auditPage <= 1) {
return;
}
this.auditPage -= 1;
await this.loadAuditLogs();
}
async nextAuditPage() {
if (this.auditPage >= this.totalAuditPages) {
return;
}
this.auditPage += 1;
await this.loadAuditLogs();
}
async changeStatusFilter() { async changeStatusFilter() {
this.page = 1; this.page = 1;
await this.loadPosts(); await this.loadPosts();
@ -404,28 +457,6 @@ export class AppComponent implements OnInit, OnDestroy {
return renderMarkdown(this.draft.bodyMarkdown); return renderMarkdown(this.draft.bodyMarkdown);
} }
async uploadAsset(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) {
return;
}
this.uploadingAsset = true;
this.editorMessage = '正在上传图片';
try {
const response = await firstValueFrom(this.api.uploadAsset(file));
this.insertMarkdown(`![${altText(file.name)}](${response.asset.path})`);
this.editorMessage = '图片已插入';
this.updateAutosaveStatus();
} catch (error) {
this.editorMessage = errorMessage(error);
} finally {
this.uploadingAsset = false;
}
}
async generateSlug() { async generateSlug() {
const title = this.draft.title.trim(); const title = this.draft.title.trim();
if (!title) { if (!title) {
@ -458,10 +489,26 @@ export class AppComponent implements OnInit, OnDestroy {
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize)); return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
} }
get totalAuditPages() {
return Math.max(1, Math.ceil(this.totalAuditLogs / this.auditPageSize));
}
get pageNumbers() { get pageNumbers() {
return Array.from({ length: this.totalPages }, (_, index) => index + 1); return Array.from({ length: this.totalPages }, (_, index) => index + 1);
} }
actionText(action: string) {
return actionText(action);
}
detailsText(log: AuditLog) {
const details = log.details ?? {};
const pairs = Object.entries(details)
.filter(([, value]) => value !== undefined && value !== null && value !== '')
.map(([key, value]) => `${key}: ${String(value)}`);
return pairs.length ? pairs.join(' / ') : '无详情';
}
private async autosave() { private async autosave() {
if (!this.canAutosave()) { if (!this.canAutosave()) {
return; return;
@ -555,29 +602,6 @@ export class AppComponent implements OnInit, OnDestroy {
}); });
} }
private insertMarkdown(markdown: string) {
const textarea = this.bodyTextarea?.nativeElement;
const current = this.draft.bodyMarkdown ?? '';
if (!textarea) {
this.draft.bodyMarkdown = current ? `${current}\n\n${markdown}\n` : `${markdown}\n`;
return;
}
const start = textarea.selectionStart ?? current.length;
const end = textarea.selectionEnd ?? current.length;
const prefix = current.slice(0, start);
const suffix = current.slice(end);
const before = prefix && !prefix.endsWith('\n') ? '\n\n' : '';
const after = suffix && !suffix.startsWith('\n') ? '\n\n' : '\n';
this.draft.bodyMarkdown = `${prefix}${before}${markdown}${after}${suffix}`;
requestAnimationFrame(() => {
textarea.focus();
const cursor = start + before.length + markdown.length + after.length;
textarea.setSelectionRange(cursor, cursor);
});
}
private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') { private showFeedback(title: string, message: string, tone: FeedbackTone = 'success') {
this.feedback = { title, message, tone }; this.feedback = { title, message, tone };
if (this.feedbackTimer) { if (this.feedbackTimer) {
@ -618,10 +642,6 @@ function slugify(value: string) {
.replace(/^-|-$/g, ''); .replace(/^-|-$/g, '');
} }
function altText(filename: string) {
return filename.replace(/\.[^.]+$/, '').replace(/[-_]+/g, ' ').trim() || 'image';
}
function toDateTimeLocal(value?: string | null) { function toDateTimeLocal(value?: string | null) {
if (!value) { if (!value) {
return ''; return '';
@ -776,6 +796,22 @@ function buildStatusText(status: string) {
)[status] ?? status; )[status] ?? status;
} }
function actionText(action: string) {
return (
{
login: '登录',
login_failed: '登录失败',
logout: '退出',
post_create: '新建文章',
post_update: '修改文章',
post_delete: '删除文章',
post_publish: '发布文章',
build_create: '提交构建',
slug_generate: '生成 Slug'
} as Record<string, string>
)[action] ?? action;
}
function errorMessage(error: unknown) { function errorMessage(error: unknown) {
if (typeof error === 'object' && error && 'error' in error) { if (typeof error === 'object' && error && 'error' in error) {
const body = (error as { error?: { error?: string } }).error; const body = (error as { error?: { error?: string } }).error;

View file

@ -53,15 +53,17 @@ export type BuildJob = {
createdBy?: string | null; createdBy?: string | null;
}; };
export type Asset = { export type AuditLog = {
id: string; id: string;
path: string; actorId?: string | null;
originalName: string; actorUsername: string;
mimeType: string; action: string;
sizeBytes: number; resourceType: string;
sha256: string; resourceId: string;
ipAddress: string;
userAgent: string;
details: Record<string, unknown>;
createdAt: string; createdAt: string;
createdBy?: string | null;
}; };
export type LoginResponse = { export type LoginResponse = {
@ -83,10 +85,6 @@ export type BuildJobResponse = {
buildJob: BuildJob; buildJob: BuildJob;
}; };
export type AssetResponse = {
asset: Asset;
};
export type SlugResponse = { export type SlugResponse = {
slug: string; slug: string;
}; };
@ -95,3 +93,8 @@ export type DeletePostResponse = {
ok: boolean; ok: boolean;
buildJob?: BuildJob | null; buildJob?: BuildJob | null;
}; };
export type AuditLogsResponse = {
logs: AuditLog[];
total: number;
};