Add R2 image uploads to admin
All checks were successful
ci/woodpecker/push/ci Pipeline was successful

This commit is contained in:
yarnom 2026-06-04 14:31:45 +08:00
parent 9186801c7f
commit 49a0d078da
16 changed files with 809 additions and 14 deletions

View file

@ -2,11 +2,16 @@ package main
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log"
"net/http" "net/http"
"net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath"
"strings"
"syscall" "syscall"
"time" "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 { func serve(ctx context.Context, cfg admin.Config, db *pgxpool.Pool) error {
logServeStartup(ctx, cfg, db)
server := &http.Server{ server := &http.Server{
Addr: cfg.Addr, Addr: cfg.Addr,
Handler: admin.NewServerWithContext(ctx, db, cfg).Router(), 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) errCh := make(chan error, 1)
go func() { go func() {
log.Printf("http server listening addr=%s", cfg.Addr)
errCh <- server.ListenAndServe() errCh <- server.ListenAndServe()
}() }()
select { select {
case <-ctx.Done(): case <-ctx.Done():
log.Printf("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() 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: case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) { if errors.Is(err, http.ErrServerClosed) {
log.Printf("http server closed")
return nil return nil
} }
return err 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
}

View file

@ -10,6 +10,24 @@ require (
) )
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/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect

View file

@ -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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=

View file

