From 9186801c7fe994d6c41ad3cfeb4bbce1195e533a Mon Sep 17 00:00:00 2001 From: yarnom Date: Wed, 3 Jun 2026 18:18:50 +0800 Subject: [PATCH] Simplify admin publishing pipeline --- backend/cmd/import-articles/main.go | 33 -- backend/cmd/osaet-admin/main.go | 24 +- backend/cmd/osaetctl/main.go | 15 - backend/go.mod | 14 +- backend/go.sum | 42 -- backend/internal/admin/assets.go | 80 --- backend/internal/admin/config.go | 25 +- backend/internal/admin/logging.go | 139 +++++ backend/internal/admin/markdown_import.go | 239 --------- backend/internal/admin/router.go | 148 ++++-- backend/internal/admin/static.go | 27 + backend/internal/admin/store.go | 189 +++++-- backend/internal/admin/types.go | 24 +- backend/internal/cli/build.go | 88 --- backend/internal/cli/cli.go | 103 ---- backend/internal/cli/config.go | 106 ---- backend/internal/cli/config_test.go | 27 - backend/internal/cli/content.go | 130 ----- backend/internal/cli/db.go | 146 ----- backend/internal/cli/markdown_test.go | 57 -- backend/internal/cli/posts.go | 368 ------------- backend/internal/cli/slug_test.go | 38 -- backend/internal/cli/sqlite_schema.sql | 35 -- backend/internal/cli/storage_test.go | 68 --- backend/internal/cli/sync.go | 558 -------------------- backend/internal/cli/types.go | 87 --- backend/internal/cli/util.go | 295 ----------- backend/internal/postimport/import.go | 460 ---------------- backend/internal/postimport/import_test.go | 125 ----- backend/internal/staticserver/server.go | 45 -- backend/migrations/003_audit_logs.sql | 17 + config/local.example.yaml | 4 +- frontend/admin/src/app/admin-api.service.ts | 33 +- frontend/admin/src/app/app.component.css | 73 ++- frontend/admin/src/app/app.component.html | 86 ++- frontend/admin/src/app/app.component.ts | 144 +++-- frontend/admin/src/app/models.ts | 25 +- 37 files changed, 750 insertions(+), 3367 deletions(-) delete mode 100644 backend/cmd/import-articles/main.go delete mode 100644 backend/cmd/osaetctl/main.go delete mode 100644 backend/internal/admin/assets.go create mode 100644 backend/internal/admin/logging.go delete mode 100644 backend/internal/admin/markdown_import.go delete mode 100644 backend/internal/cli/build.go delete mode 100644 backend/internal/cli/cli.go delete mode 100644 backend/internal/cli/config.go delete mode 100644 backend/internal/cli/config_test.go delete mode 100644 backend/internal/cli/content.go delete mode 100644 backend/internal/cli/db.go delete mode 100644 backend/internal/cli/markdown_test.go delete mode 100644 backend/internal/cli/posts.go delete mode 100644 backend/internal/cli/slug_test.go delete mode 100644 backend/internal/cli/sqlite_schema.sql delete mode 100644 backend/internal/cli/storage_test.go delete mode 100644 backend/internal/cli/sync.go delete mode 100644 backend/internal/cli/types.go delete mode 100644 backend/internal/cli/util.go delete mode 100644 backend/internal/postimport/import.go delete mode 100644 backend/internal/postimport/import_test.go delete mode 100644 backend/internal/staticserver/server.go create mode 100644 backend/migrations/003_audit_logs.sql diff --git a/backend/cmd/import-articles/main.go b/backend/cmd/import-articles/main.go deleted file mode 100644 index a777735..0000000 --- a/backend/cmd/import-articles/main.go +++ /dev/null @@ -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) -} diff --git a/backend/cmd/osaet-admin/main.go b/backend/cmd/osaet-admin/main.go index 8296088..be333dd 100644 --- a/backend/cmd/osaet-admin/main.go +++ b/backend/cmd/osaet-admin/main.go @@ -32,6 +32,13 @@ func run() error { if err := cfg.Validate(); err != nil { 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) defer stop() @@ -49,8 +56,6 @@ func run() error { return admin.RunMigrations(ctx, db, cfg.MigrationsDir) case "create-user": return createUser(ctx, db) - case "import-markdown": - return importMarkdown(ctx, cfg, db) default: return fmt.Errorf("unknown command %q", command) } @@ -75,21 +80,6 @@ func createUser(ctx context.Context, db *pgxpool.Pool) error { return nil } -func importMarkdown(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { - postsDir := cfg.PostsDir - if len(os.Args) >= 3 { - postsDir = os.Args[2] - } - - result, err := admin.NewStore(db).ImportMarkdownPosts(ctx, postsDir) - if err != nil { - return err - } - - fmt.Fprintf(os.Stdout, "imported %d markdown post(s), skipped %d file(s)\n", result.Imported, result.Skipped) - return nil -} - func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { server := &http.Server{ Addr: cfg.Addr, diff --git a/backend/cmd/osaetctl/main.go b/backend/cmd/osaetctl/main.go deleted file mode 100644 index 2d51f79..0000000 --- a/backend/cmd/osaetctl/main.go +++ /dev/null @@ -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) - } -} diff --git a/backend/go.mod b/backend/go.mod index 90d26b0..8c838b7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,8 +5,8 @@ go 1.25.0 require ( github.com/gin-gonic/gin v1.12.0 github.com/jackc/pgx/v5 v5.4.3 + golang.org/x/crypto v0.48.0 gopkg.in/yaml.v3 v3.0.1 - modernc.org/sqlite v1.33.1 ) require ( @@ -14,7 +14,6 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect - github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -22,8 +21,6 @@ require ( github.com/go-playground/validator/v10 v10.30.1 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-yaml v1.19.2 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect @@ -33,25 +30,16 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect - github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/quic-go v0.59.0 // indirect - github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect - golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.51.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 // indirect google.golang.org/protobuf v1.36.10 // indirect - modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect - modernc.org/libc v1.55.3 // indirect - modernc.org/mathutil v1.6.0 // indirect - modernc.org/memory v1.8.0 // indirect - modernc.org/strutil v1.2.0 // indirect - modernc.org/token v1.1.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 2d282ee..f6a885c 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= @@ -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/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= -github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= -github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= @@ -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/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= -github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -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/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= -github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -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/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= @@ -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/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= -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= diff --git a/backend/internal/admin/assets.go b/backend/internal/admin/assets.go deleted file mode 100644 index f839df2..0000000 --- a/backend/internal/admin/assets.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/admin/config.go b/backend/internal/admin/config.go index fc33356..6c84626 100644 --- a/backend/internal/admin/config.go +++ b/backend/internal/admin/config.go @@ -20,8 +20,11 @@ type Config struct { RepoRoot string PostsDir string SiteDir string - AssetsDir string + StaticDir string AdminDir string + LogFile string + LogMaxBytes int64 + LogMaxBackups int DeepSeek DeepSeekConfig LocalLLM LocalLLMConfig SlugProvider string @@ -89,9 +92,9 @@ func LoadConfig() Config { siteDir = filepath.Join(repoRoot, "frontend", "site") } - assetsDir := os.Getenv("OSAET_ASSETS_DIR") - if assetsDir == "" { - assetsDir = filepath.Join(siteDir, "public", "assets") + staticDir := os.Getenv("OSAET_STATIC_DIR") + if staticDir == "" { + staticDir = filepath.Join(repoRoot, "dist", "site") } adminDir := os.Getenv("OSAET_ADMIN_DIR") @@ -99,6 +102,11 @@ func LoadConfig() Config { adminDir = filepath.Join(repoRoot, "frontend", "admin", "dist", "admin", "browser") } + logFile := os.Getenv("OSAET_LOG_FILE") + if logFile == "" { + logFile = filepath.Join(repoRoot, ".osaet", "logs", "osaet-admin.log") + } + local := loadLocalConfig(repoRoot) databaseURL := firstNonEmpty(os.Getenv("DATABASE_URL"), local.Database.PostgresDSN) @@ -118,8 +126,11 @@ func LoadConfig() Config { RepoRoot: repoRoot, PostsDir: postsDir, SiteDir: siteDir, - AssetsDir: assetsDir, + StaticDir: staticDir, AdminDir: adminDir, + LogFile: logFile, + LogMaxBytes: int64(firstNonZeroInt(envInt("OSAET_LOG_MAX_BYTES"), 10*1024*1024)), + LogMaxBackups: firstNonZeroInt(envInt("OSAET_LOG_MAX_BACKUPS"), 5), SlugProvider: firstNonEmpty(os.Getenv("OSAET_SLUG_PROVIDER"), local.Slug.Provider, "deepseek"), DeepSeek: DeepSeekConfig{ APIKey: deepSeekAPIKey, @@ -236,8 +247,8 @@ func (c Config) Validate() error { if c.SiteDir == "" { return errors.New("site dir is required") } - if c.AssetsDir == "" { - return errors.New("assets dir is required") + if c.StaticDir == "" { + return errors.New("static dir is required") } return nil } diff --git a/backend/internal/admin/logging.go b/backend/internal/admin/logging.go new file mode 100644 index 0000000..2bc55c6 --- /dev/null +++ b/backend/internal/admin/logging.go @@ -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 + } +} diff --git a/backend/internal/admin/markdown_import.go b/backend/internal/admin/markdown_import.go deleted file mode 100644 index 07cf360..0000000 --- a/backend/internal/admin/markdown_import.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/admin/router.go b/backend/internal/admin/router.go index be12f05..ddd1bd9 100644 --- a/backend/internal/admin/router.go +++ b/backend/internal/admin/router.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log" "net/http" "strconv" "strings" @@ -19,11 +20,11 @@ type Server struct { db *pgxpool.Pool store *Store builder *Builder - uploader *AssetUploader deepSeek DeepSeekConfig localLLM LocalLLMConfig slugProvider string adminDir string + staticDir string ctx context.Context } @@ -38,26 +39,22 @@ func NewServerWithConfig(db *pgxpool.Pool, cfg Config) *Server { func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Server { var store *Store var builder *Builder - var uploader *AssetUploader if db != nil { store = NewStore(db) if cfg.PostsDir != "" && cfg.SiteDir != "" { builder = NewBuilder(store, NewExporter(cfg.PostsDir), cfg.SiteDir) builder.Start(ctx) } - if cfg.AssetsDir != "" { - uploader = NewAssetUploader(store, cfg.AssetsDir) - } } return &Server{ db: db, store: store, builder: builder, - uploader: uploader, deepSeek: cfg.DeepSeek, localLLM: cfg.LocalLLM, slugProvider: cfg.SlugProvider, adminDir: cfg.AdminDir, + staticDir: cfg.StaticDir, ctx: ctx, } } @@ -67,6 +64,7 @@ func (s *Server) Router() http.Handler { r := gin.New() r.Use(gin.Recovery()) + r.Use(requestLogger()) r.GET("/healthz", s.health) r.GET("/readyz", s.ready) @@ -82,8 +80,8 @@ func (s *Server) Router() http.Handler { protected.Use(s.requireAuth) protected.GET("/me", s.me) protected.POST("/logout", s.logout) - protected.POST("/assets", s.uploadAsset) protected.POST("/slug", s.generateSlug) + protected.GET("/audit-logs", s.listAuditLogs) protected.GET("/posts", s.listPosts) protected.POST("/posts", s.createPost) protected.GET("/posts/:id", s.getPost) @@ -93,6 +91,8 @@ func (s *Server) Router() http.Handler { protected.POST("/posts/:id/publish", s.publishPost) protected.GET("/build-jobs/:id", s.getBuildJob) + r.NoRoute(s.siteFile) + return r } @@ -119,6 +119,17 @@ func (s *Server) adminFile(c *gin.Context) { s.adminPage(c) } +func (s *Server) siteFile(c *gin.Context) { + if strings.HasPrefix(c.Request.URL.Path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + if serveSiteFile(c, s.staticDir) { + return + } + c.String(http.StatusNotFound, "not found") +} + func (s *Server) ready(c *gin.Context) { if s.db == nil { c.JSON(http.StatusServiceUnavailable, gin.H{ @@ -190,10 +201,12 @@ func (s *Server) login(c *gin.Context) { result, err := s.store.Login(c.Request.Context(), input) if err != nil { + s.audit(c, nil, "login_failed", "user", "", gin.H{"username": input.Username, "error": err.Error()}) writeStoreError(c, err) return } SetSessionCookie(c, result.Token, result.ExpiresAt) + s.audit(c, &result.User, "login", "user", result.User.ID, gin.H{"username": result.User.Username}) c.JSON(http.StatusOK, gin.H{ "user": result.User, "expiresAt": result.ExpiresAt, @@ -214,12 +227,14 @@ func (s *Server) logout(c *gin.Context) { return } + user, _ := currentUser(c) token, _ := c.Cookie(SessionCookieName) if err := s.store.Logout(c.Request.Context(), token); err != nil { writeStoreError(c, err) return } ClearSessionCookie(c) + s.audit(c, &user, "logout", "user", user.ID, gin.H{"username": user.Username}) c.JSON(http.StatusOK, gin.H{"ok": true}) } @@ -252,6 +267,7 @@ func (s *Server) createPost(c *gin.Context) { writeStoreError(c, err) return } + s.auditCurrentUser(c, "post_create", "post", post.ID, postAuditDetails(post)) c.JSON(http.StatusCreated, gin.H{"post": post}) } @@ -271,6 +287,7 @@ func (s *Server) updatePost(c *gin.Context) { writeStoreError(c, err) return } + s.auditCurrentUser(c, "post_update", "post", post.ID, postAuditDetails(post)) c.JSON(http.StatusOK, gin.H{"post": post}) } @@ -285,33 +302,14 @@ func (s *Server) deletePost(c *gin.Context) { return } s.enqueueBuildJob(job) + details := gin.H{} + if job != nil { + details["buildJobId"] = job.ID + } + s.auditCurrentUser(c, "post_delete", "post", c.Param("id"), details) c.JSON(http.StatusOK, gin.H{"ok": true, "buildJob": job}) } -func (s *Server) uploadAsset(c *gin.Context) { - if !s.requireStore(c) { - return - } - if s.uploader == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "asset uploader is not configured"}) - return - } - - file, header, err := c.Request.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - defer file.Close() - - asset, err := s.uploader.Upload(c.Request.Context(), file, header) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusCreated, gin.H{"asset": asset}) -} - type GenerateSlugInput struct { Title string `json:"title"` Summary string `json:"summary"` @@ -345,9 +343,35 @@ func (s *Server) generateSlug(c *gin.Context) { writeStoreError(c, err) return } + s.auditCurrentUser(c, "slug_generate", "post", input.PostID, gin.H{"title": input.Title, "slug": slug}) c.JSON(http.StatusOK, gin.H{"slug": slug}) } +func (s *Server) listAuditLogs(c *gin.Context) { + if !s.requireStore(c) { + return + } + + opts := AuditLogListOptions{ + Action: c.Query("action"), + ResourceType: c.Query("resourceType"), + Query: c.Query("query"), + Limit: queryInt(c, "limit"), + Offset: queryInt(c, "offset"), + } + logs, err := s.store.ListAuditLogs(c.Request.Context(), opts) + if err != nil { + writeStoreError(c, err) + return + } + total, err := s.store.CountAuditLogs(c.Request.Context(), opts) + if err != nil { + writeStoreError(c, err) + return + } + c.JSON(http.StatusOK, gin.H{"logs": logs, "total": total}) +} + func (s *Server) generateSlugBase(ctx context.Context, title string, summary string) (string, error) { switch strings.ToLower(strings.TrimSpace(s.slugProvider)) { case "", "deepseek": @@ -385,6 +409,7 @@ func (s *Server) buildPost(c *gin.Context) { } s.enqueueBuildJob(&job) + s.auditCurrentUser(c, "build_create", "build_job", job.ID, gin.H{"postId": c.Param("id")}) c.JSON(http.StatusAccepted, gin.H{"buildJob": job}) } @@ -400,6 +425,12 @@ func (s *Server) publishPost(c *gin.Context) { } s.enqueueBuildJob(&job) + s.auditCurrentUser(c, "post_publish", "post", post.ID, gin.H{ + "title": post.Title, + "slug": post.Slug, + "status": post.Status, + "buildJobId": job.ID, + }) c.JSON(http.StatusAccepted, gin.H{ "post": post, @@ -472,3 +503,58 @@ func writeStoreError(c *gin.Context, err error) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) } } + +func currentUser(c *gin.Context) (User, bool) { + user, ok := c.Request.Context().Value(userContextKey).(User) + return user, ok +} + +func (s *Server) auditCurrentUser(c *gin.Context, action string, resourceType string, resourceID string, details map[string]any) { + user, ok := currentUser(c) + if !ok { + s.audit(c, nil, action, resourceType, resourceID, details) + return + } + s.audit(c, &user, action, resourceType, resourceID, details) +} + +func (s *Server) audit(c *gin.Context, user *User, action string, resourceType string, resourceID string, details map[string]any) { + if s.store == nil { + return + } + var actorID *string + actorUsername := "" + if user != nil { + actorID = &user.ID + actorUsername = user.Username + } + if err := s.store.CreateAuditLog(c.Request.Context(), AuditLogInput{ + ActorID: actorID, + ActorUsername: actorUsername, + Action: action, + ResourceType: resourceType, + ResourceID: resourceID, + IPAddress: c.ClientIP(), + UserAgent: c.Request.UserAgent(), + Details: details, + }); err != nil { + log.Printf("audit log failed: %v", err) + } +} + +func requestLogger() gin.HandlerFunc { + return func(c *gin.Context) { + started := time.Now() + c.Next() + log.Printf("%s %s %d %s %s", c.Request.Method, c.Request.URL.Path, c.Writer.Status(), time.Since(started), c.ClientIP()) + } +} + +func postAuditDetails(post Post) map[string]any { + return map[string]any{ + "title": post.Title, + "slug": post.Slug, + "status": post.Status, + "version": post.Version, + } +} diff --git a/backend/internal/admin/static.go b/backend/internal/admin/static.go index df68aac..5d95613 100644 --- a/backend/internal/admin/static.go +++ b/backend/internal/admin/static.go @@ -46,6 +46,33 @@ func serveAdminFile(c *gin.Context, adminDir string) bool { return true } +func serveSiteFile(c *gin.Context, staticDir string) bool { + if strings.TrimSpace(staticDir) == "" { + return false + } + + requested := strings.TrimPrefix(c.Request.URL.Path, "/") + if requested == "" { + requested = "index.html" + } + cleaned := filepath.Clean(requested) + if cleaned == "." || strings.HasPrefix(cleaned, "..") || filepath.IsAbs(cleaned) { + return false + } + + path := filepath.Join(staticDir, cleaned) + info, err := os.Stat(path) + if err == nil && info.IsDir() { + path = filepath.Join(path, "index.html") + info, err = os.Stat(path) + } + if err != nil || info.IsDir() { + return false + } + http.ServeFile(c.Writer, c.Request, path) + return true +} + func rewriteAdminBase(page []byte) []byte { return []byte(strings.Replace(string(page), ``, ``, 1)) } diff --git a/backend/internal/admin/store.go b/backend/internal/admin/store.go index 1e33e15..c0e60f0 100644 --- a/backend/internal/admin/store.go +++ b/backend/internal/admin/store.go @@ -3,6 +3,7 @@ package admin import ( "context" "database/sql" + "encoding/json" "errors" "fmt" "strings" @@ -25,6 +26,25 @@ type PostListOptions struct { Offset int } +type AuditLogInput struct { + ActorID *string + ActorUsername string + Action string + ResourceType string + ResourceID string + IPAddress string + UserAgent string + Details map[string]any +} + +type AuditLogListOptions struct { + Action string + ResourceType string + Query string + Limit int + Offset int +} + type PostInput struct { Slug string `json:"slug"` Title string `json:"title"` @@ -389,28 +409,6 @@ WHERE id = $1`, id)) return job, nil } -func (s *Store) CreateAsset(ctx context.Context, asset Asset) (Asset, error) { - created, err := scanAsset(s.db.QueryRow(ctx, ` -INSERT INTO assets (path, original_name, mime_type, size_bytes, sha256) -VALUES ($1, $2, $3, $4, $5) -ON CONFLICT (path) -DO UPDATE SET original_name = excluded.original_name, - mime_type = excluded.mime_type, - size_bytes = excluded.size_bytes, - sha256 = excluded.sha256 -RETURNING id, path, original_name, mime_type, size_bytes, sha256, created_at, created_by`, - asset.Path, - asset.OriginalName, - asset.MimeType, - asset.SizeBytes, - asset.SHA256, - )) - if err != nil { - return Asset{}, fmt.Errorf("create asset: %w", err) - } - return created, nil -} - func (s *Store) PublishedPostsForExport(ctx context.Context) ([]Post, error) { rows, err := s.db.Query(ctx, ` SELECT id, slug, title, summary, body_markdown, status, cover, version, @@ -473,6 +471,114 @@ WHERE id = $1`, id, log, message) return nil } +func (s *Store) CreateAuditLog(ctx context.Context, input AuditLogInput) error { + action := strings.TrimSpace(input.Action) + if action == "" { + return errors.New("audit action is required") + } + details := input.Details + if details == nil { + details = map[string]any{} + } + detailsJSON, err := json.Marshal(details) + if err != nil { + return fmt.Errorf("encode audit details: %w", err) + } + _, err = s.db.Exec(ctx, ` +INSERT INTO audit_logs (actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)`, + input.ActorID, + input.ActorUsername, + action, + strings.TrimSpace(input.ResourceType), + strings.TrimSpace(input.ResourceID), + strings.TrimSpace(input.IPAddress), + strings.TrimSpace(input.UserAgent), + string(detailsJSON), + ) + if err != nil { + return fmt.Errorf("create audit log: %w", err) + } + return nil +} + +func (s *Store) ListAuditLogs(ctx context.Context, opts AuditLogListOptions) ([]AuditLog, error) { + limit := opts.Limit + if limit <= 0 || limit > 200 { + limit = 50 + } + offset := opts.Offset + if offset < 0 { + offset = 0 + } + + args := []any{limit, offset} + where := "true" + if strings.TrimSpace(opts.Action) != "" { + args = append(args, strings.TrimSpace(opts.Action)) + where += fmt.Sprintf(" AND action = $%d", len(args)) + } + if strings.TrimSpace(opts.ResourceType) != "" { + args = append(args, strings.TrimSpace(opts.ResourceType)) + where += fmt.Sprintf(" AND resource_type = $%d", len(args)) + } + if strings.TrimSpace(opts.Query) != "" { + args = append(args, "%"+strings.TrimSpace(opts.Query)+"%") + where += fmt.Sprintf(` AND ( + actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d +)`, len(args), len(args), len(args), len(args), len(args)) + } + + rows, err := s.db.Query(ctx, ` +SELECT id, actor_id, actor_username, action, resource_type, resource_id, ip_address, user_agent, details, created_at +FROM audit_logs +WHERE `+where+` +ORDER BY created_at DESC +LIMIT $1 OFFSET $2`, args...) + if err != nil { + return nil, fmt.Errorf("list audit logs: %w", err) + } + defer rows.Close() + + var logs []AuditLog + for rows.Next() { + log, err := scanAuditLog(rows) + if err != nil { + return nil, err + } + logs = append(logs, log) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("audit log rows: %w", err) + } + return logs, nil +} + +func (s *Store) CountAuditLogs(ctx context.Context, opts AuditLogListOptions) (int, error) { + args := []any{} + where := "true" + if strings.TrimSpace(opts.Action) != "" { + args = append(args, strings.TrimSpace(opts.Action)) + where += fmt.Sprintf(" AND action = $%d", len(args)) + } + if strings.TrimSpace(opts.ResourceType) != "" { + args = append(args, strings.TrimSpace(opts.ResourceType)) + where += fmt.Sprintf(" AND resource_type = $%d", len(args)) + } + if strings.TrimSpace(opts.Query) != "" { + args = append(args, "%"+strings.TrimSpace(opts.Query)+"%") + where += fmt.Sprintf(` AND ( + actor_username ILIKE $%d OR action ILIKE $%d OR resource_type ILIKE $%d OR resource_id ILIKE $%d OR details::text ILIKE $%d +)`, len(args), len(args), len(args), len(args), len(args)) + } + + var total int + if err := s.db.QueryRow(ctx, `SELECT count(*) FROM audit_logs WHERE `+where, args...).Scan(&total); err != nil { + return 0, fmt.Errorf("count audit logs: %w", err) + } + return total, nil +} + func validatePostInput(input PostInput, creating bool) error { if strings.TrimSpace(input.Title) == "" { return errors.New("title is required") @@ -780,24 +886,35 @@ func scanBuildJob(row postScanner) (BuildJob, error) { return job, nil } -func scanAsset(row postScanner) (Asset, error) { - var asset Asset - var createdBy sql.NullString +func scanAuditLog(row postScanner) (AuditLog, error) { + var log AuditLog + var actorID sql.NullString + var details []byte err := row.Scan( - &asset.ID, - &asset.Path, - &asset.OriginalName, - &asset.MimeType, - &asset.SizeBytes, - &asset.SHA256, - &asset.CreatedAt, - &createdBy, + &log.ID, + &actorID, + &log.ActorUsername, + &log.Action, + &log.ResourceType, + &log.ResourceID, + &log.IPAddress, + &log.UserAgent, + &details, + &log.CreatedAt, ) if err != nil { - return Asset{}, err + return AuditLog{}, err } - asset.CreatedBy = nullStringPtr(createdBy) - return asset, nil + log.ActorID = nullStringPtr(actorID) + if len(details) > 0 { + if err := json.Unmarshal(details, &log.Details); err != nil { + return AuditLog{}, fmt.Errorf("decode audit details: %w", err) + } + } + if log.Details == nil { + log.Details = map[string]any{} + } + return log, nil } func nullTimePtr(value sql.NullTime) *time.Time { diff --git a/backend/internal/admin/types.go b/backend/internal/admin/types.go index 9f11dee..3904a7d 100644 --- a/backend/internal/admin/types.go +++ b/backend/internal/admin/types.go @@ -76,17 +76,6 @@ type Tag struct { UpdatedAt time.Time `json:"updatedAt"` } -type Asset struct { - ID string `json:"id"` - Path string `json:"path"` - OriginalName string `json:"originalName"` - MimeType string `json:"mimeType"` - SizeBytes int64 `json:"sizeBytes"` - SHA256 string `json:"sha256"` - CreatedAt time.Time `json:"createdAt"` - CreatedBy *string `json:"createdBy"` -} - type BuildJob struct { ID string `json:"id"` Trigger BuildJobTrigger `json:"trigger"` @@ -99,3 +88,16 @@ type BuildJob struct { CreatedAt time.Time `json:"createdAt"` CreatedBy *string `json:"createdBy"` } + +type AuditLog struct { + ID string `json:"id"` + ActorID *string `json:"actorId"` + ActorUsername string `json:"actorUsername"` + Action string `json:"action"` + ResourceType string `json:"resourceType"` + ResourceID string `json:"resourceId"` + IPAddress string `json:"ipAddress"` + UserAgent string `json:"userAgent"` + Details map[string]any `json:"details"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/backend/internal/cli/build.go b/backend/internal/cli/build.go deleted file mode 100644 index 04ff317..0000000 --- a/backend/internal/cli/build.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/cli/cli.go b/backend/internal/cli/cli.go deleted file mode 100644 index 0b57514..0000000 --- a/backend/internal/cli/cli.go +++ /dev/null @@ -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 - osaetctl posts publish - osaetctl posts unpublish - osaetctl posts delete - osaetctl posts edit - 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] -`) -} diff --git a/backend/internal/cli/config.go b/backend/internal/cli/config.go deleted file mode 100644 index f8d197b..0000000 --- a/backend/internal/cli/config.go +++ /dev/null @@ -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" -` -} diff --git a/backend/internal/cli/config_test.go b/backend/internal/cli/config_test.go deleted file mode 100644 index e0d461e..0000000 --- a/backend/internal/cli/config_test.go +++ /dev/null @@ -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") - } -} diff --git a/backend/internal/cli/content.go b/backend/internal/cli/content.go deleted file mode 100644 index 3f3d177..0000000 --- a/backend/internal/cli/content.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/cli/db.go b/backend/internal/cli/db.go deleted file mode 100644 index 581ddbb..0000000 --- a/backend/internal/cli/db.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/cli/markdown_test.go b/backend/internal/cli/markdown_test.go deleted file mode 100644 index 5e7969c..0000000 --- a/backend/internal/cli/markdown_test.go +++ /dev/null @@ -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") - } -} diff --git a/backend/internal/cli/posts.go b/backend/internal/cli/posts.go deleted file mode 100644 index c7d1a16..0000000 --- a/backend/internal/cli/posts.go +++ /dev/null @@ -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 ") - } - - 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 ", 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 ") - } - - 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 ") - } - - 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) -} diff --git a/backend/internal/cli/slug_test.go b/backend/internal/cli/slug_test.go deleted file mode 100644 index 6f0b54b..0000000 --- a/backend/internal/cli/slug_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/cli/sqlite_schema.sql b/backend/internal/cli/sqlite_schema.sql deleted file mode 100644 index 1e7410b..0000000 --- a/backend/internal/cli/sqlite_schema.sql +++ /dev/null @@ -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 -); diff --git a/backend/internal/cli/storage_test.go b/backend/internal/cli/storage_test.go deleted file mode 100644 index 48bc5d6..0000000 --- a/backend/internal/cli/storage_test.go +++ /dev/null @@ -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) - } -} diff --git a/backend/internal/cli/sync.go b/backend/internal/cli/sync.go deleted file mode 100644 index 61e4119..0000000 --- a/backend/internal/cli/sync.go +++ /dev/null @@ -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 -} diff --git a/backend/internal/cli/types.go b/backend/internal/cli/types.go deleted file mode 100644 index 87e9259..0000000 --- a/backend/internal/cli/types.go +++ /dev/null @@ -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...) -} diff --git a/backend/internal/cli/util.go b/backend/internal/cli/util.go deleted file mode 100644 index b4244e1..0000000 --- a/backend/internal/cli/util.go +++ /dev/null @@ -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) -} diff --git a/backend/internal/postimport/import.go b/backend/internal/postimport/import.go deleted file mode 100644 index 7d1eb11..0000000 --- a/backend/internal/postimport/import.go +++ /dev/null @@ -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 "" -} diff --git a/backend/internal/postimport/import_test.go b/backend/internal/postimport/import_test.go deleted file mode 100644 index 5e1cf33..0000000 --- a/backend/internal/postimport/import_test.go +++ /dev/null @@ -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) - } - } -} diff --git a/backend/internal/staticserver/server.go b/backend/internal/staticserver/server.go deleted file mode 100644 index cea15e6..0000000 --- a/backend/internal/staticserver/server.go +++ /dev/null @@ -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) -} diff --git a/backend/migrations/003_audit_logs.sql b/backend/migrations/003_audit_logs.sql new file mode 100644 index 0000000..a4f469d --- /dev/null +++ b/backend/migrations/003_audit_logs.sql @@ -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); diff --git a/config/local.example.yaml b/config/local.example.yaml index 03d27fe..85d875f 100644 --- a/config/local.example.yaml +++ b/config/local.example.yaml @@ -1,7 +1,5 @@ database: - driver: "sqlite" - sqlite_path: ".osaet/osaet.db" - postgres_dsn: "" + postgres_dsn: "postgres://osaet:password@127.0.0.1:5432/osaet?sslmode=disable" slug: provider: "deepseek" # deepseek | local diff --git a/frontend/admin/src/app/admin-api.service.ts b/frontend/admin/src/app/admin-api.service.ts index 2850b7d..0b9ff50 100644 --- a/frontend/admin/src/app/admin-api.service.ts +++ b/frontend/admin/src/app/admin-api.service.ts @@ -2,7 +2,7 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { - AssetResponse, + AuditLogsResponse, BuildJobResponse, LoginResponse, DeletePostResponse, @@ -99,14 +99,6 @@ export class AdminApiService { }); } - uploadAsset(file: File) { - const body = new FormData(); - body.append('file', file); - return this.http.post(`${this.baseUrl}/assets`, body, { - withCredentials: true - }); - } - generateSlug(title: string, summary: string, postId?: string) { return this.http.post( `${this.baseUrl}/slug`, @@ -114,4 +106,27 @@ export class AdminApiService { { 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(`${this.baseUrl}/audit-logs`, { + params, + withCredentials: true + }); + } } diff --git a/frontend/admin/src/app/app.component.css b/frontend/admin/src/app/app.component.css index fe4b188..bdad29a 100644 --- a/frontend/admin/src/app/app.component.css +++ b/frontend/admin/src/app/app.component.css @@ -23,25 +23,6 @@ button:disabled { 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 { background: transparent; color: #2f4a63; @@ -312,6 +293,19 @@ textarea { 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 { display: flex; align-items: center; @@ -371,6 +365,39 @@ textarea { 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 { overflow: hidden; font-weight: 700; @@ -461,8 +488,7 @@ textarea { box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%); } -.editor-actions button, -.editor-actions .upload-button { +.editor-actions button { min-height: 2.45em; border-radius: 0.62em; padding: 0 0.9em; @@ -470,14 +496,12 @@ textarea { white-space: nowrap; } -.editor-actions .upload-button, .editor-actions .save-button, .editor-actions .build-button { background: transparent; color: #2f4a63; } -.editor-actions .upload-button:hover, .editor-actions .save-button:hover, .editor-actions .build-button:hover { background: #243b53; @@ -791,8 +815,7 @@ textarea { align-items: stretch; } - .editor-actions button, - .editor-actions .upload-button { + .editor-actions button { width: 100%; } diff --git a/frontend/admin/src/app/app.component.html b/frontend/admin/src/app/app.component.html index e8a4b12..2d66273 100644 --- a/frontend/admin/src/app/app.component.html +++ b/frontend/admin/src/app/app.component.html @@ -35,6 +35,10 @@ / @if (view === 'list') { 文章 + } @else if (view === 'logs') { + + / + 日志 } @else { / @@ -45,6 +49,7 @@
{{ user.username }}
+
@@ -135,6 +140,82 @@ + + + } @else if (view === 'logs') { +
+
+
+

Audit

+

操作日志

+
+
+ + + + +
+
+ +
+ @if (auditLogs.length === 0) { +

暂无日志

+ } @else { +
+ 时间 + 用户 + 动作 + 资源 + 详情 + IP +
+ @for (log of auditLogs; track log.id) { +
+ {{ formatDate(log.createdAt) }} + {{ log.actorUsername || '匿名' }} + {{ actionText(log.action) }} + {{ log.resourceType || '-' }} {{ log.resourceId || '' }} + {{ detailsText(log) }} + {{ log.ipAddress || '-' }} +
+ } + } +
+ +
@@ -152,10 +233,6 @@
-
@@ -248,7 +325,6 @@
@if (editorMode !== 'preview') {