Initialize AutoLibrary website with Gitea deployment

This commit is contained in:
KenanZhu committed 2026-11-04 09:59:06 +08:00
commit 18216953e7
101 files changed
+12581

No files matched your search

+186
View File
@@ -0,0 +1,186 @@
package theme_files
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"io"
"log"
"os"
"autolibrary.top/webserver/backend/models/themes_model"
)
func CompressPreviewImage(data []byte) ([]byte, error) {
config, _, err := image.DecodeConfig(bytes.NewReader(data))
if err != nil || config.Width <= 0 || config.Height <= 0 || int64(config.Width)*int64(config.Height) > 40_000_000 {
return nil, fmt.Errorf("invalid image or image dimensions exceed the limit")
}
img, format, err := image.Decode(bytes.NewReader(data))
if err != nil {
return nil, fmt.Errorf("cannot decode image: %v", err)
}
// Resize if width exceeds 800px
bounds := img.Bounds()
w := bounds.Dx()
h := bounds.Dy()
maxW := 800
if w > maxW {
h = h * maxW / w
if h < 1 {
h = 1
}
w = maxW
// Simple resize using standard library
scaled := image.NewRGBA(image.Rect(0, 0, w, h))
for y := 0; y < h; y++ {
for x := 0; x < w; x++ {
srcX := x * bounds.Dx() / w
srcY := y * bounds.Dy() / h
scaled.Set(x, y, img.At(srcX, srcY))
}
}
img = scaled
}
var buf bytes.Buffer
err = jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80})
if err != nil {
return nil, fmt.Errorf("cannot encode JPEG: %v", err)
}
log.Printf("[I] image compressed: %s %dx%d, %d -> %d bytes", format, bounds.Dx(), bounds.Dy(), len(data), buf.Len())
return buf.Bytes(), nil
}
// GET /api/themes/featured?limit=6
func ValidateAlthemeFile(data []byte) (map[string]interface{}, error) {
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("invalid zip file: %v", err)
}
var hasInfoJSON, hasThemeQSS bool
var info map[string]interface{}
for _, f := range reader.File {
if f.Name == "info.json" {
hasInfoJSON = true
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("cannot open info.json: %v", err)
}
decoder := json.NewDecoder(rc)
if err := decoder.Decode(&info); err != nil {
rc.Close()
return nil, fmt.Errorf("invalid info.json: %v", err)
}
rc.Close()
}
if f.Name == "theme.qss" {
hasThemeQSS = true
}
}
if !hasInfoJSON {
return nil, fmt.Errorf("missing info.json in .altheme file")
}
if !hasThemeQSS {
return nil, fmt.Errorf("missing theme.qss in .altheme file")
}
required := []string{"name", "author", "need_theme", "brief"}
for _, key := range required {
if _, ok := info[key]; !ok {
return nil, fmt.Errorf("info.json missing required key: %s", key)
}
}
return info, nil
}
// buildInfoJSON constructs the info.json map from a Theme DB record.
// It merges Brief and Description with a newline separator into the "brief" field.
func BuildInfoJSON(theme themes_model.Theme) map[string]interface{} {
brief := theme.Brief
if theme.Description != "" {
brief = brief + "\n" + theme.Description
}
return map[string]interface{}{
"qss": "theme.qss",
"name": theme.Name,
"author": theme.Author,
"need_theme": theme.NeedTheme,
"brief": brief,
"version": theme.Version,
"compatible_version": theme.CompatVersion,
}
}
// createAlthemeArchive creates a .altheme ZIP archive in memory
// containing theme.qss (from qssData) and info.json (from info map).
func CreateAlthemeArchive(qssData []byte, info map[string]interface{}) ([]byte, error) {
var buf bytes.Buffer
w := zip.NewWriter(&buf)
// Add theme.qss
qssWriter, err := w.Create("theme.qss")
if err != nil {
return nil, fmt.Errorf("cannot create theme.qss in archive: %v", err)
}
if _, err := qssWriter.Write(qssData); err != nil {
return nil, fmt.Errorf("cannot write theme.qss to archive: %v", err)
}
// Add info.json
infoWriter, err := w.Create("info.json")
if err != nil {
return nil, fmt.Errorf("cannot create info.json in archive: %v", err)
}
infoBytes, err := json.MarshalIndent(info, "", " ")
if err != nil {
return nil, fmt.Errorf("cannot marshal info.json: %v", err)
}
if _, err := infoWriter.Write(infoBytes); err != nil {
return nil, fmt.Errorf("cannot write info.json to archive: %v", err)
}
if err := w.Close(); err != nil {
return nil, fmt.Errorf("cannot close archive: %v", err)
}
return buf.Bytes(), nil
}
// extractQSSFromAltheme reads an existing .altheme ZIP file from disk
// and returns the raw bytes of its theme.qss entry.
func ExtractQSSFromAltheme(filePath string) ([]byte, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("cannot read .altheme file: %v", err)
}
reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return nil, fmt.Errorf("cannot open .altheme as zip: %v", err)
}
for _, f := range reader.File {
if f.Name == "theme.qss" {
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("cannot open theme.qss in archive: %v", err)
}
defer rc.Close()
return io.ReadAll(rc)
}
}
return nil, fmt.Errorf("theme.qss not found in .altheme archive")
}
+69
View File
@@ -0,0 +1,69 @@
package theme_files
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Store owns writes under a single configured storage directory. Replacing an
// archive uses a temporary file so readers never receive partially written ZIPs.
type Store struct{ root string }
func NewStore(root string) (*Store, error) {
absolute, err := filepath.Abs(root)
if err != nil {
return nil, err
}
if err := os.MkdirAll(filepath.Join(absolute, "previews"), 0755); err != nil {
return nil, err
}
return &Store{root: absolute}, nil
}
func (s *Store) Root() string { return s.root }
func (s *Store) checked(path string) (string, error) {
absolute, err := filepath.Abs(path)
if err != nil {
return "", err
}
relative, err := filepath.Rel(s.root, absolute)
if err != nil || relative == "." || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) || filepath.IsAbs(relative) {
return "", fmt.Errorf("file path is outside theme storage")
}
return absolute, nil
}
func (s *Store) WriteFile(path string, data []byte, mode os.FileMode) error {
target, err := s.checked(path)
if err != nil {
return err
}
file, err := os.CreateTemp(filepath.Dir(target), ".upload-*")
if err != nil {
return err
}
temp := file.Name()
defer os.Remove(temp)
if _, err := file.Write(data); err != nil {
file.Close()
return err
}
if err := file.Chmod(mode); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return os.Rename(temp, target)
}
func (s *Store) Remove(path string) error {
target, err := s.checked(path)
if err != nil {
return err
}
if err := os.Remove(target); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
+117
View File
@@ -0,0 +1,117 @@
package themes_service
import (
"fmt"
"log"
"os"
"path/filepath"
"sync"
"autolibrary.top/webserver/backend/models/themes_model"
"autolibrary.top/webserver/backend/services/theme_files"
"gorm.io/gorm"
)
// Service coordinates database changes and their corresponding theme archive.
type Service struct {
DB *gorm.DB
Files *theme_files.Store
locks sync.Map
}
func (s *Service) lock(id uint) func() {
value, _ := s.locks.LoadOrStore(id, &sync.Mutex{})
mutex := value.(*sync.Mutex)
mutex.Lock()
return mutex.Unlock
}
func (s *Service) ArchivePath(id uint) string {
return filepath.Join(s.Files.Root(), fmt.Sprintf("%d.altheme", id))
}
func (s *Service) Create(theme *themes_model.Theme, qss []byte) error {
var path string
err := s.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(theme).Error; err != nil {
return err
}
path = s.ArchivePath(theme.ID)
data, err := theme_files.CreateAlthemeArchive(qss, theme_files.BuildInfoJSON(*theme))
if err != nil {
return err
}
if err := s.Files.WriteFile(path, data, 0644); err != nil {
return err
}
theme.FilePath = path
theme.FileSize = fmt.Sprintf("%.1f KB", float64(len(data))/1024)
return tx.Save(theme).Error
})
if err != nil && path != "" {
_ = s.Files.Remove(path)
}
return err
}
func (s *Service) Save(theme *themes_model.Theme, qss []byte) error {
unlock := s.lock(theme.ID)
defer unlock()
path := s.ArchivePath(theme.ID)
previous, readErr := os.ReadFile(path)
if qss == nil {
var err error
qss, err = theme_files.ExtractQSSFromAltheme(path)
if err != nil {
return fmt.Errorf("cannot read existing theme archive: %w", err)
}
}
data, err := theme_files.CreateAlthemeArchive(qss, theme_files.BuildInfoJSON(*theme))
if err != nil {
return err
}
theme.FilePath = path
theme.FileSize = fmt.Sprintf("%.1f KB", float64(len(data))/1024)
wrote := false
err = s.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(theme).Error; err != nil {
return err
}
if err := s.Files.WriteFile(path, data, 0644); err != nil {
return err
}
wrote = true
return nil
})
if err != nil && wrote {
var restoreErr error
if readErr == nil {
restoreErr = s.Files.WriteFile(path, previous, 0644)
} else {
restoreErr = s.Files.Remove(path)
}
if restoreErr != nil {
log.Printf("[E] archive rollback failed for theme %d: %v", theme.ID, restoreErr)
}
}
return err
}
func (s *Service) Delete(theme *themes_model.Theme) error {
unlock := s.lock(theme.ID)
defer unlock()
if err := s.DB.Delete(theme).Error; err != nil {
return err
}
paths := []string{s.ArchivePath(theme.ID)}
if theme.PreviewURL != "" {
paths = append(paths, filepath.Join(s.Files.Root(), "previews", filepath.Base(theme.PreviewURL)))
}
for _, path := range paths {
if err := s.Files.Remove(path); err != nil {
log.Printf("[W] theme %d file cleanup: %v", theme.ID, err)
}
}
return nil
}
+77
View File
@@ -0,0 +1,77 @@
package users_service
import (
"log"
"sync"
"time"
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
)
type user struct {
DisplayName string
Verified bool
}
// Cache atomically replaces snapshots and throttles refreshes, including misses.
// Failed authentication lookups preserve the last usable display-name snapshot.
type Cache struct {
mu sync.RWMutex
refresh sync.Mutex
users map[string]user
updated time.Time
enabled bool
}
func New(enabled bool) *Cache { return &Cache{users: make(map[string]user), enabled: enabled} }
func (c *Cache) Refresh() {
if !c.enabled {
return
}
c.refresh.Lock()
defer c.refresh.Unlock()
c.mu.RLock()
recent := time.Since(c.updated) < time.Minute
c.mu.RUnlock()
if recent {
return
}
users, err := casdoorsdk.GetUsers()
c.mu.Lock()
defer c.mu.Unlock()
c.updated = time.Now()
if err != nil {
log.Printf("[W] cannot refresh author cache: %v", err)
return
}
snapshot := make(map[string]user, len(users))
for _, u := range users {
name := u.DisplayName
if name == "" {
name = u.Name
}
snapshot[u.Id] = user{DisplayName: name, Verified: u.IsAdmin || u.Tag == "verified"}
}
c.users = snapshot
}
func (c *Cache) lookup(id string) (user, bool) {
c.mu.RLock()
stale := time.Since(c.updated) >= time.Minute
c.mu.RUnlock()
if stale {
c.Refresh()
}
c.mu.RLock()
defer c.mu.RUnlock()
u, ok := c.users[id]
return u, ok
}
func (c *Cache) ResolveAuthor(id, fallback string) string {
if u, ok := c.lookup(id); ok && u.DisplayName != "" {
return u.DisplayName
}
return fallback
}
func (c *Cache) IsVerified(id string) bool { u, ok := c.lookup(id); return ok && u.Verified }