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

+172
View File
@@ -0,0 +1,172 @@
package backend
import (
"context"
"errors"
"log"
"net"
"net/http"
"path/filepath"
"time"
"autolibrary.top/webserver/backend/database/bulletins_database"
"autolibrary.top/webserver/backend/database/themes_database"
"autolibrary.top/webserver/backend/database/versions_database"
"autolibrary.top/webserver/backend/handlers/bulletins_handler"
"autolibrary.top/webserver/backend/handlers/errors_handler"
"autolibrary.top/webserver/backend/handlers/health_handler"
"autolibrary.top/webserver/backend/handlers/themes_handler"
"autolibrary.top/webserver/backend/handlers/versions_handler"
"autolibrary.top/webserver/backend/middlewares/auth_middleware"
"autolibrary.top/webserver/backend/middlewares/casdoor_middleware"
"autolibrary.top/webserver/backend/services/theme_files"
"autolibrary.top/webserver/backend/services/users_service"
"autolibrary.top/webserver/config"
"autolibrary.top/webserver/utils"
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
"github.com/gin-gonic/gin"
)
// StartBackend : initializes the backend server.
func StartBackend() error { return StartBackendContext(context.Background()) }
func StartBackendContext(ctx context.Context) error {
defer EndBackend()
cfg := config.GetConfig()
gin.SetMode(gin.ReleaseMode)
err := versions_database.InitDB(cfg.Backend.Database.Versions.Path)
if err != nil {
log.Printf("[E] versions database initialization failed: %v", err)
return err
}
err = bulletins_database.InitDB(cfg.Backend.Database.Bulletins.Path)
if err != nil {
log.Printf("[E] bulletins database initialization failed: %v", err)
return err
}
err = themes_database.InitDB(cfg.Backend.Database.Themes.Path)
if err != nil {
log.Printf("[E] themes database initialization failed: %v", err)
return err
}
files, err := theme_files.NewStore(cfg.Backend.Database.ThemesFiles.Path)
if err != nil {
return err
}
users := users_service.New(cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "")
themes := themes_handler.New(themes_database.DB, cfg, files, users)
// Pre-initialize Casdoor SDK and user display-name cache
if cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != "" {
casdoorsdk.InitConfig(
cfg.Backend.Casdoor.Endpoint,
cfg.Backend.Casdoor.ClientID,
cfg.Backend.Casdoor.ClientSecret,
cfg.Backend.Casdoor.Certificate,
cfg.Backend.Casdoor.Organization,
cfg.Backend.Casdoor.Application,
)
casdoorsdk.SetHttpClient(&http.Client{Timeout: 15 * time.Second})
}
r := gin.New()
r.MaxMultipartMemory = 8 << 20
r.Use(func(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20); c.Next() })
r.Use(gin.Recovery())
r.Use(func(c *gin.Context) {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key, X-Requested-With")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
templateDir := filepath.Join(utils.GetRootPath(), "backend", "templates")
templateFiles := []string{
filepath.Join(templateDir, "404.html"),
}
r.LoadHTMLFiles(templateFiles...)
public := r.Group("/")
{
public.GET("/health", health_handler.GetHealth)
public.GET("/download/latest", versions_handler.GetLatestDownload)
public.GET("/download/versions", versions_handler.GetDownloadVersions)
public.GET("/download/versions/:id", versions_handler.GetDownloadVersionByID)
public.GET("/bulletins", bulletins_handler.GetBulletins)
public.GET("/bulletins/:id", bulletins_handler.GetBulletinByID)
}
// Marketplace public routes
mp := r.Group("/marketplace")
{
mp.GET("/auth/config", themes.GetAuthConfig)
mp.GET("/auth/captcha", themes.GetCaptcha)
mp.POST("/auth/login", themes.LoginProxy)
mp.POST("/auth/signup", themes.SignupProxy)
mp.GET("/themes/featured", themes.GetFeaturedThemes)
mp.GET("/themes/preview/:filename", themes.ServePreview)
mp.GET("/themes", themes.GetThemes)
mp.GET("/themes/:id", themes.GetThemeByID)
mp.GET("/themes/:id/download", themes.DownloadTheme)
}
// Marketplace admin routes (API key)
mpAdmin := r.Group("/marketplace")
mpAdmin.Use(auth_middleware.APIKeyAuth())
{
mpAdmin.PUT("/admin/themes/:id/review", themes.ReviewTheme)
mpAdmin.GET("/admin/themes/pending", themes.GetPendingThemes)
}
// Marketplace user routes (Casdoor JWT)
mpUser := r.Group("/marketplace")
mpUser.Use(casdoor_middleware.CasdoorAuth())
{
mpUser.POST("/themes", themes.PostTheme)
mpUser.PUT("/themes/:id", themes.PutThemeByID)
mpUser.DELETE("/themes/:id", themes.DeleteThemeByID)
mpUser.GET("/user/check-theme-name", themes.CheckThemeName)
mpUser.POST("/themes/:id/preview", themes.UploadPreview)
mpUser.POST("/themes/:id/file", themes.ReplaceThemeFile)
mpUser.GET("/user/themes", themes.GetUserThemes)
mpUser.GET("/user/downloads", themes.GetUserDownloads)
mpUser.GET("/user/role", themes.GetUserRole)
}
// Marketplace admin routes (Casdoor JWT + admin check)
mpAdminUser := r.Group("/marketplace")
mpAdminUser.Use(casdoor_middleware.CasdoorAdminAuth())
{
mpAdminUser.GET("/admin/pending", themes.GetPendingThemes)
mpAdminUser.PUT("/admin/review/:id", themes.ReviewTheme)
mpAdminUser.GET("/admin/download/:id", themes.AdminDownloadTheme)
}
protected := r.Group("/")
protected.Use(auth_middleware.APIKeyAuth())
{
protected.POST("/download/versions", versions_handler.PostDownloadVersion)
protected.PUT("/download/versions/:id", versions_handler.PutDownloadVersionByID)
protected.DELETE("/download/versions/:id", versions_handler.DeleteDownloadVersionByID)
protected.POST("/bulletins", bulletins_handler.PostBulletin)
protected.PUT("/bulletins/:id", bulletins_handler.PutBulletinByID)
protected.DELETE("/bulletins/:id", bulletins_handler.DeleteBulletinByID)
}
r.NoRoute(errors_handler.GetNotFoundError)
backend_address := net.JoinHostPort(cfg.Backend.Host, cfg.Backend.Port)
log.Printf("[I] backend server is running on: http://%s", backend_address)
return utils.ServeHTTP(ctx, backend_address, r)
}
// EndBackend : closes the database connection.
func EndBackend() error {
return errors.Join(versions_database.CloseDB(), bulletins_database.CloseDB(), themes_database.CloseDB())
}
+7
View File
@@ -0,0 +1,7 @@
package database
import (
_ "autolibrary.top/webserver/backend/database/versions_database"
_ "autolibrary.top/webserver/backend/database/bulletins_database"
_ "autolibrary.top/webserver/backend/database/themes_database"
)
@@ -0,0 +1,50 @@
package bulletins_database
import (
"log"
"autolibrary.top/webserver/backend/models/bulletins_model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB // global bulletins database instance
func InitDB(dsn string) error {
var err error
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
log.Printf("[E] unable to connect to bulletins database: %v", err)
return err
}
err = DB.AutoMigrate(
&bulletins_model.Bulletin{},
)
if err != nil {
log.Printf("[E] bulletins database migration failed: %v", err)
return err
}
log.Printf("[I] bulletins database initialization completed")
return nil
}
func CloseDB() error {
sqlDB, err := DB.DB()
if err != nil {
log.Printf("[E] unable to get bulletins database connection: %v", err)
return err
}
err = sqlDB.Close()
if err != nil {
log.Printf("[E] unable to close bulletins database connection: %v", err)
return err
}
log.Printf("[I] bulletins database connection closed")
return nil
}
@@ -0,0 +1,54 @@
package themes_database
import (
"log"
"autolibrary.top/webserver/backend/models/themes_model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB
func InitDB(dsn string) error {
var err error
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
log.Printf("[E] unable to connect to themes database: %v", err)
return err
}
err = DB.AutoMigrate(
&themes_model.Theme{},
)
if err != nil {
log.Printf("[E] themes database migration failed: %v", err)
return err
}
log.Printf("[I] themes database initialization completed")
return nil
}
func CloseDB() error {
if DB == nil {
return nil
}
db, err := DB.DB()
if err != nil {
log.Printf("[E] unable to get themes database connection: %v", err)
return err
}
err = db.Close()
if err != nil {
log.Printf("[E] unable to close themes database connection: %v", err)
return err
}
log.Printf("[I] themes database connection closed")
return nil
}
@@ -0,0 +1,54 @@
package versions_database
import (
"log"
"autolibrary.top/webserver/backend/models/versions_model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
var DB *gorm.DB // global versions database instance
func InitDB(dsn string) error {
var err error
DB, err = gorm.Open(sqlite.Open(dsn), &gorm.Config{})
if err != nil {
log.Printf("[E] unable to connect to versions database: %v", err)
return err
}
err = DB.AutoMigrate(
&versions_model.DownloadVersion{},
)
if err != nil {
log.Printf("[E] versions database migration failed: %v", err)
return err
}
log.Printf("[I] versions database initialization completed")
return nil
}
func CloseDB() error {
if DB == nil {
return nil
}
db, err := DB.DB()
if err != nil {
log.Printf("[E] unable to get versions database connection: %v", err)
return err
}
err = db.Close()
if err != nil {
log.Printf("[E] unable to close versions database connection: %v", err)
return err
}
log.Printf("[I] versions database connection closed")
return nil
}
+9
View File
@@ -0,0 +1,9 @@
package handlers
import (
_ "autolibrary.top/webserver/backend/handlers/errors_handler"
_ "autolibrary.top/webserver/backend/handlers/health_handler"
_ "autolibrary.top/webserver/backend/handlers/versions_handler"
_ "autolibrary.top/webserver/backend/handlers/bulletins_handler"
_ "autolibrary.top/webserver/backend/handlers/themes_handler"
)
@@ -0,0 +1,310 @@
package bulletins_handler
import (
"log"
"net/http"
"strconv"
"time"
"autolibrary.top/webserver/utils"
"autolibrary.top/webserver/backend/database/bulletins_database"
"autolibrary.top/webserver/backend/models/bulletins_model"
"github.com/gin-gonic/gin"
)
/*
These handlers are used to manage bulletins.
*/
// GET /bulletins?date={yyyy-MM-dd}&time={HH:mm:ss}&range_hour={0<number}
//
// This handler regard the given 'date' and 'time' in Shanghai(+8:00) time zone.
func GetBulletins(c *gin.Context) {
dateStr := c.Query("date")
timeStr := c.Query("time")
rangeHourStr := c.Query("range_hour")
loc := utils.ShanghaiLocation
// Default to current date_time and range
if dateStr == "" {
dateStr = utils.NowInShanghai().Format("2006-01-02")
}
if timeStr == "" {
timeStr = "00:00:00"
}
if rangeHourStr == "" {
rangeHourStr = "24"
}
dateTimeStr := dateStr + " " + timeStr
dateTime, err := utils.ParseInShanghai("2006-01-02 15:04:05", dateTimeStr)
if err != nil {
log.Printf("[E] GET > try to get bulletins, but got invalid (date=%s, time=%s, tz=%s) parameters: %v", dateStr, timeStr, loc.String(), err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > try to get bulletins, but got invalid parameters: 'date' or 'time'",
"data": nil,
})
return
}
rangeHours, err := strconv.Atoi(rangeHourStr)
if err != nil {
log.Printf("[E] GET > try to get bulletins, but got invalid (range_hour=%s) parameter: %v", rangeHourStr, err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > try to get bulletins, but got invalid parameter: 'range_hour'",
"data": nil,
})
return
}
startOfDateTime := dateTime
endOfDateTime := startOfDateTime.Add(time.Duration(rangeHours)*time.Hour)
startOfDateTimeStr := startOfDateTime.Format(time.RFC3339)
endOfDateTimeStr := endOfDateTime.Format(time.RFC3339)
var bulletins []bulletins_model.Bulletin
result := bulletins_database.DB.Where(
"date_time >= ? AND date_time < ? AND is_deleted = ?", startOfDateTime, endOfDateTime, false,
).Find(&bulletins)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get bulletins from %s to %s: %v", startOfDateTimeStr, endOfDateTimeStr, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get bulletins",
"data": nil,
})
return
}
var deleteIds []int64
result = bulletins_database.DB.Model(bulletins_model.Bulletin{}).Where(
"date_time >= ? AND date_time < ? AND is_deleted = ?", startOfDateTime, endOfDateTime, true,
).Pluck("id", &deleteIds)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get bulletins from %s to %s: %v", startOfDateTimeStr, endOfDateTimeStr, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get bulletins",
"data": nil,
})
return
}
data := gin.H{
"bulletins": bulletins,
"delete_ids": deleteIds,
}
log.Printf("[I] GET > get bulletins from %s to %s successfully", startOfDateTimeStr, endOfDateTimeStr)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get bulletins successful",
"data": data,
})
}
// GET /bulletins/:id
func GetBulletinByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] GET > try to get bulletin, but got invalid id parameter: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > try to get bulletin, but got invalid id",
"data": nil,
})
return
}
var bulletin bulletins_model.Bulletin
result := bulletins_database.DB.Where("is_deleted = ?", false).First(&bulletin, id)
if result.Error != nil {
log.Printf("[E] GET > bulletin (id=%d) can not found: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "GET > bulletin can not found",
"data": nil,
})
return
}
log.Printf("[I] GET > get bulletin (id=%d) successfully", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get bulletin successful",
"data": bulletin,
})
}
// POST /bulletins
func PostBulletin(c *gin.Context) {
var bulletin bulletins_model.Bulletin
if err := c.ShouldBindJSON(&bulletin); err != nil {
log.Printf("[E] POST > try to create bulletin, but got invalid request data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "POST > try to create bulletin, but got invalid request data",
"data": nil,
})
return
}
bulletin.CreateAt = utils.NowInShanghai()
// set default value to make sure the bulletin is not edited and not deleted before
// get into the database
bulletin.IsEdited = false
bulletin.IsDeleted = false
result := bulletins_database.DB.Create(&bulletin)
if result.Error != nil {
log.Printf("[E] POST > occur an error when create bulletin: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "POST > occur an error when create bulletin",
"data": nil,
})
return
}
dataTimeStr := bulletin.DateTime.Format(time.RFC3339)
id := strconv.FormatUint(uint64(bulletin.ID), 10)
log.Printf("[I] POST > create bulletin (id=%s, date_time=%s) successful", id, dataTimeStr)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "POST > create bulletin successful",
"data": bulletin,
})
}
// PUT /bulletins/:id
func PutBulletinByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] PUT > try to update bulletin, but got invalid id parameter: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "PUT > try to update bulletin, but got invalid id",
"data": nil,
})
return
}
var updateBulletin bulletins_model.Bulletin
if err := c.ShouldBindJSON(&updateBulletin); err != nil {
log.Printf("[E] PUT > try to update bulletin, but got invalid request data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "PUT > try to update bulletin, but got invalid request data",
"data": nil,
})
return
}
var existingBulletin bulletins_model.Bulletin
result := bulletins_database.DB.Where("is_deleted = ?", false).First(&existingBulletin, id)
if result.Error != nil {
log.Printf("[E] PUT > bulletin (id=%d) can not found: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "PUT > bulletin can not found",
"data": nil,
})
return
}
// keep the original date time
updateBulletin.DateTime = existingBulletin.DateTime
// set last edit time to current time
updateBulletin.LastEditAt = utils.NowInShanghai()
// set is edited to true
updateBulletin.IsEdited = true
result = bulletins_database.DB.Model(&existingBulletin).Where("id = ?", id).Updates(updateBulletin)
if result.Error != nil {
log.Printf("[E] PUT > occur an error when update bulletin (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "PUT > occur an error when update bulletin",
"data": nil,
})
return
}
// post check after update
var updatedBulletin bulletins_model.Bulletin
if result = bulletins_database.DB.Where("is_deleted = ?", false).First(&updatedBulletin, id); result.Error == nil {
log.Printf("[I] PUT > update bulletin (id=%d) successful", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "PUT > update bulletin successful",
"data": updatedBulletin,
})
} else {
log.Printf("[E] PUT > update successful but failed to retrieve updated bulletin (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "PUT > update successful but failed to retrieve updated bulletin",
"data": nil,
})
}
}
// DELETE /bulletins/:id
func DeleteBulletinByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] DELETE > try to delete bulletin, but got invalid id: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "DELETE > try to delete bulletin, but got invalid id",
"data": nil,
})
return
}
// soft delete, actually is a update operation
var existingBulletin bulletins_model.Bulletin
result := bulletins_database.DB.Where("is_deleted = ?", false).First(&existingBulletin, id)
if result.Error != nil {
log.Printf("[E] DELETE > bulletin (id=%d) can not found: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "DELETE > bulletin can not found",
"data": nil,
})
return
}
// set is_deleted to true
existingBulletin.IsDeleted = true
result = bulletins_database.DB.Model(&existingBulletin).Where("id = ?", id).Updates(existingBulletin)
if result.Error != nil {
log.Printf("[E] DELETE > occur an error when soft delete bulletin (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "DELETE > occur an error when soft delete bulletin",
"data": nil,
})
return
}
// post check after soft delete
var deletedBulletin bulletins_model.Bulletin
if result = bulletins_database.DB.Where("is_deleted = ?", true).First(&deletedBulletin, id); result.Error == nil {
log.Printf("[I] DELETE > soft delete bulletin (id=%d) successful", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "DELETE > soft delete bulletin successful",
"data": deletedBulletin,
})
} else {
log.Printf("[E] DELETE > soft delete successful but failed to retrieve deleted bulletin (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "DELETE > soft delete successful but failed to retrieve deleted bulletin",
"data": nil,
})
}
}
@@ -0,0 +1,45 @@
package errors_handler
import (
"encoding/json"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// 404 - Not Found
func GetNotFoundError(c *gin.Context) {
errorData := gin.H{
"code": http.StatusNotFound,
"msg": c.Request.Method + " > the requested API endpoint was not found",
"data": gin.H{
"path": c.Request.URL.Path,
"method": c.Request.Method,
"endpoints": gin.H{
"health": "/health",
"downloads": "/download/versions",
},
},
}
acceptHeader := c.GetHeader("Accept")
if strings.Contains(acceptHeader, "text/html") {
// for browser requests, return HTML
jsonBytes, err := json.Marshal(errorData)
if err != nil {
jsonBytes = []byte(`{"error": "json_serialization_failed"}`)
}
jsonStr := string(jsonBytes)
c.HTML(http.StatusNotFound, "404.html", gin.H{
"title": "404 - Not Found",
"path": c.Request.URL.Path,
"method": c.Request.Method,
"baseURL": c.Request.Host,
"jsonData": jsonStr,
})
return
}
// for General requests (no text/html in Accept header), return JSON
c.JSON(http.StatusNotFound, errorData)
}
@@ -0,0 +1,22 @@
package health_handler
import (
"log"
"time"
"net/http"
"github.com/gin-gonic/gin"
)
// GET /health
func GetHealth(c *gin.Context) {
log.Printf("[I] HEALTH > get backend server health")
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > backend server is running ok",
"data": gin.H{
"timestamp": time.Now().Unix(),
"status": "healthy",
},
})
}
+226
View File
@@ -0,0 +1,226 @@
package themes_handler
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"github.com/dchest/captcha"
"github.com/gin-gonic/gin"
)
func (h *Handler) GetUserRole(c *gin.Context) {
userID, _ := c.Get("user_id")
userName, _ := c.Get("user_name")
displayName, _ := c.Get("display_name")
isAdmin, _ := c.Get("is_admin")
userTag, _ := c.Get("user_tag")
isAdminBool := false
if isAdmin != nil {
isAdminBool = isAdmin.(bool)
}
displayNameStr := ""
if displayName != nil {
displayNameStr = displayName.(string)
}
userTagStr := ""
if userTag != nil {
userTagStr = userTag.(string)
}
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > user role",
"data": gin.H{
"user_id": userID,
"user_name": userName,
"display_name": displayNameStr,
"is_admin": isAdminBool,
"is_verified": isAdminBool || userTagStr == "verified",
},
})
}
// GET /api/user/downloads (stretch goal)
func (h *Handler) GetUserDownloads(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > download history not yet implemented",
"data": []interface{}{},
})
}
// GET /api/marketplace/auth/captcha
func (h *Handler) GetCaptcha(c *gin.Context) {
id := captcha.NewLen(4)
var buf bytes.Buffer
if err := captcha.WriteImage(&buf, id, 150, 50); err != nil {
log.Printf("[E] CAPTCHA > failed to generate: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "captcha generation failed", "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > captcha",
"data": gin.H{
"captchaId": id,
"captchaImage": "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes()),
},
})
}
// GET /api/marketplace/auth/config
func (h *Handler) GetAuthConfig(c *gin.Context) {
cfg := h.Config
casdoorConfigured := cfg.Backend.Casdoor.Endpoint != "" && cfg.Backend.Casdoor.ClientID != ""
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > auth config",
"data": gin.H{
"casdoor_configured": casdoorConfigured,
"casdoor_endpoint": cfg.Backend.Casdoor.Endpoint,
"casdoor_client_id": cfg.Backend.Casdoor.ClientID,
"casdoor_organization": cfg.Backend.Casdoor.Organization,
"casdoor_application": cfg.Backend.Casdoor.Application,
},
})
}
// POST /api/marketplace/auth/login
func (h *Handler) LoginProxy(c *gin.Context) {
cfg := h.Config
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "PUT > invalid request body", "data": nil})
return
}
// Proxy to Casdoor OAuth token endpoint
tokenURL := cfg.Backend.Casdoor.Endpoint + "/api/login/oauth/access_token"
values := url.Values{
"grant_type": {"password"},
"client_id": {cfg.Backend.Casdoor.ClientID},
"client_secret": {cfg.Backend.Casdoor.ClientSecret},
"username": {req.Username},
"password": {req.Password},
"scope": {"read"},
}
reqHTTP, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, tokenURL, strings.NewReader(values.Encode()))
if err != nil {
fail(c, http.StatusBadGateway, "Invalid authentication server configuration")
return
}
reqHTTP.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := h.HTTP.Do(reqHTTP)
if err != nil {
log.Printf("[E] AUTH > login proxy error: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"code": http.StatusBadGateway, "msg": "auth server unreachable", "data": nil})
return
}
defer resp.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
if readErr != nil {
fail(c, http.StatusBadGateway, "Unable to read authentication response")
return
}
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusUnauthorized, gin.H{"code": http.StatusUnauthorized, "msg": "invalid credentials", "data": nil})
return
}
c.Header("Content-Type", "application/json")
c.String(http.StatusOK, string(body))
}
// POST /api/marketplace/auth/signup
func (h *Handler) SignupProxy(c *gin.Context) {
cfg := h.Config
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Name string `json:"name"`
CaptchaId string `json:"captchaId"`
CaptchaCode string `json:"captchaCode"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "PUT > invalid request body", "data": nil})
return
}
if req.Username == "" || req.Password == "" {
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "用户名和密码为必填项", "data": nil})
return
}
// Validate captcha
if !captcha.VerifyString(req.CaptchaId, req.CaptchaCode) {
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": "验证码错误或已过期", "data": nil})
return
}
// Proxy to Casdoor signup API
signupData := map[string]interface{}{
"organization": cfg.Backend.Casdoor.Organization,
"username": req.Username,
"password": req.Password,
"application": cfg.Backend.Casdoor.Application,
}
if req.Email != "" {
signupData["email"] = req.Email
}
if req.Name != "" {
signupData["name"] = req.Name
}
signupURL := cfg.Backend.Casdoor.Endpoint + "/api/signup"
signupBody, _ := json.Marshal(signupData)
reqHTTP, err := http.NewRequestWithContext(c.Request.Context(), http.MethodPost, signupURL, bytes.NewReader(signupBody))
if err != nil {
fail(c, http.StatusBadGateway, "Invalid authentication server configuration")
return
}
reqHTTP.Header.Set("Content-Type", "application/json")
resp, err := h.HTTP.Do(reqHTTP)
if err != nil {
log.Printf("[E] AUTH > signup proxy error: %v", err)
c.JSON(http.StatusBadGateway, gin.H{"code": http.StatusBadGateway, "msg": "auth server unreachable", "data": nil})
return
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var result map[string]interface{}
json.Unmarshal(body, &result)
if status, ok := result["status"]; !ok || status != "ok" {
msg := "注册失败"
if m, ok := result["msg"]; ok {
msg = fmt.Sprintf("%v", m)
}
c.JSON(http.StatusBadRequest, gin.H{"code": http.StatusBadRequest, "msg": msg, "data": nil})
return
}
log.Printf("[I] AUTH > signup successful for user: %s", req.Username)
c.Header("Content-Type", "application/json")
c.String(http.StatusOK, string(body))
}
@@ -0,0 +1,28 @@
package themes_handler
import (
"net/http"
"time"
"autolibrary.top/webserver/backend/services/theme_files"
"autolibrary.top/webserver/backend/services/themes_service"
"autolibrary.top/webserver/backend/services/users_service"
"autolibrary.top/webserver/config"
"gorm.io/gorm"
)
// Handler shares explicit dependencies across the marketplace HTTP handlers.
// A separate instance can be constructed for each database and storage root.
type Handler struct {
DB *gorm.DB
Config *config.Config
Files *theme_files.Store
Users *users_service.Cache
HTTP *http.Client
Themes *themes_service.Service
}
func New(db *gorm.DB, cfg *config.Config, files *theme_files.Store, users *users_service.Cache) *Handler {
return &Handler{DB: db, Config: cfg, Files: files, Users: users, Themes: &themes_service.Service{DB: db, Files: files}, HTTP: &http.Client{Timeout: 15 * time.Second}}
}
@@ -0,0 +1,71 @@
package themes_handler
import (
"errors"
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"autolibrary.top/webserver/backend/models/themes_model"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func (h *Handler) DownloadTheme(c *gin.Context) { h.serveTheme(c, true) }
func (h *Handler) AdminDownloadTheme(c *gin.Context) { h.serveTheme(c, false) }
func (h *Handler) serveTheme(c *gin.Context, public bool) {
id, err := parseThemeID(c)
if err != nil {
fail(c, http.StatusBadRequest, "Invalid theme ID")
return
}
var theme themes_model.Theme
query := h.DB
if public {
query = query.Where("status = ?", themes_model.ThemeApproved)
}
err = query.First(&theme, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "Theme not found")
return
}
if err != nil {
fail(c, 500, "Unable to load theme")
return
}
path := h.Themes.ArchivePath(theme.ID)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
fail(c, 404, "Theme file not found")
return
}
if public {
h.DB.Model(&theme).UpdateColumn("download_count", gorm.Expr("download_count + 1"))
}
version := strings.TrimLeft(theme.Version, "vV")
name := fmt.Sprintf("%s_v%s.altheme", theme.Name, version)
c.Header("Content-Disposition", mime.FormatMediaType("attachment", map[string]string{"filename": name}))
c.Header("Content-Type", "application/zip")
c.File(path)
}
func (h *Handler) ServePreview(c *gin.Context) {
name := c.Param("filename")
if name == "." || name == ".." || strings.ContainsAny(name, `/\`) || filepath.Base(name) != name {
fail(c, 400, "Invalid preview filename")
return
}
path := filepath.Join(h.Files.Root(), "previews", name)
info, err := os.Stat(path)
if err != nil || info.IsDir() {
fail(c, 404, "Preview not found")
return
}
c.Header("Cache-Control", "public, max-age=3600")
c.File(path)
}
+228
View File
@@ -0,0 +1,228 @@
package themes_handler
import (
"fmt"
"log"
"net/http"
"strconv"
"strings"
"autolibrary.top/webserver/backend/models/themes_model"
"github.com/gin-gonic/gin"
)
func (h *Handler) GetFeaturedThemes(c *gin.Context) {
limitStr := c.Query("limit")
limit := 6
if limitStr != "" {
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 && v <= 20 {
limit = v
}
}
var themes []themes_model.Theme
result := h.DB.
Where("status = ?", themes_model.ThemeApproved).
Order("download_count DESC").
Limit(limit).
Find(&themes)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get featured themes: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get featured themes",
"data": nil,
})
return
}
for i := range themes {
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
}
log.Printf("[I] GET > get featured themes (limit=%d) successful", limit)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get featured themes successful",
"data": themes,
})
}
// GET /api/themes?page=1&per_page=9&need_theme=&tags=&search=&sort=popular
func (h *Handler) GetThemes(c *gin.Context) {
pageStr := c.Query("page")
perPageStr := c.Query("per_page")
needTheme := c.Query("need_theme")
tagsStr := c.Query("tags")
search := c.Query("search")
sort := c.Query("sort")
if pageStr == "" {
pageStr = "1"
}
if perPageStr == "" {
perPageStr = "9"
}
if sort == "" {
sort = "popular"
}
page, err := strconv.Atoi(pageStr)
if err != nil || page <= 0 {
page = 1
}
perPage, err := strconv.Atoi(perPageStr)
if err != nil || perPage <= 0 || perPage > 50 {
perPage = 9
}
offset := (page - 1) * perPage
query := h.DB.Where("status = ?", themes_model.ThemeApproved)
if needTheme != "" {
if needTheme == "both" {
query = query.Where("need_theme = ?", "both")
} else if needTheme == "dark" || needTheme == "light" {
query = query.Where("need_theme = ? OR need_theme = ?", needTheme, "both")
}
}
if tagsStr != "" {
tags := strings.Split(tagsStr, ",")
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag != "" {
query = query.Where("tags LIKE ?", fmt.Sprintf("%%%s%%", tag))
}
}
}
if search != "" {
like := fmt.Sprintf("%%%s%%", search)
query = query.Where("name LIKE ? OR brief LIKE ? OR author LIKE ?", like, like, like)
}
switch sort {
case "newest":
query = query.Order("created_at DESC")
case "name":
query = query.Order("name ASC")
default: // popular
query = query.Order("download_count DESC")
}
var total int64
if result := query.Model(&themes_model.Theme{}).Count(&total); result.Error != nil {
log.Printf("[E] GET > occur an error when count themes: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get themes",
"data": nil,
})
return
}
var themes []themes_model.Theme
result := query.Limit(perPage).Offset(offset).Find(&themes)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get themes (page=%d, per_page=%d): %v", page, perPage, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get themes",
"data": nil,
})
return
}
for i := range themes {
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
}
totalPages := int(total) / perPage
if int(total)%perPage != 0 {
totalPages++
}
log.Printf("[I] GET > get themes (page=%d, per_page=%d, total=%d) successful", page, perPage, total)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get themes successful",
"data": gin.H{
"themes": themes,
"meta": gin.H{
"page": page,
"per_page": perPage,
"total": total,
"total_pages": totalPages,
"has_next": page < totalPages,
},
},
})
}
// GET /api/themes/:id
func (h *Handler) GetThemeByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] GET > try to get theme, but got invalid id: '%s'", c.Param("id"))
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > invalid id",
"data": nil,
})
return
}
var theme themes_model.Theme
result := h.DB.Where("status = ?", themes_model.ThemeApproved).First(&theme, id)
if result.Error != nil {
log.Printf("[E] GET > theme (id=%d) can not found: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "GET > theme not found",
"data": nil,
})
return
}
theme.Author = h.Users.ResolveAuthor(theme.UserID, theme.Author)
theme.Verified = h.Users.IsVerified(theme.UserID)
log.Printf("[I] GET > get theme (id=%d) successful", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get theme successful",
"data": theme,
})
}
// GET /api/themes/:id/download
func (h *Handler) GetUserThemes(c *gin.Context) {
userID, _ := c.Get("user_id")
var themes []themes_model.Theme
result := h.DB.
Where("user_id = ?", userID.(string)).
Order("created_at DESC").
Find(&themes)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get user themes: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "获取用户主题失败", "data": nil})
return
}
for i := range themes {
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
}
log.Printf("[I] GET > get user themes (user_id=%s, count=%d) successful", userID.(string), len(themes))
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "GET > get user themes successful", "data": themes})
}
+121
View File
@@ -0,0 +1,121 @@
package themes_handler
import (
"log"
"net/http"
"strconv"
"autolibrary.top/webserver/backend/models/themes_model"
"autolibrary.top/webserver/utils"
"github.com/gin-gonic/gin"
)
func (h *Handler) GetPendingThemes(c *gin.Context) {
var themes []themes_model.Theme
result := h.DB.
Where("status = ?", themes_model.ThemePending).
Order("created_at ASC").
Find(&themes)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get pending themes: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get pending themes",
"data": nil,
})
return
}
for i := range themes {
themes[i].Author = h.Users.ResolveAuthor(themes[i].UserID, themes[i].Author)
themes[i].Verified = h.Users.IsVerified(themes[i].UserID)
}
log.Printf("[I] GET > get pending themes (count=%d) successful", len(themes))
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get pending themes successful",
"data": themes,
})
}
// PUT /api/admin/themes/:id/review
func (h *Handler) ReviewTheme(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] PUT > try to review theme, but got invalid id: '%s'", c.Param("id"))
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > invalid id",
"data": nil,
})
return
}
var body struct {
Action string `json:"action"`
Comment string `json:"comment"`
}
if err := c.ShouldBindJSON(&body); err != nil {
log.Printf("[E] PUT > try to review theme (id=%d), but got invalid request data: %v", id, err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "PUT > invalid request body",
"data": nil,
})
return
}
if body.Action != "approved" && body.Action != "rejected" {
log.Printf("[E] PUT > try to review theme (id=%d), but got invalid action: '%s'", id, body.Action)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "操作必须为通过或退回",
"data": nil,
})
return
}
var theme themes_model.Theme
result := h.DB.First(&theme, id)
if result.Error != nil {
log.Printf("[E] PUT > theme (id=%d) can not found for review: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "GET > theme not found",
"data": nil,
})
return
}
theme.Status = themes_model.ThemeStatus(body.Action)
theme.ReviewComment = body.Comment
theme.ReviewedBy = c.GetString("user_name")
if theme.ReviewedBy == "" {
theme.ReviewedBy = "api-key"
}
theme.UpdatedAt = utils.NowInShanghai()
result = h.DB.Save(&theme)
if result.Error != nil {
log.Printf("[E] PUT > occur an error when review theme (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "审核操作失败",
"data": nil,
})
return
}
theme.Author = h.Users.ResolveAuthor(theme.UserID, theme.Author)
theme.Verified = h.Users.IsVerified(theme.UserID)
log.Printf("[I] PUT > review theme (id=%d, action=%s) successful", id, body.Action)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "PUT > review theme successful",
"data": theme,
})
}
+100
View File
@@ -0,0 +1,100 @@
package themes_handler
import (
"strconv"
"strings"
"unicode/utf8"
"autolibrary.top/webserver/backend/models/themes_model"
"autolibrary.top/webserver/utils"
"github.com/gin-gonic/gin"
)
func parseThemeID(c *gin.Context) (uint, error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
if err != nil || id == 0 {
return 0, strconv.ErrSyntax
}
return uint(id), nil
}
func (h *Handler) PutThemeByID(c *gin.Context) {
theme, ok := h.ownTheme(c)
if !ok {
return
}
var update struct {
Name string `json:"name"`
Brief string `json:"brief"`
Description string `json:"description"`
NeedTheme string `json:"need_theme"`
Tags []string `json:"tags"`
Version string `json:"version"`
CompatVersion string `json:"compat_version"`
PreviewURL string `json:"preview_url"`
}
if err := c.ShouldBindJSON(&update); err != nil {
fail(c, 400, "Invalid theme metadata")
return
}
if update.Name != "" && strings.TrimSpace(update.Name) != theme.Name {
var count int64
if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ? AND id != ?", theme.UserID, strings.TrimSpace(update.Name), theme.ID).Count(&count).Error; err != nil {
fail(c, 500, "Unable to check theme name")
return
}
if count > 0 {
fail(c, 409, "You already have a theme with this name")
return
}
theme.Name = strings.TrimSpace(update.Name)
}
if update.NeedTheme != "" && update.NeedTheme != "dark" && update.NeedTheme != "light" && update.NeedTheme != "both" {
fail(c, 400, "Invalid theme mode")
return
}
if utf8.RuneCountInString(update.Brief) > 50 || utf8.RuneCountInString(update.Description) > 100 {
fail(c, 400, "Theme description is too long")
return
}
if strings.TrimSpace(update.Brief) != "" {
theme.Brief = strings.TrimSpace(update.Brief)
}
theme.Description = update.Description
if update.NeedTheme != "" {
theme.NeedTheme = update.NeedTheme
}
if update.Tags != nil {
theme.Tags = update.Tags
}
if update.Version != "" {
theme.Version = update.Version
}
theme.CompatVersion = update.CompatVersion
// Preview changes go through the authenticated image upload endpoint. Preserve
// the legacy field only when it refers to the theme's existing preview.
if update.PreviewURL != "" && update.PreviewURL != theme.PreviewURL {
fail(c, 400, "Use the preview upload endpoint to replace an image")
return
}
theme.Status = themes_model.ThemePending
theme.UpdatedAt = utils.NowInShanghai()
if err := h.Themes.Save(theme, nil); err != nil {
fail(c, 500, "Unable to save theme and archive")
return
}
success(c, 200, "Theme updated; review required", theme)
}
func (h *Handler) DeleteThemeByID(c *gin.Context) {
theme, ok := h.ownTheme(c)
if !ok {
return
}
if err := h.Themes.Delete(theme); err != nil {
fail(c, 500, "Unable to delete theme")
return
}
success(c, 200, "Theme deleted", nil)
}
+223
View File
@@ -0,0 +1,223 @@
package themes_handler
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"strings"
"unicode/utf8"
"autolibrary.top/webserver/backend/models/themes_model"
"autolibrary.top/webserver/backend/services/theme_files"
"autolibrary.top/webserver/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func fail(c *gin.Context, status int, message string) {
c.JSON(status, gin.H{"code": status, "msg": message, "data": nil})
}
func success(c *gin.Context, status int, message string, data interface{}) {
c.JSON(status, gin.H{"code": status, "msg": message, "data": data})
}
func (h *Handler) ownTheme(c *gin.Context) (*themes_model.Theme, bool) {
var theme themes_model.Theme
id, err := parseThemeID(c)
if err != nil {
fail(c, http.StatusBadRequest, "Invalid theme ID")
return nil, false
}
err = h.DB.First(&theme, id).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, http.StatusNotFound, "Theme not found")
return nil, false
}
if err != nil {
fail(c, http.StatusInternalServerError, "Unable to load theme")
return nil, false
}
if theme.UserID != c.GetString("user_id") {
fail(c, http.StatusForbidden, "You can only edit your own themes")
return nil, false
}
return &theme, true
}
// readUpload enforces the same limits advertised by the web form.
func readUpload(c *gin.Context, field string, extensions []string, max int64) ([]byte, error) {
file, header, err := c.Request.FormFile(field)
if err != nil {
return nil, err
}
defer file.Close()
ext := strings.ToLower(filepath.Ext(header.Filename))
valid := false
for _, candidate := range extensions {
if ext == candidate {
valid = true
}
}
if !valid {
return nil, fmt.Errorf("invalid %s file type", field)
}
if header.Size > max {
return nil, fmt.Errorf("%s file is too large", field)
}
data, err := io.ReadAll(io.LimitReader(file, max+1))
if err != nil {
return nil, err
}
if int64(len(data)) > max {
return nil, fmt.Errorf("%s file is too large", field)
}
if len(data) == 0 {
return nil, fmt.Errorf("%s file is empty", field)
}
return data, nil
}
func (h *Handler) savePreview(data []byte) (string, error) {
compressed, err := theme_files.CompressPreviewImage(data)
if err != nil {
return "", err
}
name := fmt.Sprintf("preview_%d.jpg", utils.NowInShanghai().UnixNano())
if err := h.Files.WriteFile(filepath.Join(h.Files.Root(), "previews", name), compressed, 0644); err != nil {
return "", err
}
return "/marketplace/themes/preview/" + name, nil
}
func (h *Handler) PostTheme(c *gin.Context) {
var metadata struct {
Name string `json:"name"`
Brief string `json:"brief"`
Description string `json:"description"`
NeedTheme string `json:"need_theme"`
Tags []string `json:"tags"`
Version string `json:"version"`
CompatVersion string `json:"compat_version"`
}
if err := json.Unmarshal([]byte(c.PostForm("metadata")), &metadata); err != nil {
fail(c, 400, "Invalid theme metadata")
return
}
metadata.Name = strings.TrimSpace(metadata.Name)
metadata.Brief = strings.TrimSpace(metadata.Brief)
if metadata.Name == "" || metadata.Brief == "" || utf8.RuneCountInString(metadata.Brief) > 50 || utf8.RuneCountInString(metadata.Description) > 100 {
fail(c, 400, "Invalid name, brief or description")
return
}
if metadata.NeedTheme != "dark" && metadata.NeedTheme != "light" && metadata.NeedTheme != "both" {
fail(c, 400, "Invalid theme mode")
return
}
var count int64
if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ?", c.GetString("user_id"), metadata.Name).Count(&count).Error; err != nil {
fail(c, 500, "Unable to check theme name")
return
}
if count > 0 {
fail(c, 409, "You already have a theme with this name")
return
}
qss, err := readUpload(c, "file", []string{".qss"}, 8<<20)
if err != nil {
fail(c, 400, err.Error())
return
}
previewData, err := readUpload(c, "preview", []string{".png", ".jpg", ".jpeg"}, 4<<20)
if err != nil && !errors.Is(err, http.ErrMissingFile) {
fail(c, 400, err.Error())
return
}
var preview string
if len(previewData) > 0 {
preview, err = h.savePreview(previewData)
if err != nil {
fail(c, 400, "Unable to process preview image")
return
}
}
author := c.GetString("display_name")
if author == "" {
author = c.GetString("user_name")
}
theme := themes_model.Theme{Name: metadata.Name, Brief: metadata.Brief, Description: metadata.Description, NeedTheme: metadata.NeedTheme, Tags: metadata.Tags, Version: metadata.Version, CompatVersion: metadata.CompatVersion, UserID: c.GetString("user_id"), Author: author, Status: themes_model.ThemePending, PreviewURL: preview, CreatedAt: utils.NowInShanghai(), UpdatedAt: utils.NowInShanghai()}
if err := h.Themes.Create(&theme, qss); err != nil {
if preview != "" {
_ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(preview)))
}
fail(c, 500, "Unable to save theme and archive")
return
}
success(c, http.StatusCreated, "Theme submitted for review", theme)
}
func (h *Handler) CheckThemeName(c *gin.Context) {
name := strings.TrimSpace(c.Query("name"))
if name == "" {
fail(c, 400, "Name is required")
return
}
var count int64
if err := h.DB.Model(&themes_model.Theme{}).Where("user_id = ? AND name = ?", c.GetString("user_id"), name).Count(&count).Error; err != nil {
fail(c, 500, "Unable to check theme name")
return
}
success(c, 200, "Theme name checked", count > 0)
}
func (h *Handler) UploadPreview(c *gin.Context) {
theme, ok := h.ownTheme(c)
if !ok {
return
}
data, err := readUpload(c, "preview", []string{".png", ".jpg", ".jpeg"}, 4<<20)
if err != nil {
fail(c, 400, err.Error())
return
}
preview, err := h.savePreview(data)
if err != nil {
fail(c, 400, "Unable to process preview image")
return
}
previous := theme.PreviewURL
theme.PreviewURL = preview
theme.Status = themes_model.ThemePending
theme.UpdatedAt = utils.NowInShanghai()
if err := h.Themes.Save(theme, nil); err != nil {
_ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(preview)))
fail(c, 500, "Unable to save preview")
return
}
if previous != "" {
_ = h.Files.Remove(filepath.Join(h.Files.Root(), "previews", filepath.Base(previous)))
}
success(c, 200, "Preview updated; review required", gin.H{"preview_url": preview})
}
func (h *Handler) ReplaceThemeFile(c *gin.Context) {
theme, ok := h.ownTheme(c)
if !ok {
return
}
qss, err := readUpload(c, "file", []string{".qss"}, 8<<20)
if err != nil {
fail(c, 400, err.Error())
return
}
theme.Status = themes_model.ThemePending
theme.UpdatedAt = utils.NowInShanghai()
if err := h.Themes.Save(theme, qss); err != nil {
fail(c, 500, "Unable to replace theme file")
return
}
success(c, 200, "File replaced; review required", theme)
}
@@ -0,0 +1,301 @@
package versions_handler
import (
"errors"
"gorm.io/gorm"
"log"
"net/http"
"strconv"
"autolibrary.top/webserver/backend/database/versions_database"
"autolibrary.top/webserver/backend/models/versions_model"
"autolibrary.top/webserver/utils"
"github.com/gin-gonic/gin"
)
/*
These handlers are used to manage download versions.
*/
// GetLatestDownload returns the newest published stable release, or null.
func GetLatestDownload(c *gin.Context) {
var version versions_model.DownloadVersion
err := versions_database.DB.Where("type = ? AND release_date <= ?", "stable", utils.NowInShanghai()).Order("release_date DESC, id DESC").First(&version).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "No stable release available", "data": nil})
return
}
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"code": http.StatusInternalServerError, "msg": "Unable to load latest release", "data": nil})
return
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK, "msg": "Latest stable release", "data": version})
}
// GET /download/versions?page={number}&per_page={number<=50}
func GetDownloadVersions(c *gin.Context) {
// Parse page and perPage parameters
pageStr := c.Query("page")
perPageStr := c.Query("per_page")
// Default values for page and perPage
if pageStr == "" {
pageStr = "1"
}
if perPageStr == "" {
perPageStr = "9"
}
page, err := strconv.Atoi(pageStr)
if err != nil {
log.Printf("[E] GET > try to get downloads, but got invalid parameter: 'page=%s'", pageStr)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > try to get downloads, but got invalid parameter: 'page'",
"data": nil,
})
return
}
perPage, err := strconv.Atoi(perPageStr)
if err != nil {
log.Printf("[E] GET > try to get downloads (page=%d), but got invalid parameter: 'per_page=%s'", page, perPageStr)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > try to get downloads, but got invalid parameter: 'per_page'",
"data": nil,
})
return
}
// constrain page and perPage parameters
if page <= 0 {
page = 1
}
if perPage <= 0 || perPage > 50 {
perPage = 9
}
// Calculate offset
offset := (page - 1) * perPage
var downloads []versions_model.DownloadVersion
result := versions_database.DB.Order("id DESC").Limit(perPage).Offset(offset).Find(&downloads)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get downloads (page=%d, per_page=%d): %v", page, perPage, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get downloads",
"data": nil,
})
return
}
var total int64
result = versions_database.DB.Model(&versions_model.DownloadVersion{}).Count(&total)
if result.Error != nil {
log.Printf("[E] GET > occur an error when get downloads (page=%d, per_page=%d): %v", page, perPage, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "GET > occur an error when get downloads",
"data": nil,
})
return
}
totalPages := int(total) / perPage
if int(total)%perPage != 0 {
totalPages++
}
pageMeta := gin.H{
"page": page,
"per_page": perPage,
"total": total,
"total_pages": totalPages,
"has_next": page < totalPages,
}
log.Printf("[I] GET > get downloads (page=%d, per_page=%d) successful", page, perPage)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get downloads successful",
"data": gin.H{
"downloads": downloads,
"meta": pageMeta,
},
})
}
// GET /download/versions/:id
func GetDownloadVersionByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] GET > try to get download, but got invalid id: '%s'", c.Param("id"))
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "GET > try to get download, but got invalid id",
"data": nil,
})
return
}
var download versions_model.DownloadVersion
result := versions_database.DB.First(&download, id)
if result.Error != nil {
log.Printf("[E] GET > download (id=%d) can not found: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "GET > download can not found",
"data": nil,
})
return
}
log.Printf("[I] GET > get download (id=%d) successful", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "GET > get download successful",
"data": download,
})
}
// POST /download/versions
func PostDownloadVersion(c *gin.Context) {
var download versions_model.DownloadVersion
if err := c.ShouldBindJSON(&download); err != nil {
log.Printf("[E] POST > try to create download, but got invalid request data: %v", err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "POST > try to create download, but got invalid request data",
"data": nil,
})
return
}
download.CreatedAt = utils.NowInShanghai()
result := versions_database.DB.Create(&download)
if result.Error != nil {
log.Printf("[E] POST > occur an error when create download: %v", result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "POST > occur an error when create download",
"data": nil,
})
return
}
id := strconv.FormatUint(uint64(download.ID), 10)
log.Printf("[I] POST > create download (id=%s) successful", id)
c.JSON(http.StatusCreated, gin.H{
"code": http.StatusCreated,
"msg": "POST > create download successful",
"data": download,
})
}
// PUT /download/versions/:id
func PutDownloadVersionByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] PUT > try to update download, but got invalid id: '%s'", c.Param("id"))
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "PUT > try to update download, but got invalid id",
"data": nil,
})
return
}
var updateDownload versions_model.DownloadVersion
if err = c.ShouldBindJSON(&updateDownload); err != nil {
log.Printf("[E] PUT > try to update download (id=%d), but got invalid request data: %v", id, err)
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "PUT > try to update download, but got invalid request data",
"data": nil,
})
return
}
var existingDownload versions_model.DownloadVersion
result := versions_database.DB.First(&existingDownload, id)
if result.Error != nil {
log.Printf("[E] PUT > download (id=%d) can not found: %v", id, result.Error)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "PUT > download can not found",
"data": nil,
})
return
}
result = versions_database.DB.Model(&versions_model.DownloadVersion{}).Where("id = ?", id).Updates(updateDownload)
if result.Error != nil {
log.Printf("[E] PUT > occur an error when update download (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "PUT > occur an error when update download",
"data": nil,
})
return
}
// post check after update
var updatedDownload versions_model.DownloadVersion
if result = versions_database.DB.First(&updatedDownload, id); result.Error == nil {
log.Printf("[I] PUT > update download (id=%d) successful", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "PUT > update download successful",
"data": updatedDownload,
})
} else {
log.Printf("[W] PUT > update successful but failed to retrieve updated download (id=%d): %v", id, result.Error)
c.JSON(http.StatusAccepted, gin.H{
"code": http.StatusAccepted,
"msg": "PUT > update successful but failed to retrieve updated download",
"data": nil,
})
}
}
// DELETE /download/versions/:id
func DeleteDownloadVersionByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
log.Printf("[E] DELETE > try to delete download, but got invalid id: %s", c.Param("id"))
c.JSON(http.StatusBadRequest, gin.H{
"code": http.StatusBadRequest,
"msg": "DELETE > try to delete download, but got invalid id",
"data": nil,
})
return
}
result := versions_database.DB.Delete(&versions_model.DownloadVersion{}, id)
if result.Error != nil {
log.Printf("[E] DELETE > occur an error when delete download (id=%d): %v", id, result.Error)
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "DELETE > occur an error when delete download",
"data": nil,
})
return
}
if result.RowsAffected == 0 {
log.Printf("[E] DELETE > try to delete download (id=%d), but RowsAffected is 0", id)
c.JSON(http.StatusNotFound, gin.H{
"code": http.StatusNotFound,
"msg": "DELETE > delete download has no effect",
"data": nil,
})
return
}
log.Printf("[I] DELETE > delete download (id=%d) successful", id)
c.JSON(http.StatusOK, gin.H{
"code": http.StatusOK,
"msg": "DELETE > delete download successful",
"data": nil,
})
}
+6
View File
@@ -0,0 +1,6 @@
package middlewares
import (
_ "autolibrary.top/webserver/backend/middlewares/auth_middleware"
_ "autolibrary.top/webserver/backend/middlewares/casdoor_middleware"
)
@@ -0,0 +1,49 @@
package auth_middleware
import (
"log"
"net/http"
"autolibrary.top/webserver/config"
"github.com/gin-gonic/gin"
)
func APIKeyAuth() gin.HandlerFunc {
return func(c *gin.Context) {
cfg := config.GetConfig()
if !cfg.Backend.Auth.Required {
c.Next()
return
}
apiKey := c.GetHeader("X-API-Key")
if apiKey == "" {
apiKey = c.Query("api_key")
}
// Check if API key is configured
expectedKey := cfg.Backend.Auth.Key
if expectedKey == "" || expectedKey == "null" {
log.Printf("[E] AUTH > API key not configured")
c.JSON(http.StatusInternalServerError, gin.H{
"code": http.StatusInternalServerError,
"msg": "AUTH > API key has not been configured on this server",
})
c.Abort()
return
}
// Check if API key is valid
if apiKey == "" || apiKey != expectedKey {
log.Printf("[W] AUTH > invalid API key or key is missing")
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"msg": "AUTH > invalid API key or key is missing",
})
c.Abort()
return
}
c.Next()
}
}
@@ -0,0 +1,92 @@
package casdoor_middleware
import (
"log"
"net/http"
"strings"
"autolibrary.top/webserver/config"
"github.com/casdoor/casdoor-go-sdk/casdoorsdk"
"github.com/gin-gonic/gin"
)
func CasdoorAuth() gin.HandlerFunc {
cfg := config.GetConfig()
return func(c *gin.Context) {
// If Casdoor is not configured, return 501 Not Implemented
if cfg.Backend.Casdoor.Endpoint == "" || cfg.Backend.Casdoor.ClientID == "" {
c.JSON(http.StatusNotImplemented, gin.H{
"code": http.StatusNotImplemented,
"msg": "AUTH > Casdoor authentication is not configured on this server",
"data": nil,
})
c.Abort()
return
}
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"msg": "AUTH > missing Authorization header",
"data": nil,
})
c.Abort()
return
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader {
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"msg": "AUTH > Authorization header must be Bearer <token>",
"data": nil,
})
c.Abort()
return
}
claims, err := casdoorsdk.ParseJwtToken(token)
if err != nil {
log.Printf("[W] AUTH > invalid JWT token: %v", err)
c.JSON(http.StatusUnauthorized, gin.H{
"code": http.StatusUnauthorized,
"msg": "AUTH > invalid or expired token",
"data": nil,
})
c.Abort()
return
}
c.Set("user_id", claims.User.Id)
c.Set("user_name", claims.User.Name)
c.Set("display_name", claims.User.DisplayName)
c.Set("is_admin", claims.User.IsAdmin)
c.Set("user_tag", claims.User.Tag)
c.Next()
}
}
// CasdoorAdminAuth requires both valid Casdoor JWT and admin privileges
func CasdoorAdminAuth() gin.HandlerFunc {
auth := CasdoorAuth()
return func(c *gin.Context) {
auth(c)
if c.IsAborted() {
return
}
isAdmin, _ := c.Get("is_admin")
if isAdmin == nil || !isAdmin.(bool) {
c.JSON(http.StatusForbidden, gin.H{
"code": http.StatusForbidden,
"msg": "AUTH > admin privileges required",
"data": nil,
})
c.Abort()
return
}
c.Next()
}
}
+8
View File
@@ -0,0 +1,8 @@
package models
import (
_ "autolibrary.top/webserver/backend/models/base_model"
_ "autolibrary.top/webserver/backend/models/versions_model"
_ "autolibrary.top/webserver/backend/models/bulletins_model"
_ "autolibrary.top/webserver/backend/models/themes_model"
)
+45
View File
@@ -0,0 +1,45 @@
package base_model
import (
"fmt"
"encoding/json"
"database/sql/driver"
)
type StringArray []string
func (s StringArray) Value() (driver.Value, error) {
if len(s) == 0 {
return "[]", nil // empty array
}
return json.Marshal(s)
}
func (s *StringArray) Scan(value interface{}) error {
if value == nil {
*s = StringArray{}
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return fmt.Errorf("unsupported type: %T", value)
}
if len(bytes) == 0 {
*s = StringArray{}
return nil
}
if string(bytes) == "[]" {
*s = StringArray{}
return nil
}
return json.Unmarshal(bytes, s)
}
+18
View File
@@ -0,0 +1,18 @@
package bulletins_model
import (
"time"
)
type Bulletin struct {
ID uint `json:"id" gorm:"primaryKey"`
DateTime time.Time `json:"dateTime" gorm:"index"` // Time zone: Asia/Shanghai
Author string `json:"author" gorm:"type:varchar(20);index"`
Title string `json:"title"`
Content string `json:"content"`
// we use soft delete, to keep the uniqueness of ID
LastEditAt time.Time `json:"lastEditAt"` // Time zone: Asia/Shanghai
IsEdited bool `json:"isEdited" gorm:"default:false"`
IsDeleted bool `json:"isDeleted" gorm:"default:false"`
CreateAt time.Time `json:"createAt"` // Time zone: Asia/Shanghai
}
+38
View File
@@ -0,0 +1,38 @@
package themes_model
import (
"time"
"autolibrary.top/webserver/backend/models/base_model"
)
type ThemeStatus string
const (
ThemePending ThemeStatus = "pending"
ThemeApproved ThemeStatus = "approved"
ThemeRejected ThemeStatus = "rejected"
)
type Theme struct {
ID uint `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Author string `json:"author"`
UserID string `json:"userId" gorm:"index"`
NeedTheme string `json:"needTheme" gorm:"type:varchar(10);index"`
Brief string `json:"brief"`
Description string `json:"description"`
Tags base_model.StringArray `json:"tags" gorm:"type:text"`
Status ThemeStatus `json:"status" gorm:"type:varchar(20);index;default:pending"`
FilePath string `json:"-" gorm:"type:text"`
FileSize string `json:"fileSize"`
PreviewURL string `json:"previewUrl"`
DownloadCount uint `json:"downloadCount" gorm:"default:0"`
Version string `json:"version"`
CompatVersion string `json:"compatVersion"`
ReviewedBy string `json:"reviewedBy"`
ReviewComment string `json:"reviewComment"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
Verified bool `json:"verified" gorm:"-"`
}
+29
View File
@@ -0,0 +1,29 @@
package versions_model
import (
"time"
)
type VersionType string
type DownloadVersion struct {
ID uint `json:"id" gorm:"primaryKey"`
Version string `json:"version"`
Type VersionType `json:"type" gorm:"type:varchar(20);index"`
ReleaseDate time.Time `json:"releaseDate"` // Time zone: Asia/Shanghai
FileSize string `json:"fileSize"`
SupportOS string `json:"supportOS"`
Features string `json:"features"` // separated by ','
GitHubZipURL string `json:"githubZipUrl"`
GitHubInstallerURL string `json:"githubInstallerUrl"`
CloudZipURL string `json:"cloudZipUrl"`
CloudInstallerURL string `json:"cloudInstallerUrl"`
GitHubDmgURL string `json:"githubDmgUrl"`
GiteaZipURL string `json:"giteaZipUrl"`
GiteaInstallerURL string `json:"giteaInstallerUrl"`
GiteaDmgURL string `json:"giteaDmgUrl"`
ReleaseNotesURL string `json:"releaseNotesUrl"`
CloudDmgURL string `json:"cloudDmgUrl"`
CloudPassword string `json:"cloudPassword"`
CreatedAt time.Time `json:"createdAt"`
}
+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 }
+118
View File
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/styles/github.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.7.0/highlight.min.js"></script>
<title>{{.title}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
color: #333;
}
h1 {
color: #dc2626;
border-bottom: 1px solid #e5e5e5;
padding-bottom: 0.5rem;
}
.json-container {
background: #f8f8f8;
border: 1px solid #e0e0e0;
border-radius: 4px;
padding: 1rem;
margin: 1rem 0;
font-family: 'Monaco', 'Consolas', monospace;
white-space: pre-wrap;
overflow-x: auto;
}
.links {
margin-top: 1.5rem;
}
.links a {
display: inline-block;
margin-right: 1rem;
margin-bottom: 0.5rem;
color: #2563eb;
text-decoration: none;
}
.links a:hover {
text-decoration: underline;
}
.info {
background: #eff6ff;
border-left: 4px solid #3b82f6;
padding: 0.75rem;
margin: 1rem 0;
}
.code {
font-size: 0.9em;
font-family: 'Monaco', 'Consolas', monospace;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #1a1a1a;
color: #e5e5e5;
}
h1 {
color: #f87171;
border-bottom-color: #404040;
}
.json-container {
background-color: #2d2d2d;
border-color: #404040;
color: #d4d4d4;
}
.json-container > .hljs-string {
color: #a8d0ff !important;
}
.info {
background-color: #1e3a5f;
border-left-color: #60a5fa;
}
.links a {
color: #93c5fd;
}
.links a:hover {
color: #bfdbfe;
}
}
</style>
</head>
<body>
<h1>404 - Not Found</h1>
<p>The requested API endpoint '<span class="code">{{.path}}</span>' was not found on this server.</p>
<div class="info">
<strong>API Response (JSON format):</strong>
</div>
<div class="json-container" id="jsonData">
{{.jsonData}}
</div>
<div class="links">
<strong>API links:</strong><br>
<a href="/health" target="_blank">Check API health</a>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
try {
const jsonContainer = document.getElementById('jsonData');
const jsonData = JSON.parse(jsonContainer.textContent.replace(/&quot;/g, '"'));
const formattedJson = JSON.stringify(jsonData, null, 2);
jsonContainer.textContent = formattedJson;
hljs.highlightElement(jsonContainer);
} catch (e) {
console.error('Error formatting JSON:', e);
jsonContainer.style.color = 'red';
}
});
</script>
</body>
</html>