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 (
"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
}

View file

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

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

View file

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

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