@ -27,6 +27,7 @@ type Config struct {
LogMaxBackups int LogMaxBackups int
DeepSeek DeepSeekConfig DeepSeek DeepSeekConfig
LocalLLM LocalLLMConfig LocalLLM LocalLLMConfig
R2 R2Config
SlugProvider string SlugProvider string
} }
@ -44,6 +45,17 @@ type LocalLLMConfig struct {
NumPredict int NumPredict int
} }
type R2Config struct {
Endpoint string
Bucket string
Prefix string
AccessKeyID string
SecretAccessKey string
Region string
PublicBaseURL string
MaxUploadBytes int64
}
type localConfig struct { type localConfig struct {
Database struct { Database struct {
PostgresDSN string `yaml:"postgres_dsn"` PostgresDSN string `yaml:"postgres_dsn"`
@ -64,6 +76,16 @@ type localConfig struct {
TopP float64 `yaml:"top_p"` TopP float64 `yaml:"top_p"`
NumPredict int `yaml:"num_predict"` NumPredict int `yaml:"num_predict"`
} `yaml:"local_llm"` } `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 { func LoadConfig() Config {
@ -144,6 +166,16 @@ func LoadConfig() Config {
TopP: firstNonZeroFloat(envFloat("LOCAL_LLM_TOP_P"), local.LocalLLM.TopP, 0.8), TopP: firstNonZeroFloat(envFloat("LOCAL_LLM_TOP_P"), local.LocalLLM.TopP, 0.8),
NumPredict: firstNonZeroInt(envInt("LOCAL_LLM_NUM_PREDICT"), local.LocalLLM.NumPredict, 32), 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)),
},
} }
} }

View file

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

View file

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

View file

@ -22,6 +22,7 @@ type Server struct {
builder *Builder builder *Builder
deepSeek DeepSeekConfig deepSeek DeepSeekConfig
localLLM LocalLLMConfig localLLM LocalLLMConfig
uploader *ImageUploader
slugProvider string slugProvider string
adminDir string adminDir string
staticDir string staticDir string
@ -46,12 +47,23 @@ func NewServerWithContext(ctx context.Context, db *pgxpool.Pool, cfg Config) *Se
builder.Start(ctx) 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{ return &Server{
db: db, db: db,
store: store, store: store,
builder: builder, builder: builder,
deepSeek: cfg.DeepSeek, deepSeek: cfg.DeepSeek,
localLLM: cfg.LocalLLM, localLLM: cfg.LocalLLM,
uploader: uploader,
slugProvider: cfg.SlugProvider, slugProvider: cfg.SlugProvider,
adminDir: cfg.AdminDir, adminDir: cfg.AdminDir,
staticDir: cfg.StaticDir, staticDir: cfg.StaticDir,
@ -81,6 +93,7 @@ func (s *Server) Router() http.Handler {
protected.GET("/me", s.me) protected.GET("/me", s.me)
protected.POST("/logout", s.logout) protected.POST("/logout", s.logout)
protected.POST("/slug", s.generateSlug) protected.POST("/slug", s.generateSlug)
protected.POST("/images", s.uploadImage)
protected.GET("/audit-logs", s.listAuditLogs) protected.GET("/audit-logs", s.listAuditLogs)
protected.GET("/posts", s.listPosts) protected.GET("/posts", s.listPosts)
protected.POST("/posts", s.createPost) protected.POST("/posts", s.createPost)
@ -347,6 +360,39 @@ func (s *Server) generateSlug(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"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) { func (s *Server) listAuditLogs(c *gin.Context) {
if !s.requireStore(c) { if !s.requireStore(c) {
return return

View file

@ -16,3 +16,13 @@ local_llm:
temperature: 0.1 temperature: 0.1
top_p: 0.8 top_p: 0.8
num_predict: 32 num_predict: 32
r2:
endpoint: ""
bucket: ""
prefix: ""
accessKeyId: ""
secretAccessKey: ""
region: "auto"
publicBaseUrl: ""
maxUploadBytes: 20971520

View file

@ -4,6 +4,7 @@ import { Injectable, inject } from '@angular/core';
import { import {
AuditLogsResponse, AuditLogsResponse,
BuildJobResponse, BuildJobResponse,
ImageUploadResponse,
LoginResponse, LoginResponse,
DeletePostResponse, DeletePostResponse,
PostInput, PostInput,
@ -129,4 +130,12 @@ export class AdminApiService {
withCredentials: true withCredentials: true
}); });
} }
uploadImage(file: File) {
const body = new FormData();
body.append('file', file);
return this.http.post<ImageUploadResponse>(`${this.baseUrl}/images`, body, {
withCredentials: true
});
}
} }

View file

@ -61,6 +61,13 @@ label {
font-size: 0.9em; font-size: 0.9em;
} }
.body-field {
display: grid;
gap: 0.5em;
color: #55575d;
font-size: 0.9em;
}
textarea { textarea {
resize: vertical; resize: vertical;
line-height: 1.7; line-height: 1.7;
@ -560,6 +567,43 @@ textarea {
gap: 1em; 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 { .mode-switch {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -604,6 +648,7 @@ textarea {
} }
.markdown-preview { .markdown-preview {
container-type: inline-size;
overflow: auto; overflow: auto;
border: 1px solid #e8e5df; border: 1px solid #e8e5df;
border-radius: 0.7em; border-radius: 0.7em;
@ -636,9 +681,31 @@ textarea {
.markdown-preview img { .markdown-preview img {
display: block; 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; border-radius: 0.7em;
margin: 1.2em auto; 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 { .markdown-preview pre {
@ -827,4 +894,9 @@ textarea {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
} }
.body-tools {
width: 100%;
justify-content: flex-start;
}
} }

View file

@ -306,19 +306,30 @@
></textarea> ></textarea>
</label> </label>
<label class="body-field"> <div class="body-field">
<span class="body-label-row"> <span class="body-label-row">
正文 Markdown 正文 Markdown
<span class="mode-switch" role="group" aria-label="编辑模式"> <span class="body-tools">
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')"> <label class="image-upload-button" [class.disabled]="uploadingImage">
编辑 {{ uploadingImage ? '上传中' : '上传图片' }}
</button> <input
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')"> type="file"
预览 accept="image/*"
</button> [disabled]="uploadingImage"
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')"> (change)="uploadImage($event)"
分栏 />
</button> </label>
<span class="mode-switch" role="group" aria-label="编辑模式">
<button type="button" [class.active]="editorMode === 'edit'" (click)="setEditorMode('edit')">
编辑
</button>
<button type="button" [class.active]="editorMode === 'preview'" (click)="setEditorMode('preview')">
预览
</button>
<button type="button" [class.active]="editorMode === 'split'" (click)="setEditorMode('split')">
分栏
</button>
</span>
</span> </span>
</span> </span>
@ -336,7 +347,7 @@
<article class="markdown-preview" [innerHTML]="previewHtml"></article> <article class="markdown-preview" [innerHTML]="previewHtml"></article>
} }
</div> </div>
</label> </div>
<div class="editor-status-row"> <div class="editor-status-row">
<p class="message">{{ editorMessage }}</p> <p class="message">{{ editorMessage }}</p>

View file

@ -45,6 +45,7 @@ export class AppComponent implements OnInit, OnDestroy {
loading = true; loading = true;
saving = false; saving = false;
generatingSlug = false; generatingSlug = false;
uploadingImage = false;
autosaveStatus = '未修改'; autosaveStatus = '未修改';
lastAutosavedAt: Date | null = null; 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() { get totalPages() {
return Math.max(1, Math.ceil(this.totalPosts / this.pageSize)); return Math.max(1, Math.ceil(this.totalPosts / this.pageSize));
} }
@ -612,6 +638,11 @@ export class AppComponent implements OnInit, OnDestroy {
this.feedbackTimer = null; this.feedbackTimer = null;
}, 3200); }, 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[]) { function normalizeTags(tags: string[]) {

View file

@ -98,3 +98,16 @@ export type AuditLogsResponse = {
logs: AuditLog[]; logs: AuditLog[];
total: number; total: number;
}; };
export type UploadedImage = {
key: string;
url: string;
markdown: string;
filename: string;
size: number;
contentType: string;
};
export type ImageUploadResponse = {
image: UploadedImage;
};

View file

@ -0,0 +1,18 @@
<svg width="400" height="400" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="faviconGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e5485f"/>
<stop offset="100%" stop-color="#25aba4"/>
</linearGradient>
</defs>
<text
x="200"
y="250"
text-anchor="middle"
dominant-baseline="middle"
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="270"
font-weight="800"
letter-spacing="-4"
fill="url(#faviconGradient)">´༥`</text>
</svg>

After

Width:  |  Height:  |  Size: 612 B

View file

@ -36,6 +36,7 @@ const rssUrl = absoluteUrl('/rss.xml');
<title>{pageTitle}</title> <title>{pageTitle}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
<meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" /> <meta name="robots" content="index,follow,max-image-preview:large,max-snippet:-1,max-video-preview:-1" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="canonical" href={canonicalUrl} /> <link rel="canonical" href={canonicalUrl} />
<link rel="alternate" type="application/rss+xml" title={`${site.title} RSS`} href={rssUrl} /> <link rel="alternate" type="application/rss+xml" title={`${site.title} RSS`} href={rssUrl} />
<meta property="og:site_name" content={site.title} /> <meta property="og:site_name" content={site.title} />

18
icon/favicon.svg Normal file
View file

@ -0,0 +1,18 @@
<svg width="400" height="400" viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="faviconGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#e5485f"/>
<stop offset="100%" stop-color="#25aba4"/>
</linearGradient>
</defs>
<text
x="200"
y="250"
text-anchor="middle"
dominant-baseline="middle"
font-family="Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
font-size="270"
font-weight="800"
letter-spacing="-4"
fill="url(#faviconGradient)">´༥`</text>
</svg>

After

Width:  |  Height:  |  Size: 612 B