Add R2 image uploads to admin
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
This commit is contained in:
parent
9186801c7f
commit
49a0d078da
16 changed files with 809 additions and 14 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
248
backend/internal/admin/r2.go
Normal file
248
backend/internal/admin/r2.go
Normal 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("", 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
|
||||
}
|
||||
30
backend/internal/admin/r2_test.go
Normal file
30
backend/internal/admin/r2_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<ImageUploadResponse>(`${this.baseUrl}/images`, body, {
|
||||
withCredentials: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -306,19 +306,30 @@
|
|||
></textarea>
|
||||
</label>
|
||||
|
||||
<label class="body-field">
|
||||
<div class="body-field">
|
||||
<span class="body-label-row">
|
||||
正文 Markdown
|
||||
<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 class="body-tools">
|
||||
<label class="image-upload-button" [class.disabled]="uploadingImage">
|
||||
{{ uploadingImage ? '上传中' : '上传图片' }}
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
[disabled]="uploadingImage"
|
||||
(change)="uploadImage($event)"
|
||||
/>
|
||||
</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>
|
||||
|
||||
|
|
@ -336,7 +347,7 @@
|
|||
<article class="markdown-preview" [innerHTML]="previewHtml"></article>
|
||||
}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="editor-status-row">
|
||||
<p class="message">{{ editorMessage }}</p>
|
||||
|
|
|
|||
|
|
@ -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[]) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
18
frontend/site/public/favicon.svg
Normal file
18
frontend/site/public/favicon.svg
Normal 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 |
|
|
@ -36,6 +36,7 @@ const rssUrl = absoluteUrl('/rss.xml');
|
|||
<title>{pageTitle}</title>
|
||||
<meta name="description" content={description} />
|
||||
<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="alternate" type="application/rss+xml" title={`${site.title} RSS`} href={rssUrl} />
|
||||
<meta property="og:site_name" content={site.title} />
|
||||
|
|
|
|||
18
icon/favicon.svg
Normal file
18
icon/favicon.svg
Normal 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 |
Loading…
Add table
Add a link
Reference in a new issue