diff --git a/backend/cmd/import-articles/main.go b/backend/cmd/import-articles/main.go new file mode 100644 index 0000000..a777735 --- /dev/null +++ b/backend/cmd/import-articles/main.go @@ -0,0 +1,33 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "osaet/backend/internal/postimport" +) + +func main() { + fs := flag.NewFlagSet("import-articles", flag.ExitOnError) + file := fs.String("file", "articles.csv", "CSV file path") + overwrite := fs.Bool("overwrite", false, "overwrite existing markdown files") + postsDir := fs.String("posts-dir", "content/posts", "posts output directory relative to project root") + if err := fs.Parse(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + + result, err := postimport.Import(postimport.Options{ + CSVPath: *file, + PostsDir: *postsDir, + Overwrite: *overwrite, + WorkingDir: "", + }) + if err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + os.Exit(1) + } + + fmt.Printf("imported %d post(s), skipped %d existing file(s), skipped %d non-post row(s)\n", result.Imported, result.SkippedExisting, result.SkippedNonPost) +} diff --git a/backend/cmd/osaet-admin/main.go b/backend/cmd/osaet-admin/main.go index 68d2527..8296088 100644 --- a/backend/cmd/osaet-admin/main.go +++ b/backend/cmd/osaet-admin/main.go @@ -2,16 +2,11 @@ package main import ( "context" - "database/sql" "errors" "fmt" - "log" "net/http" - "net/url" "os" "os/signal" - "path/filepath" - "strings" "syscall" "time" @@ -37,13 +32,6 @@ 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() @@ -61,6 +49,8 @@ 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) } @@ -85,9 +75,22 @@ func createUser(ctx context.Context, db *pgxpool.Pool) error { return nil } -func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { - logServeStartup(ctx, cfg, db) +func importMarkdown(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { + postsDir := cfg.PostsDir + if len(os.Args) >= 3 { + postsDir = os.Args[2] + } + result, err := admin.NewStore(db).ImportMarkdownPosts(ctx, postsDir) + if err != nil { + return err + } + + fmt.Fprintf(os.Stdout, "imported %d markdown post(s), skipped %d file(s)\n", result.Imported, result.Skipped) + return nil +} + +func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { server := &http.Server{ Addr: cfg.Addr, Handler: admin.NewServerWithContext(ctx, db, cfg).Router(), @@ -96,213 +99,18 @@ func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { errCh := make(chan error, 1) go func() { - log.Printf("http server listening addr=%s", cfg.Addr) errCh <- server.ListenAndServe() }() select { case <-ctx.Done(): - log.Printf("shutdown signal received") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - if err := server.Shutdown(shutdownCtx); err != nil { - return err - } - log.Printf("http server stopped") - return nil + return server.Shutdown(shutdownCtx) case err := <-errCh: if errors.Is(err, http.ErrServerClosed) { - log.Printf("http server closed") return nil } return err } } - -func logServeStartup(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) { - log.Printf("osaet-admin starting command=serve") - log.Printf("config addr=%s repo_root=%s", cfg.Addr, cleanPath(cfg.RepoRoot)) - log.Printf("paths posts=%s site=%s static=%s admin=%s migrations=%s", - cleanPath(cfg.PostsDir), - cleanPath(cfg.SiteDir), - cleanPath(cfg.StaticDir), - cleanPath(cfg.AdminDir), - cleanPath(cfg.MigrationsDir), - ) - log.Printf("logging file=%s max_bytes=%d max_backups=%d", cleanPath(cfg.LogFile), cfg.LogMaxBytes, cfg.LogMaxBackups) - log.Printf("database configured=%t %s", strings.TrimSpace(cfg.DatabaseURL) != "", databaseSummary(cfg.DatabaseURL)) - log.Printf("r2 configured=%t endpoint=%s bucket=%s prefix=%s public_base_url=%s max_upload_bytes=%d", - strings.TrimSpace(cfg.R2.Endpoint) != "" && - strings.TrimSpace(cfg.R2.Bucket) != "" && - strings.TrimSpace(cfg.R2.AccessKeyID) != "" && - strings.TrimSpace(cfg.R2.SecretAccessKey) != "", - r2EndpointHost(cfg.R2.Endpoint), - r2BucketName(cfg.R2.Bucket), - cleanR2Prefix(cfg.R2.Bucket, cfg.R2.Prefix), - cfg.R2.PublicBaseURL, - cfg.R2.MaxUploadBytes, - ) - logPathState("static output", cfg.StaticDir) - logPathState("admin output", cfg.AdminDir) - logPathState("posts snapshot", cfg.PostsDir) - logMigrationState(ctx, db, cfg.MigrationsDir) - logDatabaseInfo(ctx, db) - log.Printf("routes public=/ admin=/admin api=/api/admin health=/healthz ready=/readyz") -} - -func r2EndpointHost(endpoint string) string { - if strings.TrimSpace(endpoint) == "" { - return "" - } - parsed, err := url.Parse(endpoint) - if err != nil { - return "invalid" - } - return parsed.Host -} - -func r2BucketName(bucket string) string { - bucket = strings.Trim(strings.TrimSpace(bucket), "/") - parts := strings.SplitN(bucket, "/", 2) - return parts[0] -} - -func cleanR2Prefix(bucket string, prefix string) string { - bucket = strings.Trim(strings.TrimSpace(bucket), "/") - prefix = strings.Trim(strings.TrimSpace(prefix), "/") - parts := strings.SplitN(bucket, "/", 2) - if len(parts) == 2 && prefix != "" { - return parts[1] + "/" + prefix - } - if len(parts) == 2 { - return parts[1] - } - return prefix -} - -func logDatabaseInfo(ctx context.Context, db *pgxpool.Pool) { - if db == nil { - log.Printf("database pool unavailable") - return - } - - var database string - var user string - var serverAddr sql.NullString - var serverPort sql.NullInt32 - var serverVersion string - var timezone string - err := db.QueryRow(ctx, ` -SELECT current_database(), - current_user, - inet_server_addr()::text, - inet_server_port(), - current_setting('server_version'), - current_setting('TimeZone')`).Scan( - &database, - &user, - &serverAddr, - &serverPort, - &serverVersion, - &timezone, - ) - if err != nil { - log.Printf("database info failed: %v", err) - return - } - - addr := "local" - if serverAddr.Valid { - addr = serverAddr.String - } - port := int32(0) - if serverPort.Valid { - port = serverPort.Int32 - } - stats := db.Stat() - log.Printf("database connected db=%s user=%s server=%s port=%d postgres=%s timezone=%s", database, user, addr, port, serverVersion, timezone) - log.Printf("database pool total=%d idle=%d acquired=%d max=%d", stats.TotalConns(), stats.IdleConns(), stats.AcquiredConns(), stats.MaxConns()) -} - -func logMigrationState(ctx context.Context, db *pgxpool.Pool, migrationsDir string) { - migrations, err := admin.LoadMigrationFiles(migrationsDir) - if err != nil { - log.Printf("migrations dir=%s status=unreadable error=%v", cleanPath(migrationsDir), err) - return - } - - var tableExists bool - err = db.QueryRow(ctx, `SELECT to_regclass('public.admin_schema_migrations') IS NOT NULL`).Scan(&tableExists) - if err != nil { - log.Printf("migrations files=%d applied=unknown error=%v", len(migrations), err) - return - } - if !tableExists { - log.Printf("migrations files=%d applied=0 table=missing", len(migrations)) - return - } - - var applied int - if err := db.QueryRow(ctx, `SELECT count(*) FROM admin_schema_migrations`).Scan(&applied); err != nil { - log.Printf("migrations files=%d applied=unknown error=%v", len(migrations), err) - return - } - log.Printf("migrations files=%d applied=%d pending=%d", len(migrations), applied, maxInt(len(migrations)-applied, 0)) -} - -func logPathState(label string, path string) { - info, err := os.Stat(path) - if err != nil { - log.Printf("path %s=%s status=missing error=%v", label, cleanPath(path), err) - return - } - if info.IsDir() { - log.Printf("path %s=%s status=dir", label, cleanPath(path)) - return - } - log.Printf("path %s=%s status=file size=%d", label, cleanPath(path), info.Size()) -} - -func databaseSummary(databaseURL string) string { - if strings.TrimSpace(databaseURL) == "" { - return "dsn=empty" - } - parsed, err := url.Parse(databaseURL) - if err != nil { - return "dsn=unparseable" - } - user := "" - if parsed.User != nil { - user = parsed.User.Username() - } - dbName := strings.TrimPrefix(parsed.Path, "/") - sslMode := parsed.Query().Get("sslmode") - parts := []string{ - "scheme=" + parsed.Scheme, - "host=" + parsed.Host, - "db=" + dbName, - "user=" + user, - } - if sslMode != "" { - parts = append(parts, "sslmode="+sslMode) - } - return strings.Join(parts, " ") -} - -func cleanPath(path string) string { - if path == "" { - return "" - } - cleaned, err := filepath.Abs(path) - if err != nil { - return path - } - return cleaned -} - -func maxInt(a int, b int) int { - if a > b { - return a - } - return b -} diff --git a/backend/cmd/osaetctl/main.go b/backend/cmd/osaetctl/main.go new file mode 100644 index 0000000..2d51f79 --- /dev/null +++ b/backend/cmd/osaetctl/main.go @@ -0,0 +1,15 @@ +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 848df3e..90d26b0 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -5,33 +5,16 @@ 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 ( - github.com/aws/aws-sdk-go-v2 v1.41.11 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 // indirect - github.com/aws/aws-sdk-go-v2/config v1.32.22 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.21 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27 // indirect - github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1 // indirect - github.com/aws/aws-sdk-go-v2/service/signin v1.1.3 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.31.1 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.43.1 // indirect - github.com/aws/smithy-go v1.27.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -39,6 +22,8 @@ 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 @@ -48,16 +33,25 @@ 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 097ad98..2d282ee 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,39 +1,3 @@ -github.com/aws/aws-sdk-go-v2 v1.41.11 h1:9PRf7jyTMEUM6fuNRAJa2mO/skJfrF50rENJwf2LXqw= -github.com/aws/aws-sdk-go-v2 v1.41.11/go.mod h1:iiUX27gOXRuYaoeUVXhUpPwjJHzISfPAjjcuhUbLSVs= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12 h1:oRtsqWgxbpeXrOlxOoQStx2M9WNbIkPq4C4Xn1or6bc= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.12/go.mod h1:Zg0Oe9qT+9wcezlm1a64wGJp2qZdRElVxo/seJf7jYU= -github.com/aws/aws-sdk-go-v2/config v1.32.22 h1:Vfvp7+fYKsVCADcWOEllqEV47aIBXhNchvyDFu1B5fY= -github.com/aws/aws-sdk-go-v2/config v1.32.22/go.mod h1:0+H+0nPKbvWltf5vSIGkApv+hGbaQ4FfwTjGIYQREcw= -github.com/aws/aws-sdk-go-v2/credentials v1.19.21 h1:0+HscFXtNa4+3buV4IlG6v5lnOdzi5TrpicFGjKHgh4= -github.com/aws/aws-sdk-go-v2/credentials v1.19.21/go.mod h1:UE8+9t5zudFwu5k5ShC1PKArVEdOkQQdCXIHQAVNUcU= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27 h1:BEfN1sjtiKEdikRDxYkjZNE4tyvw/YbGWCbl3xDZgRw= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.27/go.mod h1:ISGSFNbOHRS+JV/17yStzRTPBUHHqF92kCpRLLyH3Nk= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27 h1:8sPbKi1/KRHwl5oR3qN9mUXestCeHuaRutxylnr/eVY= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.27/go.mod h1:QV9IVIopJ1dpQUno0f9VYDUwOEjj8u0iEJ4JiZVre3Y= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27 h1:9d8AoASQY9UwrOSmiJ7uSM0MGUPFhnenwSvpaFfat2c= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.27/go.mod h1:x0rldpsnUQaQIs4Rh+Vwm9Z/0vI6BxadGtsgJfZFb8s= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28 h1:eaS9vwQ5ym4Y9S6+G/K3d3lgZhxs9Sldcn/YS7cmdKY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.28/go.mod h1:oTdbDr+BMs7gAYrNpD0LDTyqQfv6yOYgTDv46+xbwFY= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11 h1:rFSsqDfCMPAmG70JOsYqFZCHXkyatoGa1K4YEt/BggQ= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.11/go.mod h1:XG68qW+YLLFH0vnSDCou43Cgj5TeAG83O5NRSJgt04Y= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20 h1:yt2fjgev3Hqm33zPw0ZWtki3sZ0SLcr+PkuvXDAAf/8= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.20/go.mod h1:wnPjCjPJ6x5GBhrER8f0QakaQ2LokfhCVYxmAZBpPjY= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27 h1:2/pUo42hhVmQcM21ttZoBOLHQymyUH8qEnZGTIuGBT8= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.27/go.mod h1:p7hwgbwompjCRNTdB3ytlldddNt1rDBgVVMqWEVG1II= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27 h1:JEXSW4wztrl1MoL5EMvJMO7lc/TRZloztrJKNl96SW8= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.27/go.mod h1:8eL+YgEqy6IYqjwW6PG0Ubn59a2xtCzbz7Pi18JBu04= -github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1 h1:WkX5IXwcxgO/WPTvhEqoSW2L1GB1OyIxk0vuzzdTftc= -github.com/aws/aws-sdk-go-v2/service/s3 v1.103.1/go.mod h1:9Q9ZHyiTItraw8BXpO48pk398Mou0YCSI+xvFcaGgxU= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.3 h1:t6U7sowMfOjTeZXtDOtgEJXsoJyX4MDag+sfWGwUM9M= -github.com/aws/aws-sdk-go-v2/service/signin v1.1.3/go.mod h1:WhO1EH3phjFWValQDsExaxncgEWJsHeoTvuyQAj3jwU= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.1 h1:TUV8oytPCX1PfVgZn0N8/sPZx7T0YasaMCBHox1erlw= -github.com/aws/aws-sdk-go-v2/service/sso v1.31.1/go.mod h1:tEL1hqCrkgwrDVL04HuLxz1SLUXdh+4kKhWv1pXKeiY= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4 h1:p9+Fizo2sUB6wI5Yb3K5lmykQAGs5JrKLBV/me6613Y= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.4/go.mod h1:0x10Wy0dVS4Gn552xhHY5th2QdYpfJf44EsfyYGV194= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.1 h1:r/vUkpLilfCA3sxbRnkHbJejaoVHEdj4FEhv+Zva4DU= -github.com/aws/aws-sdk-go-v2/service/sts v1.43.1/go.mod h1:t01JURC8Fe5M+7R1K0vzIZ2NT04HqvZR+FjlHrHDT2A= -github.com/aws/smithy-go v1.27.0 h1:ZoFioDKJxkSIW2otF9T0aPtNlUwhdVCcuZh/rzH9Hus= -github.com/aws/smithy-go v1.27.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= @@ -45,6 +9,8 @@ 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= @@ -66,6 +32,12 @@ 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= @@ -91,6 +63,8 @@ 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= @@ -99,6 +73,8 @@ 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= @@ -125,6 +101,8 @@ 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= @@ -134,6 +112,8 @@ 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= @@ -142,3 +122,29 @@ 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 new file mode 100644 index 0000000..f839df2 --- /dev/null +++ b/backend/internal/admin/assets.go @@ -0,0 +1,80 @@ +package admin + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "regexp" + "strings" +) + +const maxAssetSizeBytes = 12 * 1024 * 1024 + +var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) + +type AssetUploader struct { + store *Store + assetsDir string +} + +func NewAssetUploader(store *Store, assetsDir string) *AssetUploader { + return &AssetUploader{store: store, assetsDir: assetsDir} +} + +func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) { + if header.Size > maxAssetSizeBytes { + return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes) + } + + data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1)) + if err != nil { + return Asset{}, fmt.Errorf("read asset: %w", err) + } + if int64(len(data)) > maxAssetSizeBytes { + return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes) + } + + mimeType := http.DetectContentType(data) + if !strings.HasPrefix(mimeType, "image/") { + return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType) + } + + sum := sha256.Sum256(data) + sha := hex.EncodeToString(sum[:]) + name := assetFilename(header.Filename, sha) + if err := os.MkdirAll(u.assetsDir, 0o755); err != nil { + return Asset{}, fmt.Errorf("create assets dir: %w", err) + } + + if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil { + return Asset{}, fmt.Errorf("write asset: %w", err) + } + + asset := Asset{ + Path: "/assets/" + name, + OriginalName: header.Filename, + MimeType: mimeType, + SizeBytes: int64(len(data)), + SHA256: sha, + } + return u.store.CreateAsset(ctx, asset) +} + +func assetFilename(original string, sha string) string { + ext := strings.ToLower(filepath.Ext(original)) + base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original)) + base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._") + if base == "" { + base = "image" + } + if ext == "" { + ext = ".bin" + } + return fmt.Sprintf("%s-%s%s", base, sha[:12], ext) +} diff --git a/backend/internal/admin/config.go b/backend/internal/admin/config.go index 9a90902..fc33356 100644 --- a/backend/internal/admin/config.go +++ b/backend/internal/admin/config.go @@ -20,14 +20,10 @@ type Config struct { RepoRoot string PostsDir string SiteDir string - StaticDir string + AssetsDir string AdminDir string - LogFile string - LogMaxBytes int64 - LogMaxBackups int DeepSeek DeepSeekConfig LocalLLM LocalLLMConfig - R2 R2Config SlugProvider string } @@ -45,17 +41,6 @@ type LocalLLMConfig struct { NumPredict int } -type R2Config struct { - Endpoint string - Bucket string - Prefix string - AccessKeyID string - SecretAccessKey string - Region string - PublicBaseURL string - MaxUploadBytes int64 -} - type localConfig struct { Database struct { PostgresDSN string `yaml:"postgres_dsn"` @@ -76,16 +61,6 @@ type localConfig struct { TopP float64 `yaml:"top_p"` NumPredict int `yaml:"num_predict"` } `yaml:"local_llm"` - R2 struct { - Endpoint string `yaml:"endpoint"` - Bucket string `yaml:"bucket"` - Prefix string `yaml:"prefix"` - AccessKeyID string `yaml:"accessKeyId"` - SecretAccessKey string `yaml:"secretAccessKey"` - Region string `yaml:"region"` - PublicBaseURL string `yaml:"publicBaseUrl"` - MaxUploadBytes int64 `yaml:"maxUploadBytes"` - } `yaml:"r2"` } func LoadConfig() Config { @@ -114,9 +89,9 @@ func LoadConfig() Config { siteDir = filepath.Join(repoRoot, "frontend", "site") } - staticDir := os.Getenv("OSAET_STATIC_DIR") - if staticDir == "" { - staticDir = filepath.Join(repoRoot, "dist", "site") + assetsDir := os.Getenv("OSAET_ASSETS_DIR") + if assetsDir == "" { + assetsDir = filepath.Join(siteDir, "public", "assets") } adminDir := os.Getenv("OSAET_ADMIN_DIR") @@ -124,11 +99,6 @@ 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) @@ -148,11 +118,8 @@ func LoadConfig() Config { RepoRoot: repoRoot, PostsDir: postsDir, SiteDir: siteDir, - StaticDir: staticDir, + AssetsDir: assetsDir, 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, @@ -166,16 +133,6 @@ func LoadConfig() Config { TopP: firstNonZeroFloat(envFloat("LOCAL_LLM_TOP_P"), local.LocalLLM.TopP, 0.8), NumPredict: firstNonZeroInt(envInt("LOCAL_LLM_NUM_PREDICT"), local.LocalLLM.NumPredict, 32), }, - R2: R2Config{ - Endpoint: firstNonEmpty(os.Getenv("OSAET_R2_ENDPOINT"), local.R2.Endpoint), - Bucket: firstNonEmpty(os.Getenv("OSAET_R2_BUCKET"), local.R2.Bucket), - Prefix: firstNonEmpty(os.Getenv("OSAET_R2_PREFIX"), local.R2.Prefix), - AccessKeyID: firstNonEmpty(os.Getenv("OSAET_R2_ACCESS_KEY_ID"), local.R2.AccessKeyID), - SecretAccessKey: firstNonEmpty(os.Getenv("OSAET_R2_SECRET_ACCESS_KEY"), local.R2.SecretAccessKey), - Region: firstNonEmpty(os.Getenv("OSAET_R2_REGION"), local.R2.Region, "auto"), - PublicBaseURL: firstNonEmpty(os.Getenv("OSAET_R2_PUBLIC_BASE_URL"), local.R2.PublicBaseURL), - MaxUploadBytes: int64(firstNonZeroInt(envInt("OSAET_R2_MAX_UPLOAD_BYTES"), int(local.R2.MaxUploadBytes), 20*1024*1024)), - }, } } @@ -279,8 +236,8 @@ func (c Config) Validate() error { if c.SiteDir == "" { return errors.New("site dir is required") } - if c.StaticDir == "" { - return errors.New("static dir is required") + if c.AssetsDir == "" { + return errors.New("assets dir is required") } return nil } diff --git a/backend/internal/admin/logging.go b/backend/internal/admin/logging.go deleted file mode 100644 index 2bc55c6..0000000 --- a/backend/internal/admin/logging.go +++ /dev/null @@ -1,139 +0,0 @@ -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 new file mode 100644 index 0000000..07cf360 --- /dev/null +++ b/backend/internal/admin/markdown_import.go @@ -0,0 +1,239 @@ +package admin + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +type MarkdownImportResult struct { + Imported int + Skipped int +} + +type markdownFrontmatter struct { + Slug string `yaml:"slug"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Status string `yaml:"status"` + Tags []string `yaml:"tags"` + Cover string `yaml:"cover"` + Version int `yaml:"version"` + SlugSource string `yaml:"slug_source"` + SlugLocked bool `yaml:"slug_locked"` + PublishedAt string `yaml:"published_at"` + CreatedAt string `yaml:"created_at"` + UpdatedAt string `yaml:"updated_at"` +} + +func (s *Store) ImportMarkdownPosts(ctx context.Context, postsDir string) (MarkdownImportResult, error) { + entries, err := os.ReadDir(postsDir) + if err != nil { + return MarkdownImportResult{}, fmt.Errorf("read posts dir: %w", err) + } + + var result MarkdownImportResult + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + + post, err := readMarkdownPost(filepath.Join(postsDir, entry.Name())) + if err != nil { + return result, err + } + if post.Title == "" || post.Slug == "" { + result.Skipped++ + continue + } + if err := s.upsertImportedPost(ctx, post); err != nil { + return result, err + } + result.Imported++ + } + + return result, nil +} + +func readMarkdownPost(path string) (Post, error) { + data, err := os.ReadFile(path) + if err != nil { + return Post{}, fmt.Errorf("read markdown post %s: %w", path, err) + } + + frontmatterData, body, err := splitMarkdownFrontmatter(data) + if err != nil { + return Post{}, fmt.Errorf("parse markdown post %s: %w", path, err) + } + + var meta markdownFrontmatter + if err := yaml.Unmarshal(frontmatterData, &meta); err != nil { + return Post{}, fmt.Errorf("parse frontmatter %s: %w", path, err) + } + + slug := strings.TrimSpace(meta.Slug) + if slug == "" { + slug = strings.TrimSuffix(filepath.Base(path), ".md") + } + status := PostStatus(strings.TrimSpace(meta.Status)) + if !ValidPostStatus(status) || status == PostStatusDeleted { + status = PostStatusDraft + } + version := meta.Version + if version < 1 { + version = 1 + } + slugSource := strings.TrimSpace(meta.SlugSource) + if slugSource == "" { + slugSource = "manual" + } + + now := time.Now() + createdAt := parseFrontmatterTime(meta.CreatedAt, now) + updatedAt := parseFrontmatterTime(meta.UpdatedAt, createdAt) + var publishedAt *time.Time + if parsed, ok := parseOptionalFrontmatterTime(meta.PublishedAt); ok { + publishedAt = &parsed + } + + return Post{ + Slug: slug, + Title: strings.TrimSpace(meta.Title), + Summary: meta.Summary, + BodyMarkdown: strings.TrimLeft(string(body), "\n"), + Status: status, + Tags: normalizeTagNames(meta.Tags), + Cover: meta.Cover, + Version: version, + SlugSource: slugSource, + SlugLocked: meta.SlugLocked, + PublishedAt: publishedAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, nil +} + +func (s *Store) upsertImportedPost(ctx context.Context, post Post) error { + tx, err := s.db.Begin(ctx) + if err != nil { + return fmt.Errorf("begin markdown import: %w", err) + } + defer tx.Rollback(ctx) + + imported, err := scanPost(tx.QueryRow(ctx, ` +INSERT INTO posts ( + slug, title, summary, body_markdown, status, cover, version, + slug_source, slug_locked, published_at, created_at, updated_at +) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) +ON CONFLICT (slug) +DO UPDATE SET + title = excluded.title, + summary = excluded.summary, + body_markdown = excluded.body_markdown, + status = excluded.status, + cover = excluded.cover, + version = GREATEST(posts.version, excluded.version), + slug_source = excluded.slug_source, + slug_locked = excluded.slug_locked, + published_at = excluded.published_at, + updated_at = excluded.updated_at, + deleted_at = NULL +RETURNING id, slug, title, summary, body_markdown, status, cover, version, + slug_source, slug_locked, published_at, created_at, updated_at, deleted_at`, + post.Slug, + post.Title, + post.Summary, + post.BodyMarkdown, + post.Status, + post.Cover, + post.Version, + post.SlugSource, + post.SlugLocked, + post.PublishedAt, + post.CreatedAt, + post.UpdatedAt, + )) + if err != nil { + return fmt.Errorf("upsert markdown post %s: %w", post.Slug, err) + } + if err := replacePostTags(ctx, tx, imported.ID, post.Tags); err != nil { + return err + } + + if _, err := tx.Exec(ctx, ` +INSERT INTO post_versions (post_id, version, title, summary, body_markdown, status, reason) +VALUES ($1, $2, $3, $4, $5, $6, $7) +ON CONFLICT (post_id, version) DO NOTHING`, + imported.ID, + imported.Version, + imported.Title, + imported.Summary, + imported.BodyMarkdown, + imported.Status, + VersionReasonImport, + ); err != nil { + return fmt.Errorf("insert markdown import version %s: %w", post.Slug, err) + } + + if err := tx.Commit(ctx); err != nil { + return fmt.Errorf("commit markdown import: %w", err) + } + return nil +} + +func splitMarkdownFrontmatter(data []byte) ([]byte, []byte, error) { + if !bytes.HasPrefix(data, []byte("---\n")) { + return nil, nil, errors.New("missing frontmatter opening marker") + } + + rest := data[len("---\n"):] + idx := bytes.Index(rest, []byte("\n---")) + if idx < 0 { + return nil, nil, errors.New("missing frontmatter closing marker") + } + + frontmatter := rest[:idx] + body := rest[idx+len("\n---"):] + if bytes.HasPrefix(body, []byte("\r\n")) { + body = body[2:] + } else if bytes.HasPrefix(body, []byte("\n")) { + body = body[1:] + } + return frontmatter, body, nil +} + +func parseFrontmatterTime(value string, fallback time.Time) time.Time { + if parsed, ok := parseOptionalFrontmatterTime(value); ok { + return parsed + } + return fallback +} + +func parseOptionalFrontmatterTime(value string) (time.Time, bool) { + value = strings.TrimSpace(value) + if value == "" { + return time.Time{}, false + } + + layouts := []string{ + time.RFC3339, + "2006-01-02 15:04:05.999999999Z07", + "2006-01-02 15:04:05.999999Z07", + "2006-01-02 15:04:05Z07", + "2006-01-02 15:04:05", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + return parsed, true + } + } + return time.Time{}, false +} diff --git a/backend/internal/admin/r2.go b/backend/internal/admin/r2.go deleted file mode 100644 index 5751077..0000000 --- a/backend/internal/admin/r2.go +++ /dev/null @@ -1,248 +0,0 @@ -package admin - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/hex" - "errors" - "fmt" - "io" - "mime" - "net/http" - "net/url" - "path" - "path/filepath" - "strings" - "time" - - "github.com/aws/aws-sdk-go-v2/aws" - "github.com/aws/aws-sdk-go-v2/config" - "github.com/aws/aws-sdk-go-v2/credentials" - "github.com/aws/aws-sdk-go-v2/service/s3" -) - -type ImageUploader struct { - client *s3.Client - bucket string - prefix string - publicBaseURL string - maxUploadBytes int64 -} - -type UploadedImage struct { - Key string `json:"key"` - URL string `json:"url"` - Markdown string `json:"markdown"` - Filename string `json:"filename"` - Size int64 `json:"size"` - ContentType string `json:"contentType"` -} - -func NewImageUploader(ctx context.Context, cfg R2Config) (*ImageUploader, error) { - normalized, err := normalizeR2Config(cfg) - if err != nil { - return nil, err - } - - awsConfig, err := config.LoadDefaultConfig(ctx, - config.WithRegion(normalized.Region), - config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider( - normalized.AccessKeyID, - normalized.SecretAccessKey, - "", - )), - ) - if err != nil { - return nil, err - } - - client := s3.NewFromConfig(awsConfig, func(options *s3.Options) { - options.BaseEndpoint = aws.String(normalized.Endpoint) - options.UsePathStyle = true - }) - - return &ImageUploader{ - client: client, - bucket: normalized.Bucket, - prefix: normalized.Prefix, - publicBaseURL: normalized.PublicBaseURL, - maxUploadBytes: normalized.MaxUploadBytes, - }, nil -} - -func (u *ImageUploader) Upload(ctx context.Context, filename string, body io.Reader, size int64) (UploadedImage, error) { - if u == nil { - return UploadedImage{}, errors.New("R2 image uploader is not configured") - } - if size <= 0 { - return UploadedImage{}, errors.New("file is empty") - } - if u.maxUploadBytes > 0 && size > u.maxUploadBytes { - return UploadedImage{}, fmt.Errorf("file is too large: max %d bytes", u.maxUploadBytes) - } - - limit := u.maxUploadBytes - if limit <= 0 { - limit = 20 * 1024 * 1024 - } - data, err := io.ReadAll(io.LimitReader(body, limit+1)) - if err != nil { - return UploadedImage{}, err - } - if int64(len(data)) > limit { - return UploadedImage{}, fmt.Errorf("file is too large: max %d bytes", limit) - } - if len(data) == 0 { - return UploadedImage{}, errors.New("file is empty") - } - - head := data - if len(head) > 512 { - head = head[:512] - } - contentType := http.DetectContentType(head) - if !strings.HasPrefix(contentType, "image/") { - return UploadedImage{}, errors.New("only image files are supported") - } - - key, cleanFilename, err := u.objectKey(filename, contentType) - if err != nil { - return UploadedImage{}, err - } - - if _, err := u.client.PutObject(ctx, &s3.PutObjectInput{ - Bucket: aws.String(u.bucket), - Key: aws.String(key), - Body: bytes.NewReader(data), - ContentType: aws.String(contentType), - }); err != nil { - return UploadedImage{}, err - } - - publicURL := strings.TrimRight(u.publicBaseURL, "/") + "/" + escapePath(key) - return UploadedImage{ - Key: key, - URL: publicURL, - Markdown: fmt.Sprintf("![%s](%s)", cleanFilename, publicURL), - Filename: cleanFilename, - Size: size, - ContentType: contentType, - }, nil -} - -func (u *ImageUploader) objectKey(filename string, contentType string) (string, string, error) { - cleanFilename := sanitizeFilename(filename) - ext := strings.ToLower(filepath.Ext(cleanFilename)) - if ext == "" { - extensions, _ := mime.ExtensionsByType(contentType) - if len(extensions) > 0 { - ext = extensions[0] - cleanFilename += ext - } - } - token, err := randomHex(4) - if err != nil { - return "", "", err - } - stem := strings.TrimSuffix(cleanFilename, filepath.Ext(cleanFilename)) - keyName := fmt.Sprintf("%s-%s-%s%s", time.Now().Format("20060102-150405"), token, stem, ext) - return joinObjectPath(u.prefix, keyName), cleanFilename, nil -} - -func normalizeR2Config(cfg R2Config) (R2Config, error) { - cfg.Endpoint = strings.TrimSpace(cfg.Endpoint) - cfg.Bucket = strings.Trim(strings.TrimSpace(cfg.Bucket), "/") - cfg.Prefix = strings.Trim(strings.TrimSpace(cfg.Prefix), "/") - cfg.AccessKeyID = strings.TrimSpace(cfg.AccessKeyID) - cfg.SecretAccessKey = strings.TrimSpace(cfg.SecretAccessKey) - cfg.Region = firstNonEmpty(cfg.Region, "auto") - cfg.PublicBaseURL = strings.TrimSpace(cfg.PublicBaseURL) - if cfg.MaxUploadBytes <= 0 { - cfg.MaxUploadBytes = 20 * 1024 * 1024 - } - - endpoint, err := url.Parse(cfg.Endpoint) - if err != nil || endpoint.Scheme == "" || endpoint.Host == "" { - return R2Config{}, errors.New("r2.endpoint must be a valid URL") - } - endpointPath := strings.Trim(endpoint.Path, "/") - endpoint.Path = "" - endpoint.RawPath = "" - endpoint.RawQuery = "" - endpoint.Fragment = "" - cfg.Endpoint = endpoint.String() - - if cfg.Bucket == "" { - return R2Config{}, errors.New("r2.bucket is required") - } - bucketParts := strings.SplitN(cfg.Bucket, "/", 2) - cfg.Bucket = bucketParts[0] - if len(bucketParts) == 2 { - cfg.Prefix = joinObjectPath(bucketParts[1], cfg.Prefix) - } - if endpointPath != "" && endpointPath != cfg.Bucket { - cfg.Prefix = joinObjectPath(endpointPath, cfg.Prefix) - } - - if cfg.AccessKeyID == "" || cfg.SecretAccessKey == "" { - return R2Config{}, errors.New("r2 access keys are required") - } - if cfg.PublicBaseURL == "" { - return R2Config{}, errors.New("r2.publicBaseUrl is required") - } - if !strings.HasPrefix(cfg.PublicBaseURL, "http://") && !strings.HasPrefix(cfg.PublicBaseURL, "https://") { - cfg.PublicBaseURL = "https://" + cfg.PublicBaseURL - } - return cfg, nil -} - -func sanitizeFilename(filename string) string { - filename = strings.TrimSpace(filepath.Base(filename)) - if filename == "" || filename == "." { - return "image" - } - var out strings.Builder - for _, r := range filename { - switch { - case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': - out.WriteRune(r) - case r == '.', r == '-', r == '_': - out.WriteRune(r) - default: - out.WriteByte('-') - } - } - cleaned := strings.Trim(out.String(), ".-") // keep keys readable and URL-safe - if cleaned == "" { - return "image" - } - return cleaned -} - -func joinObjectPath(parts ...string) string { - clean := []string{} - for _, part := range parts { - part = strings.Trim(part, "/") - if part != "" { - clean = append(clean, part) - } - } - return path.Join(clean...) -} - -func escapePath(value string) string { - parts := strings.Split(value, "/") - for index, part := range parts { - parts[index] = url.PathEscape(part) - } - return strings.Join(parts, "/") -} - -func randomHex(bytes int) (string, error) { - buffer := make([]byte, bytes) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return hex.EncodeToString(buffer), nil -} diff --git a/backend/internal/admin/r2_test.go b/backend/internal/admin/r2_test.go deleted file mode 100644 index d858e25..0000000 --- a/backend/internal/admin/r2_test.go +++ /dev/null @@ -1,30 +0,0 @@ -package admin - -import "testing" - -func TestNormalizeR2ConfigSplitsBucketPath(t *testing.T) { - cfg, err := normalizeR2Config(R2Config{ - Endpoint: "https://example.r2.cloudflarestorage.com/stroage", - Bucket: "stroage/Image/", - AccessKeyID: "access-key", - SecretAccessKey: "secret-key", - Region: "auto", - PublicBaseURL: "r2.example.com", - MaxUploadBytes: 20, - }) - if err != nil { - t.Fatalf("normalizeR2Config returned error: %v", err) - } - if cfg.Endpoint != "https://example.r2.cloudflarestorage.com" { - t.Fatalf("unexpected endpoint: %s", cfg.Endpoint) - } - if cfg.Bucket != "stroage" { - t.Fatalf("unexpected bucket: %s", cfg.Bucket) - } - if cfg.Prefix != "Image" { - t.Fatalf("unexpected prefix: %s", cfg.Prefix) - } - if cfg.PublicBaseURL != "https://r2.example.com" { - t.Fatalf("unexpected public base URL: %s", cfg.PublicBaseURL) - } -} diff --git a/backend/internal/admin/router.go b/backend/internal/admin/router.go index 0567075..be12f05 100644 --- a/backend/internal/admin/router.go +++ b/backend/internal/admin/router.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "log" "net/http" "strconv" "strings" @@ -20,12 +19,11 @@ type Server struct { db *pgxpool.Pool store *Store builder *Builder + uploader *AssetUploader deepSeek DeepSeekConfig localLLM LocalLLMConfig - uploader *ImageUploader slugProvider string adminDir string - staticDir string ctx context.Context } @@ -40,33 +38,26 @@ 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) } - } - var uploader *ImageUploader - if cfg.R2.Endpoint != "" || cfg.R2.Bucket != "" || cfg.R2.AccessKeyID != "" || cfg.R2.SecretAccessKey != "" { - createdUploader, err := NewImageUploader(ctx, cfg.R2) - if err != nil { - log.Printf("R2 image uploader disabled: %v", err) - } else { - uploader = createdUploader + if cfg.AssetsDir != "" { + uploader = NewAssetUploader(store, cfg.AssetsDir) } } - return &Server{ db: db, store: store, builder: builder, + uploader: uploader, deepSeek: cfg.DeepSeek, localLLM: cfg.LocalLLM, - uploader: uploader, slugProvider: cfg.SlugProvider, adminDir: cfg.AdminDir, - staticDir: cfg.StaticDir, ctx: ctx, } } @@ -76,7 +67,6 @@ 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) @@ -92,9 +82,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.POST("/images", s.uploadImage) - protected.GET("/audit-logs", s.listAuditLogs) protected.GET("/posts", s.listPosts) protected.POST("/posts", s.createPost) protected.GET("/posts/:id", s.getPost) @@ -104,8 +93,6 @@ 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 } @@ -132,17 +119,6 @@ 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{ @@ -214,12 +190,10 @@ 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, @@ -240,14 +214,12 @@ 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}) } @@ -280,7 +252,6 @@ 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}) } @@ -300,7 +271,6 @@ 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}) } @@ -315,14 +285,33 @@ 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"` @@ -356,68 +345,9 @@ 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) uploadImage(c *gin.Context) { - if s.uploader == nil { - c.JSON(http.StatusServiceUnavailable, gin.H{"error": "R2 image uploader is not configured"}) - return - } - - file, err := c.FormFile("file") - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"}) - return - } - opened, err := file.Open() - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - defer opened.Close() - - uploaded, err := s.uploader.Upload(c.Request.Context(), file.Filename, opened, file.Size) - if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) - return - } - - s.auditCurrentUser(c, "image_upload", "image", uploaded.Key, gin.H{ - "filename": uploaded.Filename, - "url": uploaded.URL, - "size": uploaded.Size, - "contentType": uploaded.ContentType, - }) - c.JSON(http.StatusCreated, gin.H{"image": uploaded}) -} - -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": @@ -455,7 +385,6 @@ 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}) } @@ -471,12 +400,6 @@ 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, @@ -549,58 +472,3 @@ 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 5d95613..df68aac 100644 --- a/backend/internal/admin/static.go +++ b/backend/internal/admin/static.go @@ -46,33 +46,6 @@ 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 c0e60f0..1e33e15 100644 --- a/backend/internal/admin/store.go +++ b/backend/internal/admin/store.go @@ -3,7 +3,6 @@ package admin import ( "context" "database/sql" - "encoding/json" "errors" "fmt" "strings" @@ -26,25 +25,6 @@ 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"` @@ -409,6 +389,28 @@ 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, @@ -471,114 +473,6 @@ 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") @@ -886,35 +780,24 @@ func scanBuildJob(row postScanner) (BuildJob, error) { return job, nil } -func scanAuditLog(row postScanner) (AuditLog, error) { - var log AuditLog - var actorID sql.NullString - var details []byte +func scanAsset(row postScanner) (Asset, error) { + var asset Asset + var createdBy sql.NullString err := row.Scan( - &log.ID, - &actorID, - &log.ActorUsername, - &log.Action, - &log.ResourceType, - &log.ResourceID, - &log.IPAddress, - &log.UserAgent, - &details, - &log.CreatedAt, + &asset.ID, + &asset.Path, + &asset.OriginalName, + &asset.MimeType, + &asset.SizeBytes, + &asset.SHA256, + &asset.CreatedAt, + &createdBy, ) if err != nil { - return AuditLog{}, err + return Asset{}, err } - 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 + asset.CreatedBy = nullStringPtr(createdBy) + return asset, nil } func nullTimePtr(value sql.NullTime) *time.Time { diff --git a/backend/internal/admin/types.go b/backend/internal/admin/types.go index 3904a7d..9f11dee 100644 --- a/backend/internal/admin/types.go +++ b/backend/internal/admin/types.go @@ -76,6 +76,17 @@ 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"` @@ -88,16 +99,3 @@ 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 new file mode 100644 index 0000000..04ff317 --- /dev/null +++ b/backend/internal/cli/build.go @@ -0,0 +1,88 @@ +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 new file mode 100644 index 0000000..0b57514 --- /dev/null +++ b/backend/internal/cli/cli.go @@ -0,0 +1,103 @@ +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 new file mode 100644 index 0000000..f8d197b --- /dev/null +++ b/backend/internal/cli/config.go @@ -0,0 +1,106 @@ +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 new file mode 100644 index 0000000..e0d461e --- /dev/null +++ b/backend/internal/cli/config_test.go @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000..3f3d177 --- /dev/null +++ b/backend/internal/cli/content.go @@ -0,0 +1,130 @@ +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 new file mode 100644 index 0000000..581ddbb --- /dev/null +++ b/backend/internal/cli/db.go @@ -0,0 +1,146 @@ +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 new file mode 100644 index 0000000..5e7969c --- /dev/null +++ b/backend/internal/cli/markdown_test.go @@ -0,0 +1,57 @@ +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 new file mode 100644 index 0000000..c7d1a16 --- /dev/null +++ b/backend/internal/cli/posts.go @@ -0,0 +1,368 @@ +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 new file mode 100644 index 0000000..6f0b54b --- /dev/null +++ b/backend/internal/cli/slug_test.go @@ -0,0 +1,38 @@ +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 new file mode 100644 index 0000000..1e7410b --- /dev/null +++ b/backend/internal/cli/sqlite_schema.sql @@ -0,0 +1,35 @@ +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 new file mode 100644 index 0000000..48bc5d6 --- /dev/null +++ b/backend/internal/cli/storage_test.go @@ -0,0 +1,68 @@ +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 new file mode 100644 index 0000000..61e4119 --- /dev/null +++ b/backend/internal/cli/sync.go @@ -0,0 +1,558 @@ +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 new file mode 100644 index 0000000..87e9259 --- /dev/null +++ b/backend/internal/cli/types.go @@ -0,0 +1,87 @@ +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 new file mode 100644 index 0000000..b4244e1 --- /dev/null +++ b/backend/internal/cli/util.go @@ -0,0 +1,295 @@ +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 new file mode 100644 index 0000000..7d1eb11 --- /dev/null +++ b/backend/internal/postimport/import.go @@ -0,0 +1,460 @@ +package postimport + +import ( + "bytes" + "crypto/rand" + "encoding/csv" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "time" + "unicode" + + "gopkg.in/yaml.v3" +) + +const defaultPostsDir = "content/posts" + +type Options struct { + CSVPath string + PostsDir string + Overwrite bool + WorkingDir string +} + +type Result struct { + Imported int + SkippedExisting int + SkippedNonPost int +} + +type PostFile struct { + Path string + Frontmatter PostFrontmatter + Body string +} + +type PostFrontmatter struct { + ID string `yaml:"id"` + Slug string `yaml:"slug"` + Title string `yaml:"title"` + Summary string `yaml:"summary"` + Status string `yaml:"status"` + Tags []string `yaml:"tags"` + Cover string `yaml:"cover"` + Version int `yaml:"version"` + SlugSource string `yaml:"slug_source"` + SlugLocked bool `yaml:"slug_locked"` + PublishedAt *string `yaml:"published_at"` + CreatedAt string `yaml:"created_at"` + UpdatedAt string `yaml:"updated_at"` +} + +type csvArticle struct { + ID string + Slug string + Title string + BodyMD string + BodyHTML string + Status string + ArchiveID string + AuthorID string + PublishedAt string + CreatedAt string + UpdatedAt string + Type string +} + +func Import(options Options) (Result, error) { + root, err := findProjectRoot(options.WorkingDir) + if err != nil { + return Result{}, err + } + + csvPath := resolveRootPath(root, firstNonEmpty(options.CSVPath, "articles.csv")) + postsDir := resolveRootPath(root, firstNonEmpty(options.PostsDir, defaultPostsDir)) + if err := os.MkdirAll(postsDir, 0o755); err != nil { + return Result{}, err + } + + file, err := os.Open(csvPath) + if err != nil { + return Result{}, err + } + defer file.Close() + + reader := csv.NewReader(file) + reader.FieldsPerRecord = -1 + reader.LazyQuotes = false + + headers, err := reader.Read() + if err != nil { + return Result{}, err + } + index := map[string]int{} + for i, header := range headers { + index[strings.TrimSpace(header)] = i + } + + var result Result + for rowNum := 2; ; rowNum++ { + record, err := reader.Read() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return result, fmt.Errorf("%s row %d: %w", csvPath, rowNum, err) + } + + article := csvArticle{ + ID: csvValue(record, index, "id"), + Slug: csvValue(record, index, "slug"), + Title: csvValue(record, index, "title"), + BodyMD: csvValue(record, index, "body_md"), + BodyHTML: csvValue(record, index, "body_html"), + Status: csvValue(record, index, "status"), + ArchiveID: csvValue(record, index, "archive_id"), + AuthorID: csvValue(record, index, "author_id"), + PublishedAt: csvValue(record, index, "published_at"), + CreatedAt: csvValue(record, index, "created_at"), + UpdatedAt: csvValue(record, index, "updated_at"), + Type: csvValue(record, index, "type"), + } + + if strings.TrimSpace(article.Type) != "" && strings.TrimSpace(article.Type) != "post" { + result.SkippedNonPost++ + continue + } + + post, skippedExisting, err := articleToPost(root, postsDir, article, options.Overwrite) + if err != nil { + return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err) + } + if skippedExisting { + result.SkippedExisting++ + continue + } + if err := writePostFile(post); err != nil { + return result, fmt.Errorf("%s row %d (%s): %w", csvPath, rowNum, article.Slug, err) + } + result.Imported++ + } + + return result, nil +} + +func articleToPost(root string, postsDir string, article csvArticle, overwrite bool) (PostFile, bool, error) { + id := strings.TrimSpace(article.ID) + if id == "" { + id = randomID() + } + + title := strings.TrimSpace(article.Title) + if title == "" { + return PostFile{}, false, errors.New("missing title") + } + + slug := sanitizeSlug(article.Slug) + if slug == "" { + slug = fallbackSlug(title) + } + if slug == "" { + return PostFile{}, false, errors.New("missing slug") + } + + status := strings.TrimSpace(article.Status) + if status != "published" && status != "draft" { + status = "draft" + } + + createdAt, err := normalizeLegacyTime(article.CreatedAt) + if err != nil { + return PostFile{}, false, fmt.Errorf("invalid created_at: %w", err) + } + updatedAt, err := normalizeLegacyTime(article.UpdatedAt) + if err != nil { + return PostFile{}, false, fmt.Errorf("invalid updated_at: %w", err) + } + + var publishedAt *string + if strings.TrimSpace(article.PublishedAt) != "" { + normalized, err := normalizeLegacyTime(article.PublishedAt) + if err != nil { + return PostFile{}, false, fmt.Errorf("invalid published_at: %w", err) + } + publishedAt = &normalized + } + + path := filepath.Join(postsDir, slug+".md") + if _, err := os.Stat(path); err == nil { + if !overwrite { + return PostFile{}, true, nil + } + } else if !errors.Is(err, os.ErrNotExist) { + return PostFile{}, false, err + } + + finalSlug := slug + if !overwrite { + finalSlug, err = uniqueSlug(root, slug) + if err != nil { + return PostFile{}, false, err + } + path, err = uniquePostPath(postsDir, finalSlug) + if err != nil { + return PostFile{}, false, err + } + } + + body := strings.TrimLeft(article.BodyMD, "\n") + if strings.TrimSpace(body) == "" { + body = strings.TrimLeft(article.BodyHTML, "\n") + } + + return PostFile{ + Path: path, + Frontmatter: PostFrontmatter{ + ID: id, + Slug: finalSlug, + Title: title, + Summary: "", + Status: status, + Tags: []string{}, + Cover: "", + Version: 1, + SlugSource: "manual", + SlugLocked: true, + PublishedAt: publishedAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + Body: body, + }, false, nil +} + +func csvValue(record []string, index map[string]int, key string) string { + i, ok := index[key] + if !ok || i >= len(record) { + return "" + } + return record[i] +} + +func normalizeLegacyTime(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", errors.New("empty time") + } + layouts := []string{ + "2006-01-02 15:04:05.999999999Z07", + "2006-01-02 15:04:05.999999Z07", + "2006-01-02 15:04:05Z07", + time.RFC3339, + } + for _, layout := range layouts { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed.Format(time.RFC3339), nil + } + } + return "", fmt.Errorf("unsupported time format %q", value) +} + +func writePostFile(post PostFile) error { + var frontmatter bytes.Buffer + encoder := yaml.NewEncoder(&frontmatter) + encoder.SetIndent(2) + if err := encoder.Encode(post.Frontmatter); err != nil { + return err + } + if err := encoder.Close(); err != nil { + return err + } + + var output bytes.Buffer + output.WriteString("---\n") + output.Write(frontmatter.Bytes()) + output.WriteString("---\n\n") + output.WriteString(strings.TrimLeft(post.Body, "\n")) + + tmp := post.Path + ".tmp" + if err := os.WriteFile(tmp, output.Bytes(), 0o644); err != nil { + return err + } + return os.Rename(tmp, post.Path) +} + +func loadPosts(root string, postsDir string) ([]PostFile, error) { + dir := resolveRootPath(root, postsDir) + entries, err := os.ReadDir(dir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, err + } + var posts []PostFile + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".md") { + continue + } + posts = append(posts, PostFile{Path: filepath.Join(dir, entry.Name()), Frontmatter: PostFrontmatter{Slug: strings.TrimSuffix(entry.Name(), ".md")}}) + } + return posts, nil +} + +func slugExists(root string, slug string) bool { + posts, err := loadPosts(root, defaultPostsDir) + if err == nil { + for _, post := range posts { + fileSlug := strings.TrimSuffix(filepath.Base(post.Path), ".md") + if post.Frontmatter.Slug == slug || fileSlug == slug { + return true + } + } + } + + _, err = os.Stat(filepath.Join(root, defaultPostsDir, slug+".md")) + return err == nil +} + +func uniqueSlug(root string, slug string) (string, error) { + base := sanitizeSlug(slug) + if base == "" { + return "", errors.New("empty slug") + } + if !slugExists(root, base) { + return base, nil + } + for i := 2; i < 1000; i++ { + candidate := fmt.Sprintf("%s-%d", base, i) + if !slugExists(root, candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("could not find available slug for %q", base) +} + +func uniquePostPath(postsDir string, slug string) (string, error) { + candidate := filepath.Join(postsDir, slug+".md") + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate, nil + } else if err != nil { + return "", err + } + for i := 2; i < 1000; i++ { + candidate = filepath.Join(postsDir, fmt.Sprintf("%s-%d.md", slug, i)) + if _, err := os.Stat(candidate); errors.Is(err, os.ErrNotExist) { + return candidate, nil + } else if err != nil { + return "", err + } + } + return "", fmt.Errorf("could not find available filename for slug %q", slug) +} + +func findProjectRoot(start string) (string, error) { + wd := start + if wd == "" { + var err error + wd, err = os.Getwd() + if err != nil { + return "", err + } + } + for { + if isProjectRoot(wd) { + return wd, nil + } + parent := filepath.Dir(wd) + if parent == wd { + return "", errors.New("could not find project root") + } + wd = parent + } +} + +func isProjectRoot(dir string) bool { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "backend", "go.mod")); err == nil { + return true + } + } + if _, err := os.Stat(filepath.Join(dir, "backend", "cmd", "osaetctl")); err == nil { + if _, err := os.Stat(filepath.Join(dir, "frontend", "site", "package.json")); err == nil { + return true + } + } + return false +} + +func resolveRootPath(root string, path string) string { + if filepath.IsAbs(path) { + return path + } + return filepath.Join(root, path) +} + +func fallbackSlug(title string) string { + var words []string + var b strings.Builder + flush := func() { + if b.Len() > 0 { + words = append(words, b.String()) + b.Reset() + } + } + for _, r := range strings.ToLower(title) { + switch { + case r >= 'a' && r <= 'z': + b.WriteRune(r) + case r >= '0' && r <= '9': + b.WriteRune(r) + case unicode.IsSpace(r) || r == '-' || r == '_' || r == '/': + flush() + default: + flush() + } + } + flush() + if len(words) == 0 { + return "post-" + time.Now().Format("20060102150405") + } + return sanitizeSlug(strings.Join(words, "-")) +} + +func sanitizeSlug(slug string) string { + slug = strings.ToLower(strings.TrimSpace(slug)) + re := regexp.MustCompile(`[^a-z0-9]+`) + slug = re.ReplaceAllString(slug, "-") + slug = strings.Trim(slug, "-") + for strings.Contains(slug, "--") { + slug = strings.ReplaceAll(slug, "--", "-") + } + if len(slug) > 80 { + slug = strings.Trim(slug[:80], "-") + } + return slug +} + +func randomID() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return fmt.Sprintf("post-%d", time.Now().UnixNano()) + } + return hex.EncodeToString(b[:]) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/backend/internal/postimport/import_test.go b/backend/internal/postimport/import_test.go new file mode 100644 index 0000000..5e1cf33 --- /dev/null +++ b/backend/internal/postimport/import_test.go @@ -0,0 +1,125 @@ +package postimport + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestNormalizeLegacyTime(t *testing.T) { + got, err := normalizeLegacyTime("2026-01-13 01:25:27.486491+00") + if err != nil { + t.Fatal(err) + } + if got != "2026-01-13T01:25:27Z" { + t.Fatalf("normalized time = %q", got) + } +} + +func TestArticleToPostFallbackSlugAndBody(t *testing.T) { + root := t.TempDir() + postsDir := filepath.Join(root, defaultPostsDir) + if err := os.MkdirAll(postsDir, 0o755); err != nil { + t.Fatal(err) + } + + post, skipped, err := articleToPost(root, postsDir, csvArticle{ + Title: "Hello World", + BodyMD: "\nBody\n", + Status: "published", + CreatedAt: "2026-01-13 01:25:27.486491+00", + UpdatedAt: "2026-01-13 01:25:27.486491+00", + PublishedAt: "2026-01-13 01:25:27.486491+00", + Type: "post", + }, false) + if err != nil { + t.Fatal(err) + } + if skipped { + t.Fatal("expected importable post") + } + if post.Frontmatter.Slug != "hello-world" { + t.Fatalf("slug = %q", post.Frontmatter.Slug) + } + if post.Body != "Body\n" { + t.Fatalf("body = %q", post.Body) + } + if post.Frontmatter.PublishedAt == nil || *post.Frontmatter.PublishedAt != "2026-01-13T01:25:27Z" { + t.Fatalf("published_at = %#v", post.Frontmatter.PublishedAt) + } +} + +func TestArticleToPostSkipsExistingWithoutOverwrite(t *testing.T) { + root := t.TempDir() + postsDir := filepath.Join(root, defaultPostsDir) + if err := os.MkdirAll(postsDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(postsDir, "smoking.md"), []byte("existing"), 0o644); err != nil { + t.Fatal(err) + } + + _, skipped, err := articleToPost(root, postsDir, csvArticle{ + Slug: "smoking", + Title: "抽烟", + BodyMD: "Body", + Status: "published", + CreatedAt: "2026-01-13 01:25:27.486491+00", + UpdatedAt: "2026-01-13 01:25:27.486491+00", + Type: "post", + }, false) + if err != nil { + t.Fatal(err) + } + if !skipped { + t.Fatal("expected existing file to be skipped") + } +} + +func TestImportWritesMarkdown(t *testing.T) { + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "backend", "cmd", "osaetctl"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(root, "frontend", "site"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "frontend", "site", "package.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + csvPath := filepath.Join(root, "articles.csv") + csvContent := strings.Join([]string{ + "id,slug,title,body_md,body_html,status,archive_id,author_id,published_at,created_at,updated_at,type", + "post-1,test-post,Test Post,\"Line 1\n\nLine 2\",,published,,,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,2026-01-13 01:25:27.486491+00,post", + }, "\n") + if err := os.WriteFile(csvPath, []byte(csvContent), 0o644); err != nil { + t.Fatal(err) + } + + result, err := Import(Options{CSVPath: csvPath, PostsDir: defaultPostsDir, WorkingDir: root}) + if err != nil { + t.Fatal(err) + } + if result.Imported != 1 || result.SkippedExisting != 0 || result.SkippedNonPost != 0 { + t.Fatalf("result = %#v", result) + } + + data, err := os.ReadFile(filepath.Join(root, defaultPostsDir, "test-post.md")) + if err != nil { + t.Fatal(err) + } + content := string(data) + for _, want := range []string{ + "slug: test-post", + "title: Test Post", + "status: published", + "published_at: \"2026-01-13T01:25:27Z\"", + "Line 1", + "Line 2", + } { + if !strings.Contains(content, want) { + t.Fatalf("expected output to contain %q\n%s", want, content) + } + } +} diff --git a/backend/internal/staticserver/server.go b/backend/internal/staticserver/server.go new file mode 100644 index 0000000..cea15e6 --- /dev/null +++ b/backend/internal/staticserver/server.go @@ -0,0 +1,45 @@ +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 deleted file mode 100644 index a4f469d..0000000 --- a/backend/migrations/003_audit_logs.sql +++ /dev/null @@ -1,17 +0,0 @@ -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 239c53c..03d27fe 100644 --- a/config/local.example.yaml +++ b/config/local.example.yaml @@ -1,5 +1,7 @@ database: - postgres_dsn: "postgres://osaet:password@127.0.0.1:5432/osaet?sslmode=disable" + driver: "sqlite" + sqlite_path: ".osaet/osaet.db" + postgres_dsn: "" slug: provider: "deepseek" # deepseek | local @@ -16,13 +18,3 @@ local_llm: temperature: 0.1 top_p: 0.8 num_predict: 32 - -r2: - endpoint: "" - bucket: "" - prefix: "" - accessKeyId: "" - secretAccessKey: "" - region: "auto" - publicBaseUrl: "" - maxUploadBytes: 20971520 diff --git a/frontend/admin/src/app/admin-api.service.ts b/frontend/admin/src/app/admin-api.service.ts index 024a56b..2850b7d 100644 --- a/frontend/admin/src/app/admin-api.service.ts +++ b/frontend/admin/src/app/admin-api.service.ts @@ -2,9 +2,8 @@ import { HttpClient, HttpParams } from '@angular/common/http'; import { Injectable, inject } from '@angular/core'; import { - AuditLogsResponse, + AssetResponse, BuildJobResponse, - ImageUploadResponse, LoginResponse, DeletePostResponse, PostInput, @@ -100,6 +99,14 @@ 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`, @@ -107,35 +114,4 @@ 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 - }); - } - - uploadImage(file: File) { - const body = new FormData(); - body.append('file', file); - return this.http.post(`${this.baseUrl}/images`, body, { - withCredentials: true - }); - } } diff --git a/frontend/admin/src/app/app.component.css b/frontend/admin/src/app/app.component.css index 94519c3..fe4b188 100644 --- a/frontend/admin/src/app/app.component.css +++ b/frontend/admin/src/app/app.component.css @@ -23,6 +23,25 @@ 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; @@ -61,13 +80,6 @@ label { font-size: 0.9em; } -.body-field { - display: grid; - gap: 0.5em; - color: #55575d; - font-size: 0.9em; -} - textarea { resize: vertical; line-height: 1.7; @@ -300,19 +312,6 @@ 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; @@ -372,39 +371,6 @@ 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; @@ -495,7 +461,8 @@ textarea { box-shadow: 0 0.08em 0.35em rgb(35 36 40 / 4%); } -.editor-actions button { +.editor-actions button, +.editor-actions .upload-button { min-height: 2.45em; border-radius: 0.62em; padding: 0 0.9em; @@ -503,12 +470,14 @@ 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; @@ -567,43 +536,6 @@ textarea { gap: 1em; } -.body-tools { - display: inline-flex; - align-items: center; - flex-wrap: wrap; - gap: 0.45em; - justify-content: flex-end; -} - -.image-upload-button { - width: auto; - min-height: 2.35em; - display: inline-grid; - place-items: center; - border: 1px solid #eee; - border-radius: 0.65em; - background: #fff; - color: #2f4a63; - cursor: pointer; - font-size: 0.9em; - line-height: 1; - padding: 0 0.8em; -} - -.image-upload-button:hover { - background: #f1f3f5; - color: #1c3147; -} - -.image-upload-button.disabled { - cursor: not-allowed; - opacity: 0.55; -} - -.image-upload-button input { - display: none; -} - .mode-switch { display: inline-flex; align-items: center; @@ -648,7 +580,6 @@ textarea { } .markdown-preview { - container-type: inline-size; overflow: auto; border: 1px solid #e8e5df; border-radius: 0.7em; @@ -681,31 +612,9 @@ textarea { .markdown-preview img { display: block; - max-width: 80%; - max-width: min(100%, 80cqw); - max-height: min(48vh, 72cqw); - width: auto; - height: auto; + max-width: min(100%, 42em); border-radius: 0.7em; margin: 1.2em auto; - object-fit: contain; -} - -:host ::ng-deep .markdown-preview p > img, -:host ::ng-deep .markdown-preview img { - display: block; - max-width: 80%; - max-width: min(100%, 80cqw); - max-height: min(48vh, 72cqw); - width: auto; - height: auto; - border-radius: 0.7em; - margin: 1.2em auto; - object-fit: contain; -} - -:host ::ng-deep .markdown-preview p:has(> img:only-child) { - margin: 1.2em 0; } .markdown-preview pre { @@ -882,7 +791,8 @@ textarea { align-items: stretch; } - .editor-actions button { + .editor-actions button, + .editor-actions .upload-button { width: 100%; } @@ -894,9 +804,4 @@ textarea { align-items: flex-start; flex-direction: column; } - - .body-tools { - width: 100%; - justify-content: flex-start; - } } diff --git a/frontend/admin/src/app/app.component.html b/frontend/admin/src/app/app.component.html index d7c2f06..e8a4b12 100644 --- a/frontend/admin/src/app/app.component.html +++ b/frontend/admin/src/app/app.component.html @@ -35,10 +35,6 @@ / @if (view === 'list') { 文章 - } @else if (view === 'logs') { - - / - 日志 } @else { / @@ -49,7 +45,6 @@
{{ user.username }}
-
@@ -140,82 +135,6 @@ - - - } @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 || '-' }} -
- } - } -
- -
@@ -233,6 +152,10 @@
+
@@ -306,36 +229,26 @@ > -
+