diff --git a/backend/cmd/osaet-admin/main.go b/backend/cmd/osaet-admin/main.go index be333dd..68d2527 100644 --- a/backend/cmd/osaet-admin/main.go +++ b/backend/cmd/osaet-admin/main.go @@ -2,11 +2,16 @@ package main import ( "context" + "database/sql" "errors" "fmt" + "log" "net/http" + "net/url" "os" "os/signal" + "path/filepath" + "strings" "syscall" "time" @@ -81,6 +86,8 @@ func createUser(ctx context.Context, db *pgxpool.Pool) error { } func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error { + logServeStartup(ctx, cfg, db) + server := &http.Server{ Addr: cfg.Addr, Handler: admin.NewServerWithContext(ctx, db, cfg).Router(), @@ -89,18 +96,213 @@ 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() - return server.Shutdown(shutdownCtx) + if err := server.Shutdown(shutdownCtx); err != nil { + return err + } + log.Printf("http server stopped") + return nil 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/go.mod b/backend/go.mod index 8c838b7..848df3e 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -10,6 +10,24 @@ require ( ) 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 diff --git a/backend/go.sum b/backend/go.sum index f6a885c..097ad98 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,39 @@ +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= diff --git a/backend/internal/admin/config.go b/backend/internal/admin/config.go index 6c84626..9a90902 100644 --- a/backend/internal/admin/config.go +++ b/backend/internal/admin/config.go @@ -27,6 +27,7 @@ type Config struct { LogMaxBackups int DeepSeek DeepSeekConfig LocalLLM LocalLLMConfig + R2 R2Config SlugProvider string } @@ -44,6 +45,17 @@ 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"` @@ -64,6 +76,16 @@ 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 { @@ -144,6 +166,16 @@ 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)), + }, } } diff --git a/backend/internal/admin/r2.go b/backend/internal/admin/r2.go new file mode 100644 index 0000000..5751077 --- /dev/null +++ b/backend/internal/admin/r2.go @@ -0,0 +1,248 @@ +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 new file mode 100644 index 0000000..d858e25 --- /dev/null +++ b/backend/internal/admin/r2_test.go @@ -0,0 +1,30 @@ +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 ddd1bd9..0567075 100644 --- a/backend/internal/admin/router.go +++ b/backend/internal/admin/router.go @@ -22,6 +22,7 @@ type Server struct { builder *Builder deepSeek DeepSeekConfig localLLM LocalLLMConfig + uploader *ImageUploader slugProvider string adminDir string staticDir string @@ -46,12 +47,23 @@ func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Se 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 + } + } + return &Server{ db: db, store: store, builder: builder, deepSeek: cfg.DeepSeek, localLLM: cfg.LocalLLM, + uploader: uploader, slugProvider: cfg.SlugProvider, adminDir: cfg.AdminDir, staticDir: cfg.StaticDir, @@ -81,6 +93,7 @@ func (s *Server) Router() http.Handler { protected.GET("/me", s.me) protected.POST("/logout", s.logout) 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) @@ -347,6 +360,39 @@ func (s *Server) generateSlug(c *gin.Context) { 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 diff --git a/config/local.example.yaml b/config/local.example.yaml index 85d875f..239c53c 100644 --- a/config/local.example.yaml +++ b/config/local.example.yaml @@ -16,3 +16,13 @@ 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 0b9ff50..024a56b 100644 --- a/frontend/admin/src/app/admin-api.service.ts +++ b/frontend/admin/src/app/admin-api.service.ts @@ -4,6 +4,7 @@ import { Injectable, inject } from '@angular/core'; import { AuditLogsResponse, BuildJobResponse, + ImageUploadResponse, LoginResponse, DeletePostResponse, PostInput, @@ -129,4 +130,12 @@ export class AdminApiService { 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 bdad29a..94519c3 100644 --- a/frontend/admin/src/app/app.component.css +++ b/frontend/admin/src/app/app.component.css @@ -61,6 +61,13 @@ 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; @@ -560,6 +567,43 @@ 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; @@ -604,6 +648,7 @@ textarea { } .markdown-preview { + container-type: inline-size; overflow: auto; border: 1px solid #e8e5df; border-radius: 0.7em; @@ -636,9 +681,31 @@ textarea { .markdown-preview img { display: block; - max-width: min(100%, 42em); + 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 > 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 { @@ -827,4 +894,9 @@ 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 2d66273..d7c2f06 100644 --- a/frontend/admin/src/app/app.component.html +++ b/frontend/admin/src/app/app.component.html @@ -306,19 +306,30 @@ > - +

{{ editorMessage }}

diff --git a/frontend/admin/src/app/app.component.ts b/frontend/admin/src/app/app.component.ts index bae453e..bc46dc7 100644 --- a/frontend/admin/src/app/app.component.ts +++ b/frontend/admin/src/app/app.component.ts @@ -45,6 +45,7 @@ export class AppComponent implements OnInit, OnDestroy { loading = true; saving = false; generatingSlug = false; + uploadingImage = false; autosaveStatus = '未修改'; lastAutosavedAt: Date | null = null; @@ -485,6 +486,31 @@ export class AppComponent implements OnInit, OnDestroy { } } + async uploadImage(event: Event) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (!file) { + return; + } + + this.uploadingImage = true; + this.editorMessage = '正在上传图片'; + try { + const response = await firstValueFrom(this.api.uploadImage(file)); + this.insertMarkdown(response.image.markdown); + this.editorMessage = '图片已上传并插入正文'; + this.showFeedback('图片上传成功', response.image.url); + this.updateAutosaveStatus(); + } catch (error) { + const message = errorMessage(error); + this.editorMessage = message; + this.showFeedback('图片上传失败', message, 'error'); + } finally { + this.uploadingImage = false; + input.value = ''; + } + } + get totalPages() { return Math.max(1, Math.ceil(this.totalPosts / this.pageSize)); } @@ -612,6 +638,11 @@ export class AppComponent implements OnInit, OnDestroy { this.feedbackTimer = null; }, 3200); } + + private insertMarkdown(markdown: string) { + const body = this.draft.bodyMarkdown.trimEnd(); + this.draft.bodyMarkdown = body ? `${body}\n\n${markdown}\n` : `${markdown}\n`; + } } function normalizeTags(tags: string[]) { diff --git a/frontend/admin/src/app/models.ts b/frontend/admin/src/app/models.ts index 6d86860..f1fd0a0 100644 --- a/frontend/admin/src/app/models.ts +++ b/frontend/admin/src/app/models.ts @@ -98,3 +98,16 @@ export type AuditLogsResponse = { logs: AuditLog[]; total: number; }; + +export type UploadedImage = { + key: string; + url: string; + markdown: string; + filename: string; + size: number; + contentType: string; +}; + +export type ImageUploadResponse = { + image: UploadedImage; +}; diff --git a/frontend/site/public/favicon.svg b/frontend/site/public/favicon.svg new file mode 100644 index 0000000..17448dc --- /dev/null +++ b/frontend/site/public/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + ´༥` + diff --git a/frontend/site/src/components/seo/SeoHead.astro b/frontend/site/src/components/seo/SeoHead.astro index ae5b21f..91e17dd 100644 --- a/frontend/site/src/components/seo/SeoHead.astro +++ b/frontend/site/src/components/seo/SeoHead.astro @@ -36,6 +36,7 @@ const rssUrl = absoluteUrl('/rss.xml'); {pageTitle} + diff --git a/icon/favicon.svg b/icon/favicon.svg new file mode 100644 index 0000000..17448dc --- /dev/null +++ b/icon/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + ´༥` +