feat: add admin publishing workflow and yar theme
Add Go/Postgres admin APIs, Angular admin UI, manual build flow, asset uploads, markdown import/export, configurable slug generation, and the Yar reading theme. Exclude local docs and generated development artifacts from version control.
This commit is contained in:
parent
b78f4b39c9
commit
f0b50d13ea
121 changed files with 27139 additions and 550 deletions
80
backend/internal/admin/assets.go
Normal file
80
backend/internal/admin/assets.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxAssetSizeBytes = 12 * 1024 * 1024
|
||||
|
||||
var safeAssetNamePattern = regexp.MustCompile(`[^a-zA-Z0-9._-]+`)
|
||||
|
||||
type AssetUploader struct {
|
||||
store *Store
|
||||
assetsDir string
|
||||
}
|
||||
|
||||
func NewAssetUploader(store *Store, assetsDir string) *AssetUploader {
|
||||
return &AssetUploader{store: store, assetsDir: assetsDir}
|
||||
}
|
||||
|
||||
func (u *AssetUploader) Upload(ctx context.Context, file multipart.File, header *multipart.FileHeader) (Asset, error) {
|
||||
if header.Size > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(file, maxAssetSizeBytes+1))
|
||||
if err != nil {
|
||||
return Asset{}, fmt.Errorf("read asset: %w", err)
|
||||
}
|
||||
if int64(len(data)) > maxAssetSizeBytes {
|
||||
return Asset{}, fmt.Errorf("asset is too large: max %d bytes", maxAssetSizeBytes)
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(data)
|
||||
if !strings.HasPrefix(mimeType, "image/") {
|
||||
return Asset{}, fmt.Errorf("unsupported asset type: %s", mimeType)
|
||||
}
|
||||
|
||||
sum := sha256.Sum256(data)
|
||||
sha := hex.EncodeToString(sum[:])
|
||||
name := assetFilename(header.Filename, sha)
|
||||
if err := os.MkdirAll(u.assetsDir, 0o755); err != nil {
|
||||
return Asset{}, fmt.Errorf("create assets dir: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(filepath.Join(u.assetsDir, name), data, 0o644); err != nil {
|
||||
return Asset{}, fmt.Errorf("write asset: %w", err)
|
||||
}
|
||||
|
||||
asset := Asset{
|
||||
Path: "/assets/" + name,
|
||||
OriginalName: header.Filename,
|
||||
MimeType: mimeType,
|
||||
SizeBytes: int64(len(data)),
|
||||
SHA256: sha,
|
||||
}
|
||||
return u.store.CreateAsset(ctx, asset)
|
||||
}
|
||||
|
||||
func assetFilename(original string, sha string) string {
|
||||
ext := strings.ToLower(filepath.Ext(original))
|
||||
base := strings.TrimSuffix(filepath.Base(original), filepath.Ext(original))
|
||||
base = strings.Trim(safeAssetNamePattern.ReplaceAllString(base, "-"), "-._")
|
||||
if base == "" {
|
||||
base = "image"
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
return fmt.Sprintf("%s-%s%s", base, sha[:12], ext)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue